From a790b62405d67a139161f84e35a1d46dcff82e1b Mon Sep 17 00:00:00 2001 From: jakegrigsby Date: Thu, 21 May 2026 21:32:02 -0500 Subject: [PATCH 1/4] may 2026 metamon-dev sync --- .github/workflows/black-fix.yml | 51 + .github/workflows/static.yml | 45 - .gitignore | 36 +- .gitmodules | 2 +- .pre-commit-config.yaml | 5 + .vscode/settings.json | 16 + README.md | 268 +- examples/evaluate_custom_models.py | 61 +- media/icons/tauros.png | Bin 0 -> 299604 bytes media/metamon_logo.png | Bin 0 -> 692426 bytes metamon/__init__.py | 6 +- metamon/backend/replay_parser/__main__.py | 41 +- metamon/backend/replay_parser/backward.py | 10 +- metamon/backend/replay_parser/checks.py | 4 +- metamon/backend/replay_parser/forward.py | 2 +- metamon/backend/replay_parser/replay_state.py | 2 +- metamon/backend/replay_parser/str_parsing.py | 8 +- metamon/backend/team_construction/__init__.py | 7 + .../backend/team_construction/artifacts.py | 19 + metamon/backend/team_construction/cli.py | 2483 +++++++++++++++++ .../team_construction/coordinate_ascent.py | 315 +++ metamon/backend/team_construction/core.py | 77 + .../team_construction/feature_baseline.py | 33 + .../team_construction/feature_interaction.py | 152 + .../team_construction/feature_sparse.py | 29 + metamon/backend/team_construction/matchup.py | 297 ++ .../backend/team_construction/model_fit.py | 266 ++ .../team_construction/model_scoring.py | 118 + .../backend/team_construction/pokemon_pool.py | 463 +++ .../team_construction/restricted_game.py | 234 ++ .../backend/team_construction/simulation.py | 794 ++++++ .../backend/team_construction/teams/parse.py | 246 ++ .../team_construction/teams/retrieval.py | 312 +++ metamon/backend/team_construction/train.py | 1020 +++++++ metamon/backend/team_construction/update.py | 411 +++ metamon/backend/team_prediction/.gitignore | 8 + .../team_prediction/build_replay_team_sets.py | 378 +++ .../compute_revealed_scores.py | 236 ++ metamon/backend/team_prediction/dataset.py | 464 +-- .../backend/team_prediction/filter_elite.py | 141 + .../team_prediction/generate_replay_stats.py | 6 +- .../generate_teamsets_from_replays.py | 127 - .../team_prediction/iterative_decoder.py | 568 ++++ metamon/backend/team_prediction/masking.py | 146 + metamon/backend/team_prediction/model.py | 115 - .../team_prediction/prediction_metrics.py | 533 ++++ .../team_prediction/prediction_model.py | 534 ++++ metamon/backend/team_prediction/predictor.py | 99 +- metamon/backend/team_prediction/team.py | 502 +++- metamon/backend/team_prediction/team_index.py | 167 ++ metamon/backend/team_prediction/train.py | 447 --- .../team_prediction/train_prediction_model.py | 1562 +++++++++++ .../team_prediction/usage_stats/__init__.py | 13 +- .../usage_stats/create_usage_jsons.py | 52 +- .../usage_stats/legacy_team_builder.py | 39 +- .../usage_stats/stat_reader.py | 455 ++- .../usage_stats/stat_scraper.py | 343 ++- metamon/backend/team_prediction/validate.py | 531 +++- metamon/backend/team_prediction/vocabulary.py | 39 +- .../team_prediction/write_team_indexes.py | 101 + metamon/backend/team_preview/preview.py | 4 + metamon/config.py | 6 + metamon/data/__init__.py | 29 +- metamon/data/download.py | 123 +- metamon/data/parsed_replay_dset.py | 115 +- metamon/env/__init__.py | 1 + metamon/env/__main__.py | 2 - metamon/env/metamon_battle.py | 4 +- metamon/env/metamon_player.py | 12 +- metamon/env/wrappers.py | 197 +- metamon/il/model.py | 8 +- metamon/interface.py | 227 +- metamon/rl/__init__.py | 1 + metamon/rl/configs/__init__.py | 0 metamon/rl/configs/datasets/__init__.py | 0 .../rl/configs/datasets/self_play_dset.yaml | 8 + metamon/rl/configs/models/__init__.py | 0 metamon/rl/configs/models/alakazam.gin | 57 - metamon/rl/configs/models/grouped_v2_50m.gin | 76 + metamon/rl/configs/models/small_rnn.gin | 40 - .../configs/models/smaller_multitaskagent.gin | 56 + .../smaller_multitaskagent_grouped_v2.gin | 72 + ...smaller_multitaskagent_grouped_v2_arch.gin | 75 + metamon/rl/configs/training/__init__.py | 0 metamon/rl/configs/training/alakazam2.gin | 3 +- metamon/rl/configs/training/alakazam3.gin | 3 +- .../configs/training/alakazam3_isfilter.gin | 44 + .../{alakazam.gin => alakazam_beta0.1.gin} | 21 +- .../rl/configs/training/alakazam_beta1.gin | 30 + .../rl/configs/training/alakazam_beta10.gin | 30 + .../rl/configs/training/alakazam_beta3.gin | 30 + .../configs/training/alakazam_bnorm_beta3.gin | 31 + .../training/alakazam_bnorm_beta3_hlgauss.gin | 40 + .../alakazam_bnorm_beta3_hlgauss_vanilla.gin | 46 + .../rl/configs/training/binary_maxq_rl.gin | 3 +- metamon/rl/configs/training/binary_rl.gin | 3 +- metamon/rl/configs/training/exp_rl.gin | 3 +- metamon/rl/configs/training/finetune.gin | 45 + .../training/grouped_v2_large_isfilter.gin | 43 + metamon/rl/configs/training/il.gin | 7 +- metamon/rl/configs/training/kakuna.gin | 3 +- metamon/rl/custom_agent.py | 527 ++++ metamon/rl/dataset_config.py | 532 ++++ metamon/rl/evaluate/README.md | 295 ++ metamon/rl/evaluate/__init__.py | 6 + .../rl/{evaluate.py => evaluate/__main__.py} | 111 +- metamon/rl/evaluate/common.py | 550 ++++ metamon/rl/evaluate/h2h/__init__.py | 0 metamon/rl/evaluate/h2h/__main__.py | 100 + metamon/rl/evaluate/h2h/config.py | 84 + metamon/rl/evaluate/h2h/example_config.yaml | 22 + .../rl/evaluate/ladder_self_play/__init__.py | 0 .../rl/evaluate/ladder_self_play/__main__.py | 8 + .../ladder_self_play/example_config.yaml | 24 + .../ladder_self_play}/launch_models.py | 341 ++- .../ladder_self_play}/serve_model.py | 39 +- metamon/rl/evaluate/launch.py | 159 ++ metamon/rl/evaluate/preview.py | 252 ++ metamon/rl/evaluate/results.py | 264 ++ metamon/rl/evaluate/serve_matchup.py | 99 + metamon/rl/evaluate/sweep/__init__.py | 0 metamon/rl/evaluate/sweep/__main__.py | 108 + metamon/rl/evaluate/sweep/config.py | 96 + metamon/rl/evaluate/sweep/example_config.yaml | 17 + .../sweep/temperature_sweep_self_play.yaml | 25 + metamon/rl/experimental/__init__.py | 0 metamon/rl/experimental/ensemble/__init__.py | 5 + metamon/rl/experimental/ensemble/agents.yaml | 8 + metamon/rl/experimental/ensemble/ensemble.py | 1571 +++++++++++ .../ensemble/ensemble_presets.json | 1171 ++++++++ metamon/rl/experimental/ensemble/register.py | 81 + metamon/rl/finetune.py | 266 ++ metamon/rl/finetune_from_hf.py | 217 -- metamon/rl/metamon_to_amago.py | 774 ++++- metamon/rl/pretrained.py | 748 ++++- metamon/rl/self_play/README.md | 31 - metamon/rl/self_play/__main__.py | 11 - metamon/rl/self_play/earlygen_config.yaml | 53 - metamon/rl/train.py | 238 +- pyproject.toml | 9 +- server/config.js | 755 ----- tools/analyze_moveset_trends.py | 205 ++ tools/analyze_team_logs.py | 619 ++++ tools/compare_team_sets.py | 500 ++++ tools/flatten_self_play_pile.sh | 187 ++ tools/patch_pokeagent_gen9ou_trajs.py | 5 +- tools/persistent_showdown_validator.js | 132 + 147 files changed, 24942 insertions(+), 3240 deletions(-) create mode 100644 .github/workflows/black-fix.yml delete mode 100644 .github/workflows/static.yml create mode 100644 .pre-commit-config.yaml create mode 100644 .vscode/settings.json create mode 100644 media/icons/tauros.png create mode 100644 media/metamon_logo.png create mode 100644 metamon/backend/team_construction/__init__.py create mode 100644 metamon/backend/team_construction/artifacts.py create mode 100644 metamon/backend/team_construction/cli.py create mode 100644 metamon/backend/team_construction/coordinate_ascent.py create mode 100644 metamon/backend/team_construction/core.py create mode 100644 metamon/backend/team_construction/feature_baseline.py create mode 100644 metamon/backend/team_construction/feature_interaction.py create mode 100644 metamon/backend/team_construction/feature_sparse.py create mode 100644 metamon/backend/team_construction/matchup.py create mode 100644 metamon/backend/team_construction/model_fit.py create mode 100644 metamon/backend/team_construction/model_scoring.py create mode 100644 metamon/backend/team_construction/pokemon_pool.py create mode 100644 metamon/backend/team_construction/restricted_game.py create mode 100644 metamon/backend/team_construction/simulation.py create mode 100644 metamon/backend/team_construction/teams/parse.py create mode 100644 metamon/backend/team_construction/teams/retrieval.py create mode 100644 metamon/backend/team_construction/train.py create mode 100644 metamon/backend/team_construction/update.py create mode 100644 metamon/backend/team_prediction/.gitignore create mode 100644 metamon/backend/team_prediction/build_replay_team_sets.py create mode 100644 metamon/backend/team_prediction/compute_revealed_scores.py create mode 100644 metamon/backend/team_prediction/filter_elite.py delete mode 100644 metamon/backend/team_prediction/generate_teamsets_from_replays.py create mode 100644 metamon/backend/team_prediction/iterative_decoder.py create mode 100644 metamon/backend/team_prediction/masking.py delete mode 100644 metamon/backend/team_prediction/model.py create mode 100644 metamon/backend/team_prediction/prediction_metrics.py create mode 100644 metamon/backend/team_prediction/prediction_model.py create mode 100644 metamon/backend/team_prediction/team_index.py delete mode 100644 metamon/backend/team_prediction/train.py create mode 100644 metamon/backend/team_prediction/train_prediction_model.py create mode 100644 metamon/backend/team_prediction/write_team_indexes.py create mode 100644 metamon/rl/configs/__init__.py create mode 100644 metamon/rl/configs/datasets/__init__.py create mode 100644 metamon/rl/configs/datasets/self_play_dset.yaml create mode 100644 metamon/rl/configs/models/__init__.py delete mode 100644 metamon/rl/configs/models/alakazam.gin create mode 100644 metamon/rl/configs/models/grouped_v2_50m.gin delete mode 100644 metamon/rl/configs/models/small_rnn.gin create mode 100644 metamon/rl/configs/models/smaller_multitaskagent.gin create mode 100644 metamon/rl/configs/models/smaller_multitaskagent_grouped_v2.gin create mode 100644 metamon/rl/configs/models/smaller_multitaskagent_grouped_v2_arch.gin create mode 100644 metamon/rl/configs/training/__init__.py create mode 100644 metamon/rl/configs/training/alakazam3_isfilter.gin rename metamon/rl/configs/training/{alakazam.gin => alakazam_beta0.1.gin} (55%) create mode 100644 metamon/rl/configs/training/alakazam_beta1.gin create mode 100644 metamon/rl/configs/training/alakazam_beta10.gin create mode 100644 metamon/rl/configs/training/alakazam_beta3.gin create mode 100644 metamon/rl/configs/training/alakazam_bnorm_beta3.gin create mode 100644 metamon/rl/configs/training/alakazam_bnorm_beta3_hlgauss.gin create mode 100644 metamon/rl/configs/training/alakazam_bnorm_beta3_hlgauss_vanilla.gin create mode 100644 metamon/rl/configs/training/finetune.gin create mode 100644 metamon/rl/configs/training/grouped_v2_large_isfilter.gin create mode 100644 metamon/rl/custom_agent.py create mode 100644 metamon/rl/dataset_config.py create mode 100644 metamon/rl/evaluate/README.md create mode 100644 metamon/rl/evaluate/__init__.py rename metamon/rl/{evaluate.py => evaluate/__main__.py} (80%) create mode 100644 metamon/rl/evaluate/common.py create mode 100644 metamon/rl/evaluate/h2h/__init__.py create mode 100644 metamon/rl/evaluate/h2h/__main__.py create mode 100644 metamon/rl/evaluate/h2h/config.py create mode 100644 metamon/rl/evaluate/h2h/example_config.yaml create mode 100644 metamon/rl/evaluate/ladder_self_play/__init__.py create mode 100644 metamon/rl/evaluate/ladder_self_play/__main__.py create mode 100644 metamon/rl/evaluate/ladder_self_play/example_config.yaml rename metamon/rl/{self_play => evaluate/ladder_self_play}/launch_models.py (54%) rename metamon/rl/{self_play => evaluate/ladder_self_play}/serve_model.py (84%) create mode 100644 metamon/rl/evaluate/launch.py create mode 100644 metamon/rl/evaluate/preview.py create mode 100644 metamon/rl/evaluate/results.py create mode 100644 metamon/rl/evaluate/serve_matchup.py create mode 100644 metamon/rl/evaluate/sweep/__init__.py create mode 100644 metamon/rl/evaluate/sweep/__main__.py create mode 100644 metamon/rl/evaluate/sweep/config.py create mode 100644 metamon/rl/evaluate/sweep/example_config.yaml create mode 100644 metamon/rl/evaluate/sweep/temperature_sweep_self_play.yaml create mode 100644 metamon/rl/experimental/__init__.py create mode 100644 metamon/rl/experimental/ensemble/__init__.py create mode 100644 metamon/rl/experimental/ensemble/agents.yaml create mode 100644 metamon/rl/experimental/ensemble/ensemble.py create mode 100644 metamon/rl/experimental/ensemble/ensemble_presets.json create mode 100644 metamon/rl/experimental/ensemble/register.py create mode 100644 metamon/rl/finetune.py delete mode 100644 metamon/rl/finetune_from_hf.py delete mode 100644 metamon/rl/self_play/README.md delete mode 100644 metamon/rl/self_play/__main__.py delete mode 100644 metamon/rl/self_play/earlygen_config.yaml delete mode 100644 server/config.js create mode 100644 tools/analyze_moveset_trends.py create mode 100644 tools/analyze_team_logs.py create mode 100644 tools/compare_team_sets.py create mode 100755 tools/flatten_self_play_pile.sh create mode 100644 tools/persistent_showdown_validator.js diff --git a/.github/workflows/black-fix.yml b/.github/workflows/black-fix.yml new file mode 100644 index 0000000000..5b14940af2 --- /dev/null +++ b/.github/workflows/black-fix.yml @@ -0,0 +1,51 @@ +name: Black (auto-fix) + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + +permissions: + contents: write + pull-requests: write + +jobs: + black_fix: + if: github.event.pull_request.draft == false + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.head_ref }} + token: ${{ secrets.GITHUB_TOKEN }} + fetch-depth: 0 + + - uses: actions/setup-python@v5 + with: + python-version: "3.10" + + - name: Install Black + run: pip install black + + - name: Run Black on changed files + run: | + BASE=$(git merge-base origin/${{ github.base_ref }} HEAD) + FILES=$(git diff --name-only --diff-filter=d "$BASE" HEAD -- '*.py') + if [ -z "$FILES" ]; then + echo "No Python files changed." + exit 0 + fi + echo "Formatting:" + echo "$FILES" + echo "$FILES" | xargs black + + - name: Commit and push changes (if any) + run: | + if git diff --quiet; then + echo "No formatting changes needed." + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add -u + git commit -m "style: auto-format with black" + git push origin HEAD:${{ github.head_ref }} diff --git a/.github/workflows/static.yml b/.github/workflows/static.yml deleted file mode 100644 index d6c98ffb33..0000000000 --- a/.github/workflows/static.yml +++ /dev/null @@ -1,45 +0,0 @@ -# Simple workflow for deploying static content to GitHub Pages -name: Deploy static content to Pages - -on: - # Runs on pushes targeting the default branch - push: - branches: ["gh-pages"] - - # Allows you to run this workflow manually from the Actions tab - workflow_dispatch: - -# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages -permissions: - contents: read - pages: write - id-token: write - -# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. -# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. -concurrency: - group: "pages" - cancel-in-progress: false - -jobs: - # Single deploy job since we're just deploying - deploy: - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - ref: gh-pages - - name: Setup Pages - uses: actions/configure-pages@v5 - - name: Upload artifact - uses: actions/upload-pages-artifact@v3 - with: - # Upload entire repository - path: website/ - - name: Deploy to GitHub Pages - id: deployment - uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index 62763c2162..d6606c77d6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,9 @@ +*logs_and_checkpoints/ +checkpoints/ + +# Weights & Biases +wandb/ + # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] @@ -6,6 +12,14 @@ __pycache__/ # C extensions *.so +# Scripting Artifacts +/wandb +*.sh +!tools/ +!tools/*.sh +*.csv +*.pt + # Distribution / packaging .Python build/ @@ -94,12 +108,6 @@ ipython_config.py # install all needed dependencies. #Pipfile.lock -# UV -# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -#uv.lock - # poetry # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. # This is especially recommended for binary packages to ensure reproducibility, and is more @@ -112,10 +120,8 @@ ipython_config.py #pdm.lock # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it # in version control. -# https://pdm.fming.dev/latest/usage/project/#working-with-version-control +# https://pdm.fming.dev/#use-with-ide .pdm.toml -.pdm-python -.pdm-build/ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm __pypackages__/ @@ -164,13 +170,5 @@ cython_debug/ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ - -# Ruff stuff: -.ruff_cache/ - -# PyPI configuration file -.pypirc -.DS_Store -movesets_data/ -checks_data/ +.idea/ +metamon/data/stats diff --git a/.gitmodules b/.gitmodules index a0b3d45927..044a10dcc7 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,3 @@ [submodule "server/pokemon-showdown"] path = server/pokemon-showdown - url = git@github.com:smogon/pokemon-showdown.git + url = https://github.com/smogon/pokemon-showdown.git diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000000..30c473d7eb --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,5 @@ +repos: + - repo: https://github.com/psf/black + rev: 25.1.0 + hooks: + - id: black diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000000..0a98ae10f6 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,16 @@ +{ + "folder-color.pathColors": [ + { + "folderPath": "metamon/data/", + "color": "foldercolorizer.color_336633" + }, + { + "folderPath": "metamon/backend/", + "color": "foldercolorizer.color_6666ff" + }, + { + "folderPath": "metamon/il/", + "color": "foldercolorizer.color_666666" + } + ] +} \ No newline at end of file diff --git a/README.md b/README.md index 1eabd4a3e9..f99378c5cf 100644 --- a/README.md +++ b/README.md @@ -1,37 +1,29 @@
Metamon Text Logo +
+ Metamon Logo
-
- -
- Metamon Banner -
- -
-
[![Paper](https://img.shields.io/badge/Paper-arXiv:2504.04395-red)](https://arxiv.org/abs/2504.04395) [![Website](https://img.shields.io/badge/Project-Website-blue)](https://metamon.tech) -[![Discord](https://img.shields.io/badge/Discord-Join%20Server-5865F2?logo=discord&logoColor=white)](https://discord.gg/9zuJqgDpGg) +[![Discord](https://img.shields.io/badge/Discord-Join%20Server-5865F2?logo=discord&logoColor=white)](https://discord.gg/9zuJqgpDGg)

-**Metamon** enables reinforcement learning (RL) research on [Pokémon Showdown](https://pokemonshowdown.com/) by providing: +**Metamon** enables plug-and-play reinforcement learning (RL) research on [Pokémon Showdown](https://pokemonshowdown.com/) by providing: -1) 20+ pretrained policies ranging from ~average to high-level human play. -2) A dataset of >4M (and counting) trajectories "reconstructed" from real human battles. -3) A dataset of >18M (and counting) trajectories generated by self-play between agents. -3) Starting points for training (or finetuning) your own imitation learning (IL) and RL policies. -5) A standardized suite of teams and heuristic opponents for evaluation. - -Metamon is the codebase behind ["Human-Level Competitive Pokémon via Scalable Offline RL and Transformers"](https://arxiv.org/abs/2504.04395) (RLC, 2025). Please check out our [project website](https://metamon.tech) for an overview of our original results. After the release of our conference paper, metamon served as a starter kit and winning baseline for the [NeurIPS 2025 PokéAgent Challenge](https://pokeagent.github.io), which motivated significant improvements to our results and datasets. +1) Datasets of 5M+ trajectories "reconstructed" from real human battles and 20M+ generated by self-play between agents. +2) Starting points for training (or finetuning) your own imitation learning (IL) and RL policies. +3) Standardized sets of competitive teams for diverse training and evaluation. +4) 40+ baseline policies ranging from beginner to expert-level human play. +Metamon began as a conference paper, [“Human-Level Competitive Pokémon via Scalable Offline RL and Transformers”](https://arxiv.org/abs/2504.04395), at RLC 2025. It later served as both a starter kit and the winning baseline for the [NeurIPS 2025 PokéAgent Challenge](https://arxiv.org/abs/2603.15563), and now provides RL baselines for the [PokéAgent Leaderboard](https://battling.pokeagentchallenge.com). Although Metamon is primarily intended to make Pokémon an accessible, data-rich research domain, our agents have consistently been among the strongest Pokémon singles bots of any kind, with **ratings against human players now reaching the 90-99th percentile** depending on the ruleset, and may be useful to competitive players as a practice opponent or analysis tool.
@@ -43,7 +35,7 @@ Metamon is the codebase behind ["Human-Level Competitive Pokémon via Scalable O #### Supported Rulesets -Pokémon Showdown hosts many different rulesets spanning nine generations of the video game franchise. Metamon initially focused on the most popular singles ruleset ("OverUsed") for **Generations 1, 2, 3, and 4** but has recently expanded to include **Generation 9 OverUsed** (OU). We also support the UnderUsed (UU), NeverUsed (NU), and Ubers tiers for Generations 1, 2, 3, and 4 – though constant rule changes and small dataset sizes have always made these a bit of an afterthought. +Pokémon Showdown hosts many different rulesets spanning nine generations of the video game franchise. Metamon initially focused on the most popular singles ruleset ("OverUsed") for **Generations 1, 2, 3, and 4** but has recently expanded to include **Generation 9 OverUsed** (OU).
@@ -51,32 +43,18 @@ Pokémon Showdown hosts many different rulesets spanning nine generations of the ### Table of Contents 1. [**Installation**](#installation) - 2. [**Quick Start**](#quick-start) - 3. [**Pretrained Models**](#pretrained-models) - 4. [**Battle Datasets**](#battle-datasets) - 5. [**Team Sets**](#team-sets) - 6. [**Baselines**](#baselines) - 7. [**Observation Spaces, Action Spaces, & Reward Functions**](#observation-spaces-action-spaces--reward-functions) - 8. [**Training and Evaluation**](#training-and-evaluation) - 9. [**Other Datasets**](#other-datasets) - 10. [**Battle Backends**](#battle-backends) - -11. [**Team Preview**](#team-preview) - -12. [**FAQ**](#faq) - -13. [**Acknowledgement**](#acknowledgements) - -14. [**Citation**](#citation) +11. [**Experimental Features**](#experimental-features) +12. [**Acknowledgements**](#acknowledgements) +13. [**Citation**](#citation)
@@ -213,7 +191,7 @@ selfplay_dset = SelfPlayDataset( action_space=action_space, reward_function=reward_fn, formats=["gen1ou"], - subset="pac-base", # or "pac-exploratory" + subset="pac-base", # or "pac-exploratory" or "pac-tauros" ) ``` @@ -252,7 +230,7 @@ ____ ## Pretrained Models -We have made every checkpoint of 29 models available on huggingface at [`jakegrigsby/metamon`](https://huggingface.co/jakegrigsby/metamon/tree/main). You will need to install [`amago`](https://github.com/UT-Austin-RPL/amago), which is an RL codebase by the same authors. Follow instructions [here](https://ut-austin-rpl.github.io/amago/installation.html). +We have made every checkpoint of 40+ models available on huggingface at [`jakegrigsby/metamon`](https://huggingface.co/jakegrigsby/metamon/tree/main). You will need to install [`amago`](https://github.com/UT-Austin-RPL/amago), which is an RL codebase by the same authors. Follow instructions [here](https://ut-austin-rpl.github.io/amago/installation.html).
@@ -261,26 +239,18 @@ We have made every checkpoint of 29 models available on huggingface at [`jakegri
-Load and run pretrained models with `metamon.rl.evaluate`. For example: +Load and run pretrained models with `metamon.rl.evaluate`. See the full [Evaluation README](metamon/rl/evaluate/README.md) for all eval types (heuristic baselines, local ladder, head-to-head challenges, parameter sweeps, and more). Quick example: ```bash python -m metamon.rl.evaluate --eval_type heuristic --agent Kakuna --gens 1 --formats ou --total_battles 100 ``` -Will run the default checkpoint of the best model for 100 battles against a set of heuristic baselines highlighted in the paper. - -Or to battle against whatever is logged onto the local Showdown server (including other pretrained models that are already waiting): - -```bash -python -m metamon.rl.evaluate --eval_type ladder --agent Kakuna --gens 1 --formats ou --total_battles 50 --username --team_set competitive -``` -
### Featured Policies -There are now **29 official metamon models**. Most of them were stepping stones to later (better) versions, and are now mainly useful as baselines or extra opponents in self-play data collection. Some notable exceptions worth knowing about are: +Most Metamon policies were stepping stones to later (better) versions, and are now mainly useful as baselines or extra opponents in self-play data collection. Some notable exceptions worth knowing about are: @@ -312,13 +282,6 @@ There are now **29 official metamon models**. Most of them were stepping stones - - - - - - - @@ -326,16 +289,22 @@ There are now **29 official metamon models**. Most of them were stepping stones + + + + + + +
The best policy trained in time to participate in the PokéAgent Challenge (as an organizer baseline). #1 in the Gen1OU qualifier and #2 in Gen9OU behind foul-play. 80%64%

Alakazam
57MSep 2025The final version of the PokéAgent Challenge effort. Patched a bug that made tera types invisible to the policy, which makes it the best candidate for future work at this model size.

Kakuna
142MThe best public metamon model – leading by nearly every metric. Trained on diverse teams to serve as a strong foundation for further research in any gen. Appears on all 5 OU leaderboards and is consistently 1500+ Elo in Gen1OU. 82%70%63%64%71%

TaurosV0
62MMay 2026Gen1OU specialist. TaurosEnsemble has held #1 on the human Showdown ladder (following KakunaEnsemble, the first Metamon agent to do so).83%
-Models can be loosely divided into two eras of active development: +Models can be loosely divided into three eras of active development: 1. **RLC Paper**: Trained on Gen 1-4 with old versions of the replay dataset and team sets. -2. **NeurIPS PokéAgent Challenge**: Basically restarted from scratch. Broadly speaking, we *reduced* model sizes, reward shaping, and the paper's emphasis on long-term memory while *improving* generalization over diverse team choices and prioritizing support for gen9ou. However, it took several iterations to recover the paper's Gen 1-4 performance. - - +2. **NeurIPS PokéAgent Challenge**: Developed new baselines by *reducing* model sizes, reward shaping, and the paper's emphasis on long-term memory while *improving* generalization over diverse team choices and prioritizing support for gen9ou. However, it took several iterations to recover the paper's Gen 1-4 performance. +3. **Post-PokéAgent**: Metamon continues to improve, though as a hobby project for the original team with sporadic development and maintenance. Our focus has shifted away from serving as a "starter kit" and toward chasing expert-level human performance.
@@ -397,16 +366,35 @@ Here is a reference of human evals for key models according to our paper:
+
+

Post-PokéAgent Policies

+ +*Policies trained after the conclusion of the PokéAgent Challenge. Metamon releases now chase expert-human performance; models may be specialists in a particular ruleset or trained on unreleased datasets.* + + + + + + + + + +
Model Name (--agent)Description
V2A*Many small-scale (~12–15M param) RL hyperparameter ablations (V2A, V2ASeed2, V2ABeta01, V2AGroupedV2ISFilter, etc.) on Gen1OU. Performance is broadly similar—between SyntheticRLV2 and Kakuna—and they are mainly useful for boosting self-play diversity rather than as standalone ladder agents.
TaurosV0Scales up the V2A findings on a fresh Gen1OU dataset at 50M params. The best standalone Gen1OU policy in metamon to date. TaurosEnsemble also reached #1 on the human Showdown ladder, building on the earlier KakunaEnsemble milestone.
+ +
+ +
+ ### Internal Leaderboards -Human ratings above are the best way to anchor performance to an external metric, but we primarily rely on self comparisons across generations and [team sets](#team-sets) to guide new research. We typically use head-to-head comparisons between key baselines: see [this Kakuna eval](https://docs.google.com/spreadsheets/d/1lU8tQ0tnnupY28kIyK6FVtvPmxLSVT9_slLShOhRsqg/edit?usp=sharing) as an example. But we can get a general sense of the ***relative* strength** of metamon over time by turning policies loose on a locally hosted Showdown ladder and sampling from the same `TeamSet`. +Human ratings above are the best way to anchor performance to an external metric, but we primarily rely on self comparisons across generations and [team sets](#team-sets) to guide new research. We can get a general sense of the ***relative* strength** of metamon over time by turning policies loose on a locally hosted Showdown ladder and sampling from the same `TeamSet`. The [PokéAgent Server](https://battling.pokeagentchallenge.com) also serves as a live leaderboard, though team sets are inconsistent. *![Gold](https://img.shields.io/badge/Gold-DAA520?style=flat) = PokéAgent Challenge policy, ![Pink](https://img.shields.io/badge/Pink-E91E63?style=flat) = Paper policy.* > [!TIP] -> *These GXE values are a measure of performance *relative* to the listed models and **have no connection to ratings on the public ladder**.* +> *These GXE values are a measure of performance *relative* to the listed models and **have no connection to ratings on the public ladder**. `TaurosV0` is the best Gen1OU policy, but does not play other rulesets.* @@ -793,8 +781,8 @@ Datasets are stored on huggingface in two formats: | Name | Size | Description | |------|------|-------------| -|**[`metamon-raw-replays`](https://huggingface.co/datasets/jakegrigsby/metamon-raw-replays)** | 2M Battles | Our curated set of Pokémon Showdown replay `.json` files... to save the Showdown API some download requests and to maintain an official reference of our training data. Will be regularly updated as new battles are played and collected. | -|**[`metamon-parsed-replays`](https://huggingface.co/datasets/jakegrigsby/metamon-parsed-replays)** | 4M Trajectories | The RL-compatible version of the dataset as reconstructed by the [replay parser](metamon/backend/replay_parser/README.md). This dataset has been significantly expanded and improved since the original paper.| +|**[`metamon-raw-replays`](https://huggingface.co/datasets/jakegrigsby/metamon-raw-replays)** | 2.7M Battles | Our curated set of Pokémon Showdown replay `.json` files... to save the Showdown API some download requests and to maintain an official reference of our training data. Will be regularly updated as new battles are played and collected. | +|**[`metamon-parsed-replays`](https://huggingface.co/datasets/jakegrigsby/metamon-parsed-replays)** | 5.3M Trajectories | The RL-compatible version of the dataset as reconstructed by the [replay parser](metamon/backend/replay_parser/README.md). This dataset has been significantly expanded and improved since the original paper.| Parsed replays will download automatically when requested by the `ParsedReplayDataset`, but these datasets are large. Download in advance with: @@ -819,22 +807,23 @@ obs_seq, action_seq, reward_seq, done_seq = replay_dset[0] In Showdown RL, we have to embrace a **mismatch between the trajectories we *observe in our own battles* and those we *gather from other player's replays***. In short, replays are saved from the point-of-view of a *spectator* rather than the point-of-view of a *player*. The server sends info to the players that it does not save to its replay, and we need to try and simulate that missing info. Metamon goes to great lengths to handle this, and is always improving ([more info here](metamon/backend/replay_parser/README.md)), but there is no way to be perfect. -**Therefore, replay data is perhaps best viewed as pretraining data for an offline-to-online finetuning problem.** Self-collected data from the online env fixes inaccuracies and can help concentrate on teams we'll be using on the ladder. The whole project is now set up to do this (see [Quick Start](#quick-start)), and we have open-sourced large self-play sets (below). +**Therefore, replay data is perhaps best viewed as pretraining data for an offline-to-online finetuning problem.** Self-collected data from the online env fixes inaccuracies and can help concentrate on teams we'll be using on the ladder. We have open-sourced large self-play sets (below).
### Self-Play Datasets -Almost all improvement in `metamon`'s performance is driven by large and diverse datasets of agent vs. agent battles. Public self-play datasets are stored on huggingface at [`jakegrigsby/metamon-parsed-pile`](https://huggingface.co/datasets/jakegrigsby/metamon-parsed-pile). Trajectories were generated by the `rl/self_play` launcher with various team sets and model pools. +Almost all improvement in `metamon`'s performance is driven by large and diverse datasets of agent vs. agent battles. Public self-play datasets are stored on huggingface at [`jakegrigsby/metamon-parsed-pile`](https://huggingface.co/datasets/jakegrigsby/metamon-parsed-pile). Trajectories were generated by the [ladder self-play launcher](metamon/rl/evaluate/README.md#ladder-self-play) with various team sets and model pools. - There are currently two subsets: + There are currently three subsets: | Name | Size | Description | |------|------|-------------| |**`pac-base`** | 11M Trajectories | Partially comprised of battles played by organizer baselines on the PokéAgent Challenge practice ladder, but the vast majority are battles collected locally for the purposes of training the ![Abra](https://img.shields.io/badge/Abra-DAA520?style=flat), ![Kadabra](https://img.shields.io/badge/Kadabra-DAA520?style=flat), and ![Alakazam](https://img.shields.io/badge/Alakazam-DAA520?style=flat) line of policies. The version uploaded here trained ![Alakazam](https://img.shields.io/badge/Alakazam-DAA520?style=flat), and previous models were trained on subsets of this dataset. | |**`pac-exploratory`** | 7M Trajectories | Self-play revisited after the NeurIPS challenge with higher sampling temperature (to improve value estimates of sub-optimal actions). ![Kakuna](https://img.shields.io/badge/Kakuna-DAA520?style=flat) was trained on `metamon-parsed-replays`, `pac-base`, and `pac-exploratory`.| +|**`pac-tauros`** | 4M Trajectories | Self-play dataset specifically focused on high-ladder Gen1OU. Contains decisions of base policies in the ~70–83% GXE range on the human ladder, and was used to train policies that can top the public leaderboard.| Self-play data will download automatically when requested by the `SelfPlayDataset`, but these datasets are large. Download in advance with: @@ -842,7 +831,7 @@ Self-play data will download automatically when requested by the `SelfPlayDatase python -m metamon.data.download self-play ``` -This downloads both subsets for all available formats (gen1ou, gen2ou, gen3ou, gen4ou, gen9ou). You can also specify formats explicitly: `--formats gen1ou gen9ou`. +This downloads all subsets for their available formats (`pac-base` and `pac-exploratory`: gen1ou–gen4ou and gen9ou; `pac-tauros`: gen1ou only). You can also specify formats explicitly: `--formats gen1ou gen9ou`. ```python from metamon.data import SelfPlayDataset @@ -851,47 +840,44 @@ self_play_dset = SelfPlayDataset( observation_space=obs_space, action_space=action_space, reward_function=reward_func, - subset="pac-base", # or "pac-exploratory" + subset="pac-base", # or "pac-exploratory" or "pac-tauros" formats=["gen1ou", "gen9ou"], ) obs_seq, action_seq, reward_seq, done_seq = self_play_dset[0] ``` -Self-play datasets are currently only available in the parsed replay format, which makes them liable to be deprecated should that format change or a major bug in the [battle backend](#battle-backends) be found. When/if this happens, the [replay parser](metamon/backend/replay_parser/README.md) would be expanded to parse ground-truth battle logs and the datasets would be re-released as a noisier aggregate of all the logs from every metamon development server during the same time period. - - -
___
- ## Team Sets +## Team Sets - Team sets are dirs of Showdown team files that are randomly sampled between episodes. They are stored on huggingface at [`jakegrigsby/metamon-teams`](https://huggingface.co/datasets/jakegrigsby/metamon-teams) and can be downloaded in advance with `python -m metamon.data.download teams` +Team sets are dirs of Showdown team files that are randomly sampled between episodes. They are stored on Hugging Face at [`jakegrigsby/metamon-teams`](https://huggingface.co/datasets/jakegrigsby/metamon-teams) and can be downloaded in advance with `python -m metamon.data.download teams`. ```python -metamon.env.get_metamon_teams(battle_format : str, set_name : str) +metamon.env.get_metamon_teams(battle_format: str, set_name: str) ``` - | `set_name` | Teams Per Battle Format | Description | -|------|---------------------------|-----------------------| -|`"competitive"`| Varies (< 30) | Human-made teams scraped from forum threads. These are usually official "sample teams" designed by experts for beginners, but we are less selective for non-OU tiers. This is the set used for human ladder evaluations in the paper. | -|`"paper_variety"`| (Gen 1-4 Only) 1k | Procedurally generated teams with unrealistic OOD lead-off Pokémon. The paper calls this the "variety set". Movesets were generated by sampling from all-time usage stats. | -| `"paper_replays"` | 1k (Gen 1-4 OU Only) | *Predicted* teams from replays. The paper calls this the "replay set". Surpassed by the "modern_replays" set below. Used the original prediction strategy of sampling from all-time usage stats.| -| `"modern_replays"` | 8k-20k
(OU Only) | *Predicted* teams based on recent replays using the best prediction strategy we have available for each generation. The result is a diverse set representing the recent metagame with blanks filled by a mixture of historical trends. | -| `"modern_replays_v2"` | Gen1: 19k, Gen2: 13k, Gen3: 31k, Gen4: 27k, Gen9: 158k. | An expanded set of replay-predicted teams; updated with Summer 2025 replays. +| `set_name` | Gen1 | Gen2 | Gen3 | Gen4 | Gen9 | Description | +|:----------:|:----:|:----:|:----:|:----:|:----:|:------------| +| `"competitive"` | < 30 | < 30 | < 30 | < 30 | < 30 | Human-made teams scraped from forum threads, usually official “sample teams” designed by experts for beginners. This is the set used for human ladder evaluations in the paper. The name is now misleading: these are probably not the most “competitive” teams. However, because they were once the main evaluation and deployment set, Metamon has overfit to them, and they remain strong defaults. | +| `"gl_05_26"` | 29k | 10k | 107k | 48k | 139k | **General Ladder May '26**: A successor to the `modern_replays` idea; fills replays from recent months with teams predicted from time- and rating-appropriate usage stats. | +| `"hl_05_26"` | 9k | 2k | 22k | 6k | 43k | **High Ladder May '26**: Subset of `gl_05_26` that restricts to higher-rated games and tournament-server replays, with a preference for replays that reveal more of the ground-truth team and a bias towards high-rating usage stats. | + -The HF readme has more information. +Legacy team sets from the paper and PokéAgent Challenge (`paper_variety`, `paper_replays`, `modern_replays`, `modern_replays_v2`, etc.) remain available and are described in more detail in the [metamon-teams README](https://huggingface.co/datasets/jakegrigsby/metamon-teams). + +You can also use your own directory of team files with, for example: -We can also use our own directory of team files with, for example: ```python from metamon.env import TeamSet -team_set = TeamSet("/path/to/your/team/dir", battle_format: str) # e.g. gen3ou +team_set = TeamSet("/path/to/your/team/dir", battle_format: str) # e.g. gen3ou ``` -But note that files would need to have the extension `".{battle_format}_team"` (e.g., .gen3nu_team). + +Files need the extension `".{battle_format}_team"` (e.g., `.gen3nu_team`).
@@ -968,6 +954,7 @@ We could create a custom version with more/less features by inheriting from `met | `ExpandedObservationSpace` | A slight improvement based on lessons learned from the paper. It also adds tera types for Gen 9. | | `TeamPreviewObservationSpace` | Further extends `ExpandedObservationSpace` with a preview of the opponent's team (for Gen 9). | | `OpponentMoveObservationSpace` | Modifies `TeamPreviewObservationSpace` to include the opponent Pokémon's revealed moves. Continues our trend of deemphasizing long-term memory. | +| `GroupedObservationSpace` | Restructures similar features into per-Pokémon groups (your active, each switch slot, opponent active) plus a misc block for format, weather, teampreview, and revealed opponent species. Designed for a shared Pokémon encoder rather than one flat text/numbers vector. Used by Post-PokéAgent policies such as `TaurosV0`. | ##### Tokenization @@ -1053,38 +1040,58 @@ export METAMON_WANDB_ENTITY="my_wandb_username" ### Train From Scratch -See `python train.py --help` for options. The training script implements offline RL on the human battle dataset *and* an optional extra dataset of self-play battles you may have collected. +See `python -m metamon.rl.train --help` for options. The script trains offline RL agents from scratch: pick model and training gin configs, observation/action/reward interfaces, and a dataset mix (via `--dataset_config` YAML). Scan `metamon/rl/pretrained.py` to see the configs used by each public model. -We might retrain the "`SmallIL`" model like this: +We might retrain the "`SmallIL`" model like this: ```bash -python -m metamon.rl.train --run_name AnyNameHere --model_gin_config small_agent.gin --train_gin_config il.gin --save_dir ~/my_checkpoint_path/ --log +python -m metamon.rl.train \ + --run_name AnyNameHere \ + --model_gin_config small_agent.gin \ + --train_gin_config il.gin \ + --dataset_config self_play_dset.yaml \ + --save_dir ~/my_checkpoint_path/ \ + --log ``` -"`SmallRL`" would be the same command with `--train_gin_config exp_rl.gin`. Scan `rl/pretrained.py` to see the configs used by each pretrained agent. Larger training runs take *days* to complete and [can (optionally) use mulitple GPUs (link)](https://ut-austin-rpl.github.io/amago/tutorial/async.html#multi-gpu-training). An example of a smaller RNN config is provided in `small_rnn.gin`. + +"`SmallRL`" would use `--train_gin_config exp_rl.gin` instead. Larger runs take *days* and [can use multiple GPUs](https://ut-austin-rpl.github.io/amago/tutorial/async.html#multi-gpu-training). A smaller-model example is `metamon/rl/configs/models/minikazam.gin`.
### Finetune from HuggingFace -**See `python finetune_from_hf.py --help` to finetune an existing model to a new dataset, training objective, or reward function!** - -Provides the same setup as the main `train` script but takes care of downloading and matching the config details of our public models. Finetuning will inherit the architecture of the base model but allows for changes to the `--train_gin_config` and `--reward_function`. Note that the best settings for quick finetuning runs are likely different from the original run! +**See `python -m metamon.rl.finetune --help` to start from a public checkpoint and adapt it.** Finetuning inherits architecture, observation space, and tokenizer from `--base_model`, but you can change the training objective (`--train_gin_config`), reward function (`--reward_function`), eval setup, and dataset mix (`--dataset_config`). -We might finetune "`SmallRL`" to the new gen 9 replay dataset and custom battles like this: +First iteration from HuggingFace: ```bash -python -m metamon.rl.finetune_from_hf --finetune_from_model SmallRL --run_name MyCustomSmallRL --save_dir ~/metamon_finetunes/ --custom_replay_dir /my/custom/parsed_replay_dataset --custom_replay_weight .25 --epochs 10 --steps_per_epoch 10000 --log --formats gen9ou --eval_gens 9 +python -m metamon.rl.finetune \ + --run_name minikazam_custom \ + --save_dir ~/metamon_finetunes/ \ + --base_model Minikazam \ + --train_gin_config finetune.gin \ + --dataset_config self_play_dset.yaml \ + --epochs 10 \ + --log \ + --eval_gens 9 ``` -You can start from any checkpoint number with `--finetune_from_ckpt`. See the huggingface for a full list. Defaults to the official eval checkpoint. +Continue from a local run with `--prev_run_dir`, `--prev_run_name`, and `--prev_checkpoint`. Use `--base_checkpoint` to pick a non-default HuggingFace epoch on the first iteration. For iterative self-play loops (new data piles, annealed mixing, chaining `prev_dataset`), see the walkthrough in `metamon/rl/finetune.py` and examples in `metamon/rl/configs/datasets/`.
### Customize -Customize the agent architecture by creating new `rl/configs/models/` `.gin` files. Customize the RL hyperparameters by creating new `rl/configs/training/` files. [Here is a link](https://ut-austin-rpl.github.io/amago/tutorial/configuration.html) to a lot more information about configuring training runs. `amago` is modular, and you can swap just about any piece of the agent with your own ideas. [Here is a link](https://ut-austin-rpl.github.io/amago/tutorial/customization.html) to more information about custom components. +Most changes go through gin configs and CLI flags rather than code edits: + +- **Architecture** — new `metamon/rl/configs/models/*.gin` files +- **Training objective / hparams** — new `metamon/rl/configs/training/*.gin` files +- **Observations, actions, rewards** — `--obs_space`, `--action_space`, and `--reward_function` (see sections above). +- **Data mix** — dataset YAMLs in `metamon/rl/configs/datasets/` when you need to change replay/self-play weighting or add custom datasets. + +`amago`'s [configuration](https://ut-austin-rpl.github.io/amago/tutorial/configuration.html) and [customization docs](https://ut-austin-rpl.github.io/amago/tutorial/customization.html) cover more features.
@@ -1092,9 +1099,9 @@ Customize the agent architecture by creating new `rl/configs/models/` `.gin` fil ### Evaluate a Custom Model -`metamon.rl.evaluate` provides quick-setup evals (`pretrained_vs_baselines`, `pretrained_vs_local_ladder`, and `pretrained_vs_pokeagent_ladder`). Full explanations are provided in the source file. +See the [Evaluation README](metamon/rl/evaluate/README.md) for full details on all evaluation modes, including automated head-to-head, parameter sweeps, and self-play launchers. -To eval a custom agent trained from scratch (`rl.train`) we'd create a `LocalPretrainedModel`. `LocalFinetunedModel` provides some quick setup for models finetuned with `rl.finetune_from_hf`. [`examples/evaluate_custom_models.py`](examples/evaluate_custom_models.py) shows an example for each, and deploys them on the PokéAgent Ladder! +To eval a custom agent trained from scratch (`rl.train`) we'd create a `LocalPretrainedModel`. `LocalFinetunedModel` provides quick setup for models finetuned with `rl.finetune`. [`examples/evaluate_custom_models.py`](examples/evaluate_custom_models.py) shows an example for each. #### Standalone Toy `il` (Deprecated) @@ -1130,7 +1137,8 @@ from metamon.backend.team_prediction.usage_stats import get_usage_stats from datetime import date usage_stats = get_usage_stats("gen1ou", start_date=date(2017, 12, 1), - end_date=date(2018, 3, 30) + end_date=date(2018, 3, 30), + rank=1500, # falls back to nearest lower (glicko) rank where data is available ) alakazam_info: dict = usage_stats["Alakazam"] # non alphanum chars and case are flexible ``` @@ -1167,7 +1175,7 @@ Given the size of our replay dataset, this creates a massive set of real (but in python -m metamon.data.download revealed-teams ``` -`metamon/backend/team_prediction` contains tools for filling in the blanks of these files, but this is all poorly documented and changes frequently, so we'll leave it at that for now. +See [**Team Prediction**](#team-prediction) for a work-in-progress learned approach to filling in the missing details (heuristic predictors still power the current pipeline). @@ -1176,11 +1184,12 @@ python -m metamon.data.download revealed-teams
-## Battle Backends -Converting Showdown messages to RL observations is hard, and there will always be bugs. Minor fixes to edge cases or rare Pokémon mechanics are fairly common and don't have a real impact on overall performance. However, a fix that directly impacts observation features (agent inputs) usually *decreases* performance of policies trained on older battles. We extend the lifespan of pretrained model weights by versioning the "battle backend" so that we can evaluate the agent in an environment that matches the dataset it was trained on. +
+

Battle Backends

-`battle_backend : str` is an arg for all the RL environment wrappers (see [Quick Start](#quick-start)). +Converting Showdown messages to RL observations is very hard, and there will always be bugs. Minor fixes to edge cases or rare Pokémon mechanics are fairly common and don't have a real impact on overall performance. However, a fix that directly impacts observation features (agent inputs) usually *decreases* performance of policies trained on older battles. We extend the lifespan of pretrained model weights by versioning the "battle backend" so that we can evaluate the agent in an environment that matches the dataset it was trained on. +`battle_backend : str` is an arg for all the RL environment wrappers (see [Quick Start](#quick-start)). There are currently three versions: @@ -1189,52 +1198,61 @@ There are currently three versions: | `"poke-env"` | Original paper verison. Uses [`poke-env`](https://github.com/hsahovic/poke-env) to process online battles. | - Creates a sim2sim gap with the replay parser that generates training data from replays.
- PP counting and tera types are broken. | When evaluating the original paper policies. | | `"pokeagent"` | Replaces `poke-env`'s message parsing with metamon's replay parser. Maintains the version used by all the new baselines and datasets created for the [PokéAgent Challenge](https://pokeagent.github.io). | - Gen9 was in Beta; tera types are reported as missing. | When evaluating a policy trained during the competition (see [Pretrained Models](#pretrained-models)). | **`"metamon"`** | Always the latest version. | | When collecting new self-play data and training new policies from scratch. | -____ -A `PretrainedAgent`saves the backend it "should" be evaluated with (if you're using them as a baseline). If you are collecting lots of new self-play data and actively working on new training runs: use `"metamon"`. Thanks to a few hacks, **it is still reasonable to use *any* `PretrainedAgent` to collect new training data in the current `metamon` backend.** - -
+A `PretrainedAgent` saves the backend it "should" be evaluated with (if you're using them as a baseline). If you are collecting lots of new self-play data and actively working on new training runs: use `"metamon"`. Thanks to a few hacks, **it is still reasonable to use *any* `PretrainedAgent` to collect new training data in the current `metamon` backend.** +
-## Team Preview +
-In Generation 9, battles begin with a "team preview" phase where both players see each other's full team and choose which Pokémon to lead with. Metamon includes a separate model for this decision. -**Training**: Team preview models are trained via `metamon/backend/team_preview/` using supervised learning on human replay data. +## Experimental Features -**Evaluation**: Pass a checkpoint to the evaluation script. An example checkpoint for gen9ou is included: -```bash -python -m metamon.rl.evaluate --team_preview_checkpoint metamon/backend/team_preview/gen9ou_high_elo_v4/best_model.pt --team_preview_use_argmax ... -``` +These capabilities are experimental research features that are implemented but are not yet thoroughly tested or commonly used. Contributions welcome! -The `--team_preview_use_argmax` flag selects the highest-probability lead deterministically; without it, the model samples from its predicted distribution. +
+

Team Prediction Models

-
+Showdown replays only reveal part of each player's team. Metamon has to infer the rest before parsed trajectories are usable. **Today, heuristic predictors in `metamon/backend/team_prediction/predictor.py`** (`NaiveUsagePredictor`, `ReplayPredictor`) sample from Smogon usage stats and replay-derived candidates; these power the current replay parser, team sets, and related pipelines. +We have also been experimenting with a learned **`TeamPredictionModel`**: a transformer over a structured team sequence (`Team2Seq`) that fills masked species, moves, items, and abilities. At inference time it uses **MaskGIT-style parallel decoding** — over several iterations, it predicts all still-masked tokens in parallel and commits the highest-confidence fills, gradually unmasking the team (see `IterativeTeamDecoder` in `iterative_decoder.py`). - ## FAQ +**Training**: `python -m metamon.backend.team_prediction.train_prediction_model` (see that module for configs and eval). +**Evaluation / use**: the model API lives in `prediction_model.py`; wiring into the replay parser and team-set builders is still in progress. See also [Revealed Teams](#revealed-teams) and [Usage Stats](#usage-stats). - #### How can I contribute? +
-Please get in touch! Currently, the easiest place to reach us is via the [PokéAgent Challenge Discord Server](https://discord.gg/9zuJqgDpGg). You can also email the lead author. +
+

Team Preview Models

+In Generation 9, battles begin with a "team preview" phase where both players see each other's full team and choose which Pokémon to lead with. Metamon includes a separate model for this decision. - #### Why do you focus on Gens 1-4? +**Training**: Team preview models are trained via `metamon/backend/team_preview/` using supervised learning on human replay data. - Because there is no team preview before Gen 5, and inferring hidden information via long-term memory was our main focus from an RL research perspective. There's more about this in the paper. A common criticism was that we were avoiding the complexity that comes with later generations' increase in the number of available Pokémon, items, abilities, and so on. If this gap exists, it is more than made up for by the volume of gen9ou replays, as Gen9OU is now arguably our second best format. +**Evaluation**: Pass a checkpoint to the evaluation script. An example checkpoint for gen9ou is included: +```bash +python -m metamon.rl.evaluate --team_preview_checkpoint metamon/backend/team_preview/gen9ou_high_elo_v4/best_model.pt --team_preview_use_argmax ... +``` +The `--team_preview_use_argmax` flag selects the highest-probability lead deterministically; without it, the model samples from its predicted distribution. - #### Will you add support for the missing Gens 5-8? +
- The main engineering barrier is the [replay parser and dataset](metamon/backend/replay_parser/README.md), which supports gen9 but would surely need some updates for backwards-compatible edge cases. This is not a huge job... but redoing the self-play training process to catch up to the performance in existing gens would be. We would definitely accept contributions on this front, but honestly have no plans to do it ourselves, as in our opinion the expansion to gen9 answered research doubts about generality and model-free RL at low search depth and new (singles) formats are more Showdown infra trouble than they're worth. +
+

Test-Time Ensembling

+We have also been experimenting with inference-time ensembles that combine multiple pretrained policies rather than training a single monolithic agent. The implementation lives in `metamon/rl/experimental/ensemble/` and uses a heuristic proposer/judge router (`HeuristicRouterEnsemblePolicy`): member models propose candidate actions, a subset judges them, and the router picks a final move using GXE priors, per-turn uncertainty, and disagreement between experts. The router itself is not trained — it is a hand-tuned inference wrapper over fixed checkpoints. - #### What about VGC (doubles)? +Public eval agents are **`PastaMittens`** and **`Exeggcute`** (nicknames in `metamon/rl/experimental/ensemble/agents.yaml`, registered via `register.py`). Each points at a member pool in `ensemble_presets.json` — `PastaMittens` uses the kakuna router family (first Metamon agent to reach #1 on the human Showdown ladder); `Exeggcute` uses the tauros family with a Gen1OU-focused pool built around `TaurosV0`. The underlying base classes `KakunaEnsemble` and `TaurosEnsemble` in `pretrained.py` are still available but use older hardcoded member specs. Use the nicknames like any other pretrained agent: -Support for VGC has been in development but we aren't announcing any timelines on this just yet. +```bash +python -m metamon.rl.evaluate --eval_type challenge --agent PastaMittens --gens 1 --formats ou ... +``` +Member pools can be overridden at runtime with `METAMON_ENSEMBLE_MEMBER_SPECS` or by name via `METAMON_ENSEMBLE_PRESET` (see presets in `metamon/rl/experimental/ensemble/ensemble_presets.json`). Ensembling is not part of the main offline RL training loop — it is an experimental deployment strategy for squeezing more ladder performance out of existing checkpoints. +

diff --git a/examples/evaluate_custom_models.py b/examples/evaluate_custom_models.py index 87d3df6aa0..b9da28e13f 100644 --- a/examples/evaluate_custom_models.py +++ b/examples/evaluate_custom_models.py @@ -1,3 +1,9 @@ +"""Evaluate custom checkpoints from ``rl.train`` or ``rl.finetune``. + +See ``metamon/rl/pretrained.py`` (``LocalPretrainedModel``, ``LocalFinetunedModel``) +and ``metamon/rl/evaluate/README.md`` for other eval modes. +""" + import metamon from metamon.rl import ( pretrained_vs_pokeagent_ladder, @@ -6,18 +12,17 @@ ) from metamon.rl.pretrained import SmallRL -""" -In this example, let's say we trained a new model from scratch with: - -python -m metamon.rl.train \\ - --run_name gen9v3 \\ - --model_gin_config medium_multitaskagent.gin \\ - --save_dir ~/metamon_ckpts/ \\ - --train_gin_config binary_rl.gin \\ - --obs_space TeamPreviewObservationSpace \\ - --tokenizer DefaultObservationSpace-v1 \\ - --log -""" +# --- Model trained from scratch (``rl.train``) --- +# +# python -m metamon.rl.train \\ +# --run_name gen9v3 \\ +# --model_gin_config medium_multitaskagent.gin \\ +# --train_gin_config binary_rl.gin \\ +# --dataset_config self_play_dset.yaml \\ +# --save_dir ~/metamon_ckpts/ \\ +# --obs_space TeamPreviewObservationSpace \\ +# --tokenizer DefaultObservationSpace-v1 \\ +# --log MyCustomModel = LocalPretrainedModel( amago_ckpt_dir="~/metamon_ckpts/", model_name="gen9v3", @@ -29,33 +34,29 @@ tokenizer=metamon.tokenizer.get_tokenizer("DefaultObservationSpace-v1"), ) -""" -Then let's say we finetuned SmallRL to Gen9 with: - -python -m metamon.rl.finetune_from_hf \\ - --finetune_from_model SmallRL \\ - --run_name smallrlfinetune \\ - --save_dir ~/metamon_ckpts/ \\ - --steps_per_epoch 10000 \\ - --epochs 3 \\ - --eval_gens 9 \\ - --formats gen9ou \\ - --log -""" +# --- Model finetuned from a public checkpoint (``rl.finetune``) --- +# +# python -m metamon.rl.finetune \\ +# --run_name smallrl_finetune \\ +# --save_dir ~/metamon_ckpts/ \\ +# --base_model SmallRL \\ +# --dataset_config self_play_dset.yaml \\ +# --epochs 10 \\ +# --log \\ +# --eval_gens 9 MyFinetunedModel = LocalFinetunedModel( base_model=SmallRL, amago_ckpt_dir="~/metamon_ckpts/", - model_name="smallrlfinetune", - default_checkpoint=2, + model_name="smallrl_finetune", + default_checkpoint=10, ) -teams = metamon.env.get_metamon_teams("gen1ou", "competitive") -# or create a custom set of teams (metamon.env.TeamSet) +teams = metamon.env.get_metamon_teams("gen9ou", "competitive") results = pretrained_vs_pokeagent_ladder( pretrained_model=MyFinetunedModel, username="PAC-MyTeamName", password="my_password", - battle_format="gen1ou", + battle_format="gen9ou", team_set=teams, total_battles=10, ) diff --git a/media/icons/tauros.png b/media/icons/tauros.png new file mode 100644 index 0000000000000000000000000000000000000000..5982823c7d2fbc5347b6227ac7683f8c95d1b8d2 GIT binary patch literal 299604 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 z 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≺Sx|6T(;%HB4XL)ONbv3jLy_jpy@g zj(>~UvoqxpWb`2xc-s4a#nK}4SM)1l3MxX{!W&2~=+UI9Fj*W9F6#REow`&N4qDY7QS@K6;efrb`*=dH)R{?wA}@~y3LrofaxB!PsNf)L z0#`Q&2#qx4Cy(v>L+sp$Jo2)c9)v36;Bup^B53nxj4b4kn`u&Ni^2&F+M8ryP}B4R?MYwiH` zn=E%$2c!L3Nc)}KhoHywMv598!EbZ)%a{wm_kR9?P3P9Zaw{v9Q6kx5sAkLg{V6T> zXBH*g-UK&KMvXCDvdAbG2LSrdCzXLAFXL>Wyfrj`vZun-_7SoK^$P~&PYVu2lebh|;l zBE`G6R;{8V}ul=G0-9&QOt8 zvEE?>3R?G-W2jvSnROIC!J17h(?63LexHntEf+)<8v5?7!k0H|$!$>az@Z|coJLAn zucqEt70`7>RW)V^F-Q>tr`*KSL|@Vx^*+USXTjeUqnYEw$;I@s4s#^RVWkjy&${8p zwiuR?#tc!=y^CReEB$)@E(m-3y-vBeXUj{g>`2)sItF+f)<)_i3(0#?rz3?%pzPLw zVB$Y#`F2xP7qoQ|S3--{p#Qj5B>6Qx>IM<79v!Zzc9D%`YqBlY#hXx=P8BmrUhy%a znfLNnFzMHiV|Uk7xwk^*qOAUDE~Fah-j!^t>%As^;eiUkM8n40h3moYkt+F?m%p1H zkHmjnk&yq>TUq;^3wCwdelAa_?Ak3>YB8s`nNITa5yuSdSE2poZbAK}NVdtXGFV8r z9_etJJtc4|rqbc{@g=^grRk02f>KFRVo?J(0uAkTT0lg?R&1=VJ8Aws^~q+VDe0P) za$oy7E5eFo6hQ&7MbLeh7i}bJv8WtN`@z?35I@Gdhz(}(FDv}r_u#~mbz)D5;MlA- zNBofJ#?V5&S`>3+N`Z*iF7YT2?T%l10#~t9t`DW_;OE#>7b+p4b*I1a;Na`iUnl*k z(AJyrREfX69gC|23#Z8#tY|}{7Cd!zGqqscMBB`dkYgaQ2bhDj3B%dLn};1R60_Vf zXtksd9Vj!{I;+L{HVyziV_Tg4%SuDn#WGKGpc7n#ijbuPS>2L19z6hGl_aDvjmC^v zSMPRjTcp?fOH86ZAOEehZS{Jj0CsIXOI;4us7XjemM(@^mu;MlgunezKJi|;v`Bs= zP8-oJtOrtoeNu4SGR4E$soy|@j0$~O-AnxJF>Rf{PSP;Ghg}6DP~4Pgswa`QIo+cz zeKl19L-hyRLFV6kXcspg*q-Tg&*&;hYLIpg|AidQ{=a=j!3XTV90{r2D2`jQ3rpea zr}f*;iA@C@b;I@k4*aA`mdQ*PEA3Slx6siupk(jP;SSFUqg297r+4c)s{vG)7n0!W6SY^lH6Wacs@#eTHv&M?roNHzpq22x#%en%u3|ODTa4_ zwQfx6OIIBlcxcE$kjA*g*`{4q#{oXqDRX5bkgQePP9I}yZ^kcAL>TZt=^nd+zWVi; z|7E`r|CLahzxE3b7;C|Ak+T5o0I-v?#z2~SA>{P^xD6D%4MZG#zXf;s3_(IxQUI*YNo6In~hC#hb%gSY%(}6pT z0VWtY7?{K_A?1R@W=IBo!Rrg@#6I@d6MB3T0I}ul3)O+gPHkoflcJGH+`?Nm{OCsG zYwy370jgtD{g8e8Eq5M(t}*1a0lYZD?W3Lg$wI#bzT;(}lD$rFq+I;3Wa(sShLBl9 zddh7Kx<3xWRC(1f5SDNbfCEJw>_F8znILD<@CD6baNs?(9?{KJovdR>9R!HLb8O&Vv^Tfu??+F9^1o7zpM6OtSw?8UwEwo;K@kuHi!

vV^X5nBAcA97ZD9; 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

@fB`B|FwsTzMc5+ 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 literal 0 HcmV?d00001 diff --git a/media/metamon_logo.png b/media/metamon_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..e28a0ffb20a5c29062410a1e8c72fd6ea336922b GIT binary patch literal 692426 zcmb4r1whnG_x6$k1}Y#3B3uIr38iZZ1qA8tC8WEhc0ok0NT`66v~+jxf~Y9n4T~Tl zwZH<)_J?u3dSCDR{y$@x-QSs+GiRRj%$YN@Jl0fKI!VJ!0{{R{D&M)S4FFIc70Cfq zUw&NkcrJhRgTzZ)Nghzvcj5EV8%-NSWm`2h0QXUu3P4J77C`nz5A`)MZWUr#9knPh*Jzf=GK-i(xo991-O&^7cnR8y6(c5~sgyys?R!*}1s z{i}r3eTk!@i;cG>`+XN@S1*bC(wDwUNF0^Fe9V7|{i}$#lk_D+HBI(gZk{&mB76dT z0+)a^?Ck7Pp7(4ev~MeZuYUAS`jWl3x4Q&Czn`BUpPwL~o2MQB4RLXCeu10(H*fMD zN$`64yLwyR=XLeE{H>9{+PQ7xW$o$U?(N{_%KoKYODi`YZ|O^yzI62C&o@229c=&Z z$<^!ou#N`E|K$n)4L$+>A8j91mHP6jgodYs%~8)^+6Uf{`YQR$XW#pg;{Ve5Ps)57 z>DNz>h6<#S;{RbbAkBI6kH-K2S%C6wIo79BSlp;{X)HeY<|wA*;f^a9o3l}KeinlnO_*4Z~{ZVCws3C^0$ zdi(X-%?x9q>%EZHd|w?iGaq%m&DQ>OFiX?H9HL>&uXSZ3IK6hx?tF*!JgI`^MjLGx z$r)Jy3HfQUYi%nh5=lv^*h2vS{?dG1_S!68m{(IWLFYT&T|53ic^(YQhfskX%hnf>}AzsL)u zlLqm{*fnc%e^(_*lLTt&0(am2ygt_JfMWHc3S1}zgqM+4+PMpaS~ImyGw;6U#F#k`mp~tkg>B=kCXO0cvNcu zpt3qQAljYhL?7BsJ;UKjOa)^Krja8FCuEd`A8>X1>D-% zFUIm>U^BAnRH)JE`4y*g#^@oRS9pGx4(9)6liye@i89nO_Qi#Ro&2nm1!Ui~`w8R% zNK%?hn<^^u5A*~fg(UyYlz+qG_oZzrlC36x@(*wLS8G4J&Nd3~SdbOT_6N&GEDBJm zJ_L;8Zwt|d-v)%v9)V-QmDB6g{~k+d1oeOsUf|T+SCxAzLWrs_KlQ&==EI%<`RcNM zMM&u9bMwc$4mKq>fs5uc32p`)(k&x;0$adDZqe>EObfn;O)I; zV!>Lk|4(E8shQtu8CrST;`ErI5Lx(l$xw$gNeBD}_^~4^#m$UKjzaSHbIPy{iEbmn zmykM&Lv+&SLH@xvBt*$uOu%K1*h4GnMl--BS0@)t&_7jMgo573$POYPsjgH%l@TDFX@R(smo)z zu7CCSV;BOOp+=`(^?1%63piu8)V>W<5%W!@A}K>Ww&$Sv?-VL{mQ>)31JN>X(B8bb(`c{}?h;}GWQc!@7uq{H79F+L)C zgW-$MD{{B3^xQ#3GsW$?{f~J!D6ZsvGgAf z^G^%40mw(Vx`S?#xqmu+B;0nJM&PF1rPOG7TEmcxFp@A&u`e9{fZ|YfqV?Uh%la%3 zo_WQucqR-OD>IiFE6%AC6ZYL3Z=D-R3<=$r*a^3KAN}^@Ph7vM6N19BqZ;m4b0; zeKPCo5wpnot!2nFw9dd)qC+{Wb%>2p<&$$-)Onwq@5@x~L?iXJ80g7L>{a;S~ty*X@ z-L~JFS3RLeSdu~U`)5Z%$KA1i1|3QCBm~8j_!rqP|2cIhcPH|vtJ_zN{T}=XG)ed z?w{i6FRX~c=K9UdAs_B)I{!RHOyK~?ohbP`Q1%HM&$s;iDGp$Hu68r z#MB4?#VgH`(`V9>l)-`96AWLG^N*L%*>mifs5>627ad-o{i`>N7Ze;z*pEvus_WCz zJCvOb5>l1*uT_5J6#R|9|5&U3>3TmN2%HU_P^Io zZkf-WaKf4u?@#T=U2Q`lH>aH#gC3=1AFal4^Vr&IzRY#Ef^k#{RvYq{vOfm=ZH6s4 z$X7R#9^2+U|0lry5jXt$mX4?G4(zdu1kJY{L)%q~M`$###~M^6U-APw|IHaf&of%Q zc^w4;SnCIub>46~9r+h;TxOVW9=wdcHhXWVoA~}*a@5rZ4Mj;Um2U%hbZG#=7z#$Q zspN`r{xtmmrM&<(DHw1rtb!vem-F)Xda6-h_dwie@@cn0_>>m*h_59-pPYu|Bdag| zn0SjO@^^SF!rKAl@1i&h#HSx^C(BKpD*_8}xA53{_>g*&8*HBqg~z&N9O>nkVVUSQ zh0kjRcR+y{X&?blQzi3TMRnL^Gu?j^p?$Y>TP_9F1M{gz!oM9_R5(DcrP7{~=Ejv! zj}~ZWrRuGPm5OhoQK}*R)6WLIto~)re}Mp>PRJa-`=M3!GaC1_nD~siFW#lYQ!$;v zA9h6Uv7#;~c{3{0TMkuPueBci!x>DOJIDS2>n|u4WS5=ys)>%0A>=U3Z~Rto_KMQB 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

{Zg)K)YFmBlm;WfgLr(^pSN#f7%)t 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%0D{Ye%8|y^! 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�R67MZlN`5fq43I0n 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 z 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

    n!4O%VW-aaC>QCbZ5r~I3iwjx3dxF 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

    <}@`j2oAW4_;5w7tbW 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 rK70?Xw4S(6Gyi^oG0+DK_?Z6ld&x?2OWiuv!T list[str]: + """Find raw replay JSON files for a battle format. + + Supports both directory layouts: + - {raw_replay_dir}/{format}/**/*.json (e.g. gen1ou/2026/02/...) + - {raw_replay_dir}/{gen}/{tier}/**/*.json (legacy HF cache layout) + """ + gen = battle_format[:4] + tier = battle_format[4:].lower() + search_roots = [ + os.path.join(raw_replay_dir, battle_format), + os.path.join(raw_replay_dir, gen, tier), + ] + filenames = [] + seen = set() + for path in search_roots: + if not os.path.isdir(path): + continue + for filename in glob.glob(f"{path}/**/*.json", recursive=True): + realpath = os.path.realpath(filename) + if realpath not in seen: + seen.add(realpath) + filenames.append(filename) + return filenames + + if __name__ == "__main__": from argparse import ArgumentParser @@ -20,7 +47,7 @@ parser.add_argument( "--raw_replay_dir", default=None, - help="Path to raw replay dataset folder. Defaults to the latest huggingface version.", + help="Path to raw replay dataset folder. Accepts {format}/... or legacy {gen}/{tier}/... layouts. Defaults to the cached HF raw-replays download.", ) parser.add_argument("--max", type=int, help="Parse up to this many replays.") parser.add_argument( @@ -77,10 +104,14 @@ if args.raw_replay_dir is None: args.raw_replay_dir = os.path.join(metamon.METAMON_CACHE_DIR, "raw-replays") - gen = args.format[:4] - format = args.format[4:].lower() - path = os.path.join(args.raw_replay_dir, gen, format) - filenames = glob.glob(f"{path}/**/*.json", recursive=True) + filenames = list_raw_replay_files(args.raw_replay_dir, args.format) + if not filenames: + raise FileNotFoundError( + f"No raw replays found for {args.format} under {args.raw_replay_dir}. " + f"Expected {args.format}/**/*.json or " + f"{args.format[:4]}/{args.format[4:].lower()}/**/*.json" + ) + print(f"Found {len(filenames)} raw replays for {args.format}") random.shuffle(filenames) if args.filter_by_code is not None: filenames = [f for f in filenames if args.filter_by_code in f] diff --git a/metamon/backend/replay_parser/backward.py b/metamon/backend/replay_parser/backward.py index 3a8e7a372e..842eba9d4b 100644 --- a/metamon/backend/replay_parser/backward.py +++ b/metamon/backend/replay_parser/backward.py @@ -24,6 +24,8 @@ def fill_missing_team_info( date_played: datetime.date, poke_list: List[Pokemon], team_predictor: TeamPredictor, + rating: Optional[int | str] = None, + gameid: Optional[str] = None, ) -> List[Pokemon]: """ Team prediction works by: @@ -45,7 +47,9 @@ def fill_missing_team_info( # 2. Predict the team try: - predicted_team = team_predictor.predict(revealed_team, date=date_played) + predicted_team = team_predictor.predict( + revealed_team, date=date_played, rating=rating, gameid=gameid + ) except Exception as e: raise BackwardException(f"Error predicting team: {e}") if not revealed_team.is_consistent_with(predicted_team): @@ -306,12 +310,16 @@ def add_filled_final_turn( date_played=date_played, poke_list=replay[-1].pokemon_1, team_predictor=team_predictor, + rating=replay.ratings[0], + gameid=replay.gameid, ) filled_turn.pokemon_2, revealed_team_2 = fill_missing_team_info( replay.format, date_played=date_played, poke_list=replay[-1].pokemon_2, team_predictor=team_predictor, + rating=replay.ratings[1], + gameid=replay.gameid, ) replay.turnlist.append(filled_turn) return replay, (revealed_team_1, revealed_team_2) diff --git a/metamon/backend/replay_parser/checks.py b/metamon/backend/replay_parser/checks.py index 19e0b6de00..bb3f9ce405 100644 --- a/metamon/backend/replay_parser/checks.py +++ b/metamon/backend/replay_parser/checks.py @@ -222,7 +222,7 @@ def check_action_alignment(replay): active = turn.active_pokemon_1 if replay.from_p1_pov else turn.active_pokemon_2 switches = turn.get_switches(replay.from_p1_pov) for active_pokemon, action in zip(active, team_actions): - if action is None or action.name in ["Struggle"] or action.is_noop: + if action is None or action.name in {"Struggle", "Fight"} or action.is_noop: # considered a "no-op" continue elif action.name == "Switch": @@ -269,7 +269,7 @@ def check_action_idxs( raise ActionIndexError(f"Found Tera action in gen {gen}") if tera > 1: raise ActionIndexError(f"Found {tera} Tera actions") - if action.name in {"Struggle", "Recharge"} and action_idx != 0: + if action.name in {"Struggle", "Recharge", "Fight"} and action_idx != 0: # check struggle and recharge special case move overrides raise ActionIndexError( f"{action.name} is action index {action_idx}; expected to be 0" diff --git a/metamon/backend/replay_parser/forward.py b/metamon/backend/replay_parser/forward.py index 21e6b57927..65a4f3fda7 100644 --- a/metamon/backend/replay_parser/forward.py +++ b/metamon/backend/replay_parser/forward.py @@ -312,7 +312,7 @@ def _parse_choice(self, args: List[str]): if ( command == "move" and choice_args - and choice_args.lower() not in {"recharge", "struggle"} + and choice_args.lower() not in {"recharge", "struggle", "fight"} ): user_pokemon = ( self.curr_turn.active_pokemon_1[poke_idx] diff --git a/metamon/backend/replay_parser/replay_state.py b/metamon/backend/replay_parser/replay_state.py index e2c13a2898..6da7880192 100644 --- a/metamon/backend/replay_parser/replay_state.py +++ b/metamon/backend/replay_parser/replay_state.py @@ -326,7 +326,7 @@ def get_pp_for_move_name(self, move_name: str) -> Optional[int]: return self.moves[move_name].pp def reveal_move(self, move: Move): - if move.name in {"Struggle", "Recharge"}: + if move.name in {"Struggle", "Recharge", "Fight"}: return if self.transformed_into is not None: diff --git a/metamon/backend/replay_parser/str_parsing.py b/metamon/backend/replay_parser/str_parsing.py index 4736a62724..a41627f94d 100644 --- a/metamon/backend/replay_parser/str_parsing.py +++ b/metamon/backend/replay_parser/str_parsing.py @@ -39,11 +39,11 @@ def move_name(name: str) -> str: def parse_hp_fraction(raw: str) -> tuple[int, int]: - fracs = re.findall(r"\b\d+/\d+\b", raw) - if len(fracs) != 1 or "/" not in fracs[0]: + # Showdown may suffix the denominator with g/y/r (HP bar color hints). + m = re.search(r"(\d+)/(\d+)(?:[gyr])?\b", raw) + if not m: raise StrParsingException("parse_hp_fraction", raw) - num, den = [int(x) for x in fracs[0].split("/")] - return num, den + return int(m.group(1)), int(m.group(2)) def parse_condition(raw: str) -> str: diff --git a/metamon/backend/team_construction/__init__.py b/metamon/backend/team_construction/__init__.py new file mode 100644 index 0000000000..f3979ba072 --- /dev/null +++ b/metamon/backend/team_construction/__init__.py @@ -0,0 +1,7 @@ +from .core import BattleExample, PokemonSet, Team + +__all__ = [ + "BattleExample", + "PokemonSet", + "Team", +] diff --git a/metamon/backend/team_construction/artifacts.py b/metamon/backend/team_construction/artifacts.py new file mode 100644 index 0000000000..ec14697daa --- /dev/null +++ b/metamon/backend/team_construction/artifacts.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +import pickle +from pathlib import Path +from typing import Any + + +def save_artifact(path: Path, payload: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("wb") as f: + pickle.dump(payload, f, protocol=pickle.HIGHEST_PROTOCOL) + + +def load_artifact(path: Path) -> dict[str, Any]: + with path.open("rb") as f: + payload = pickle.load(f) + if not isinstance(payload, dict): + raise ValueError(f"Expected dict artifact in {path}, got {type(payload)}") + return payload diff --git a/metamon/backend/team_construction/cli.py b/metamon/backend/team_construction/cli.py new file mode 100644 index 0000000000..2476730e3b --- /dev/null +++ b/metamon/backend/team_construction/cli.py @@ -0,0 +1,2483 @@ +from __future__ import annotations + +import argparse +import json +import os +import random +import shutil +import sys +import time +from pathlib import Path +from typing import Sequence + +import numpy as np +from tqdm import tqdm + +from .core import Team +from .pokemon_pool import ( + build_species_clause_keys, + get_eligible_pokemon, + load_pool_artifact, + pool_pokemon_sets, + save_pool_artifact, + team_ids_to_showdown, + team_string_to_ids, +) +from .restricted_game import ( + build_payoff_matrix, + build_strategy_pool, + build_strategy_pool_double_oracle, + payoff_antisymmetry_error, + solve_zero_sum_equilibrium, +) +from .artifacts import load_artifact, save_artifact +from .matchup import run_matchup +from .model_fit import fit_baseline_model, fit_interaction_model +from .model_scoring import interaction_matrices, make_scorer +from .coordinate_ascent import ( + coordinate_ascent_best_team, + coordinate_ascent_multi_start, + objective_vs_fixed_opponent, + objective_vs_mixture, + objective_vs_metagame, + sample_opponent_teams, + top_theta_init_team, +) +from .simulation import ( + SimulationMetadata, + load_examples_jsonl, + make_active_matchup_sampler, + make_uniform_matchup_sampler, + sample_team, + save_simulation_metadata, + simulate_battles, +) + + +def _parse_float_csv(raw: str) -> list[float]: + out = [float(x.strip()) for x in raw.split(",") if x.strip()] + if not out: + raise ValueError(f"Expected comma-separated float list, got '{raw}'") + return out + + +def _load_pool(pool_path: Path): + pool_data = load_pool_artifact(pool_path) + pokemon_sets = pool_pokemon_sets(pool_data) + species = [ps.species for ps in pokemon_sets] + format_id = str(pool_data.get("format_id") or "") + if format_id: + species_clause_keys = build_species_clause_keys(format_id, pokemon_sets) + else: + species_clause_keys = {idx: idx for idx in range(len(pokemon_sets))} + return pool_data, pokemon_sets, species, species_clause_keys + + +def _assert_model_pool_compat(model: dict, pokemon_sets) -> None: + if "num_pokemon" not in model: + return + expected = int(model["num_pokemon"]) + actual = len(pokemon_sets) + if expected != actual: + raise ValueError( + f"Model expects {expected} Pokemon features, but pool has {actual}. " + "Use a model and pool built from the same eligible set." + ) + + +def _team_to_species(team: Team, species: Sequence[str]) -> list[str]: + return [species[idx] for idx in team] + + +def _print_team(label: str, team: Team, species: Sequence[str]) -> None: + names = _team_to_species(team, species) + print(f"{label}: {', '.join(names)}") + + +def _print_restart_summary(runs: list[dict], species: Sequence[str]) -> None: + if not runs: + return + print("Restart summary:") + for row in runs: + marker = "*" if row.get("selected") else " " + start_names = ", ".join(species[idx] for idx in row["start_team"]) + final_names = ", ".join(species[idx] for idx in row["final_team"]) + print( + f" {marker} restart={row['restart_index']:>2} swaps={row['accepted_swaps']:>2} " + f"objective={row['objective']:.6f}" + ) + print(f" start=[{start_names}]") + print(f" final=[{final_names}]") + + +def _print_baseline_report( + model: dict, species: Sequence[str], top_k: int = 30 +) -> None: + theta = np.asarray(model["theta"], dtype=np.float64) + order = np.argsort(theta)[::-1] + + print("\nBaseline theta ranking (descending):") + for rank, idx in enumerate(order[: min(top_k, len(order))], start=1): + print(f" {rank:>2}. {species[idx]}\t{theta[idx]:+.5f}") + + +def _top_k_pairs( + scores: np.ndarray, names: Sequence[str], k: int +) -> list[tuple[str, float]]: + order = np.argsort(scores)[::-1] + out: list[tuple[str, float]] = [] + for idx in order: + if not np.isfinite(scores[idx]): + continue + out.append((names[idx], float(scores[idx]))) + if len(out) >= k: + break + return out + + +def _print_interaction_report( + model: dict, species: Sequence[str], k_detail: int = 3 +) -> None: + theta, synergy_matrix, matchup_matrix = interaction_matrices(model) + order = np.argsort(theta)[::-1] + + print("\nInteraction theta ranking (descending):") + for rank, idx in enumerate(order, start=1): + print(f" {rank:>2}. {species[idx]}\t{theta[idx]:+.5f}") + + print("\nPer-Pokemon interaction summary:") + for idx in order: + partner_scores = synergy_matrix[idx].copy() + partner_scores[idx] = -np.inf + partners = _top_k_pairs(partner_scores, species, k=k_detail) + + counter_scores = matchup_matrix[idx].copy() # i counters j + counter_scores[idx] = -np.inf + counters = _top_k_pairs(counter_scores, species, k=k_detail) + + countered_by_scores = matchup_matrix[:, idx].copy() # j counters i + countered_by_scores[idx] = -np.inf + countered_by = _top_k_pairs(countered_by_scores, species, k=k_detail) + + fmt = lambda rows: ", ".join(f"{name} ({score:+.3f})" for name, score in rows) + print(f" {species[idx]}:") + print(f" top synergy partners: {fmt(partners)}") + print(f" top counters: {fmt(counters)}") + print(f" countered-by: {fmt(countered_by)}") + + +def _maybe_attach_pool_metadata( + model: dict, pool_data: dict, species: Sequence[str] +) -> dict: + out = dict(model) + out["format_id"] = pool_data.get("format_id") + out["species"] = list(species) + out["pool_size"] = len(species) + return out + + +def _merge_jsonl_files(inputs: Sequence[Path], out: Path) -> int: + out.parent.mkdir(parents=True, exist_ok=True) + total = 0 + with out.open("w", encoding="utf-8") as fout: + for src in inputs: + with src.open("r", encoding="utf-8") as fin: + for line in fin: + row = line.strip() + if not row: + continue + fout.write(row + "\n") + total += 1 + return total + + +def _count_jsonl_rows(path: Path) -> int: + count = 0 + with path.open("r", encoding="utf-8") as f: + for line in f: + if line.strip(): + count += 1 + return count + + +def _wandb_log_pool_snapshot( + *, + wandb_run, + wandb_mod, + pokemon_sets, + top_k: int = 20, +) -> None: + if wandb_run is None: + return + if not pokemon_sets: + return + + usage_pct = np.asarray( + [float(ps.usage) * 100.0 for ps in pokemon_sets], dtype=np.float64 + ) + order = np.argsort(usage_pct)[::-1] + top_idxs = order[: min(top_k, len(order))] + top_rows = [ + [ + pokemon_sets[idx].species, + float(usage_pct[idx]), + str(pokemon_sets[idx].ability or ""), + ] + for idx in top_idxs + ] + top_table = wandb_mod.Table( + data=top_rows, + columns=["species", "usage_pct", "ability"], + ) + wandb_run.log( + { + "pool/usage_pct_histogram": wandb_mod.Histogram(usage_pct), + "pool/top_species_table": top_table, + "pool/top_species_usage_bar": wandb_mod.plot.bar( + top_table, + "species", + "usage_pct", + title=f"Top {len(top_rows)} Pool Usage (%)", + ), + } + ) + + +def _wandb_log_model_snapshot( + *, + wandb_run, + wandb_mod, + stage_prefix: str, + model: dict, + species: Sequence[str], + top_k: int = 20, +) -> None: + if wandb_run is None: + return + + payload: dict[str, object] = {} + theta = np.asarray(model.get("theta", []), dtype=np.float64) + if theta.size: + order = np.argsort(theta)[::-1] + top_idxs = order[: min(top_k, len(order))] + top_rows = [[species[idx], float(theta[idx])] for idx in top_idxs] + top_table = wandb_mod.Table(data=top_rows, columns=["species", "theta"]) + payload[f"{stage_prefix}/theta_histogram"] = wandb_mod.Histogram(theta) + payload[f"{stage_prefix}/top_theta_table"] = top_table + payload[f"{stage_prefix}/top_theta_bar"] = wandb_mod.plot.bar( + top_table, + "species", + "theta", + title=f"{stage_prefix}: Top Theta Weights", + ) + + fit_info = model.get("fit", {}) if isinstance(model.get("fit", {}), dict) else {} + val_metrics = fit_info.get("val_metrics") + if isinstance(val_metrics, dict): + for metric_name, metric_value in val_metrics.items(): + payload[f"{stage_prefix}/val_{metric_name}"] = float(metric_value) + + tuning_rows: list[list[float]] = [] + tuning = fit_info.get("tuning", []) + if isinstance(tuning, list): + for row in tuning: + if not isinstance(row, dict): + continue + metrics = row.get("val_metrics") + if not isinstance(metrics, dict): + continue + tuning_rows.append( + [ + float(row.get("C", 0.0)), + float(metrics.get("log_loss", float("nan"))), + float(metrics.get("accuracy", float("nan"))), + float(metrics.get("brier", float("nan"))), + ] + ) + if tuning_rows: + tuning_rows.sort(key=lambda r: r[0]) + tuning_table = wandb_mod.Table( + data=tuning_rows, + columns=["C", "log_loss", "accuracy", "brier"], + ) + payload[f"{stage_prefix}/c_tuning_table"] = tuning_table + payload[f"{stage_prefix}/c_vs_log_loss"] = wandb_mod.plot.line( + tuning_table, + "C", + "log_loss", + title=f"{stage_prefix}: C vs Validation Log Loss", + ) + payload[f"{stage_prefix}/c_vs_accuracy"] = wandb_mod.plot.line( + tuning_table, + "C", + "accuracy", + title=f"{stage_prefix}: C vs Validation Accuracy", + ) + + # Interaction-model-specific summaries (if available). + try: + _, synergy_matrix, matchup_matrix = interaction_matrices(model) + n = min(len(species), int(synergy_matrix.shape[0])) + if n >= 2: + synergy_rows: list[list[object]] = [] + for i in range(n): + for j in range(i + 1, n): + synergy_rows.append( + [f"{species[i]} + {species[j]}", float(synergy_matrix[i, j])] + ) + synergy_rows.sort(key=lambda r: float(r[1]), reverse=True) + synergy_rows = synergy_rows[: min(top_k, len(synergy_rows))] + if synergy_rows: + synergy_table = wandb_mod.Table( + data=synergy_rows, + columns=["pair", "synergy_score"], + ) + payload[f"{stage_prefix}/top_synergy_pairs_table"] = synergy_table + payload[f"{stage_prefix}/top_synergy_pairs_bar"] = wandb_mod.plot.bar( + synergy_table, + "pair", + "synergy_score", + title=f"{stage_prefix}: Top Synergy Pairs", + ) + + counter_rows: list[list[object]] = [] + for i in range(n): + for j in range(n): + if i == j: + continue + counter_rows.append( + [f"{species[i]} > {species[j]}", float(matchup_matrix[i, j])] + ) + counter_rows.sort(key=lambda r: float(r[1]), reverse=True) + counter_rows = counter_rows[: min(top_k, len(counter_rows))] + if counter_rows: + counter_table = wandb_mod.Table( + data=counter_rows, + columns=["counter_pair", "counter_score"], + ) + payload[f"{stage_prefix}/top_counter_pairs_table"] = counter_table + payload[f"{stage_prefix}/top_counter_pairs_bar"] = wandb_mod.plot.bar( + counter_table, + "counter_pair", + "counter_score", + title=f"{stage_prefix}: Top Counter Pairs", + ) + except Exception: + pass + + if payload: + wandb_run.log(payload) + + +def _wandb_log_metagame_outputs( + *, + wandb_run, + wandb_mod, + best_team: Team, + avg_prob: float, + history: list[dict], + restart_runs: list[dict], + species: Sequence[str], +) -> None: + if wandb_run is None: + return + + payload: dict[str, object] = { + "metagame/best_team_avg_win_prob": float(avg_prob), + } + + best_team_rows = [ + [slot + 1, int(member), species[member]] + for slot, member in enumerate(best_team) + ] + payload["metagame/best_team_table"] = wandb_mod.Table( + data=best_team_rows, + columns=["slot", "pokemon_id", "species"], + ) + + history_rows: list[list[object]] = [] + for step, row in enumerate(history): + history_rows.append( + [ + int(step), + str(row.get("event", "")), + float(row.get("objective", 0.0)), + ] + ) + if history_rows: + history_table = wandb_mod.Table( + data=history_rows, + columns=["step", "event", "objective"], + ) + payload["metagame/objective_trace_table"] = history_table + payload["metagame/objective_trace_line"] = wandb_mod.plot.line( + history_table, + "step", + "objective", + title="Coordinate-Ascent Objective Trace", + ) + + restart_rows: list[list[object]] = [] + for row in restart_runs: + final_team = row.get("final_team", []) + restart_rows.append( + [ + int(row.get("restart_index", -1)), + int(row.get("accepted_swaps", 0)), + float(row.get("objective", 0.0)), + bool(row.get("selected", False)), + ", ".join(species[idx] for idx in final_team), + ] + ) + if restart_rows: + payload["metagame/restarts_table"] = wandb_mod.Table( + data=restart_rows, + columns=[ + "restart_index", + "accepted_swaps", + "objective", + "selected", + "final_team", + ], + ) + + wandb_run.log(payload) + + +def _wandb_log_benchmark_outputs( + *, + wandb_run, + wandb_mod, + benchmark: dict | None, +) -> None: + if wandb_run is None or not benchmark or not benchmark.get("ran", False): + return + + payload: dict[str, object] = {} + pipeline_wr = float(benchmark.get("pipeline_win_rate", 0.0)) + random_mean = float(benchmark.get("random_mean_win_rate", 0.0)) + random_std = float(benchmark.get("random_std_win_rate", 0.0)) + margin = float(benchmark.get("margin_vs_random_mean", 0.0)) + payload["benchmark/pipeline_win_rate"] = pipeline_wr + payload["benchmark/random_mean_win_rate"] = random_mean + payload["benchmark/random_std_win_rate"] = random_std + payload["benchmark/margin_vs_random_mean"] = margin + + compare_table = wandb_mod.Table( + data=[ + ["pipeline_team", pipeline_wr], + ["random_mean", random_mean], + ], + columns=["group", "win_rate"], + ) + payload["benchmark/pipeline_vs_random_bar"] = wandb_mod.plot.bar( + compare_table, + "group", + "win_rate", + title="Pipeline Team vs Random-Team Baseline", + ) + + random_samples = benchmark.get("random_samples", []) + if isinstance(random_samples, list) and random_samples: + rows: list[list[object]] = [] + random_wrs: list[float] = [] + for idx, row in enumerate(random_samples, start=1): + wr = float(row.get("win_rate", 0.0)) + random_wrs.append(wr) + rows.append( + [ + int(idx), + wr, + ", ".join(str(name) for name in row.get("team_species", [])), + ] + ) + random_table = wandb_mod.Table( + data=rows, + columns=["sample_idx", "win_rate", "team_species"], + ) + payload["benchmark/random_samples_table"] = random_table + payload["benchmark/random_win_rate_histogram"] = wandb_mod.Histogram( + np.asarray(random_wrs, dtype=np.float64) + ) + payload["benchmark/random_win_rate_scatter"] = wandb_mod.plot.scatter( + random_table, + "sample_idx", + "win_rate", + title="Random-Team Win Rate Samples", + ) + + wandb_run.log(payload) + + +def _wandb_log_equilibrium_outputs( + *, + wandb_run, + wandb_mod, + eq_payload: dict, + support_tol: float, +) -> None: + if wandb_run is None: + return + + payload: dict[str, object] = {} + + equilibrium = eq_payload.get("equilibrium", {}) + row_mixture = np.asarray(equilibrium.get("row_mixture", []), dtype=np.float64) + strategy_species = eq_payload.get("strategy_species", []) + + if row_mixture.size: + payload["equilibrium/game_value"] = float(equilibrium.get("game_value", 0.0)) + payload["equilibrium/row_mixture_histogram"] = wandb_mod.Histogram(row_mixture) + + mix_rows: list[list[object]] = [] + for idx, prob in enumerate(row_mixture.tolist()): + label = "" + if idx < len(strategy_species): + label = ", ".join(str(name) for name in strategy_species[idx]) + mix_rows.append([int(idx), float(prob), label]) + + mix_table = wandb_mod.Table( + data=mix_rows, + columns=["strategy_idx", "probability", "team_species"], + ) + payload["equilibrium/row_mixture_table"] = mix_table + payload["equilibrium/row_mixture_bar"] = wandb_mod.plot.bar( + mix_table, + "strategy_idx", + "probability", + title="Equilibrium Row Mixture", + ) + + support_rows = [row for row in mix_rows if float(row[1]) > float(support_tol)] + if support_rows: + payload["equilibrium/support_table"] = wandb_mod.Table( + data=support_rows, + columns=["strategy_idx", "probability", "team_species"], + ) + + payoff = np.asarray(eq_payload.get("payoff_matrix", []), dtype=np.float64) + if payoff.size: + payload["equilibrium/payoff_value_histogram"] = wandb_mod.Histogram( + payoff.reshape(-1) + ) + payload["equilibrium/antisymmetry_error"] = float( + eq_payload.get("antisymmetry_error", 0.0) + ) + + oracle_rows: list[list[float]] = [] + pool_expansion = eq_payload.get("pool_expansion", {}) + if isinstance(pool_expansion, dict): + for row in pool_expansion.get("double_oracle_iterations", []): + if not isinstance(row, dict): + continue + oracle_rows.append( + [ + float(row.get("iteration", 0)), + float(row.get("game_value", 0.0)), + float(row.get("best_response_value", 0.0)), + float(row.get("exploitability", 0.0)), + ] + ) + if oracle_rows: + oracle_table = wandb_mod.Table( + data=oracle_rows, + columns=[ + "iteration", + "game_value", + "best_response_value", + "exploitability", + ], + ) + payload["equilibrium/double_oracle_table"] = oracle_table + payload["equilibrium/exploitability_line"] = wandb_mod.plot.line( + oracle_table, + "iteration", + "exploitability", + title="Double-Oracle Exploitability", + ) + payload["equilibrium/game_value_line"] = wandb_mod.plot.line( + oracle_table, + "iteration", + "game_value", + title="Double-Oracle Game Value", + ) + + if payload: + wandb_run.log(payload) + + +def _wandb_log_run_artifact( + *, + wandb_run, + wandb_mod, + artifact_name: str, + file_paths: Sequence[Path | None], +) -> None: + if wandb_run is None: + return + artifact = wandb_mod.Artifact(name=artifact_name, type="team_construction_run") + added = 0 + for path in file_paths: + if path is None: + continue + p = Path(path) + if not p.exists(): + continue + artifact.add_file(str(p), name=p.name) + added += 1 + if added > 0: + wandb_run.log_artifact(artifact) + + +def _init_custom_teamset_dir( + cache_dir: Path, set_name: str, battle_format: str +) -> Path: + target_dir = cache_dir / "teams" / set_name / battle_format + target_dir.mkdir(parents=True, exist_ok=True) + for old in target_dir.glob(f"*.{battle_format}_team"): + old.unlink() + return target_dir + + +def _write_custom_team(team_dir: Path, battle_format: str, team_text: str) -> None: + team_file = team_dir / f"team_0001.{battle_format}_team" + team_file.write_text(team_text.strip() + "\n", encoding="utf-8") + + +def _run_matchup_with_retry( + *, + battle_format: str, + num_battles: int, + model_name: str, + team_set_a: str, + team_set_b: str, + gpu_a: int, + gpu_b: int, + work_dir: Path, + checkpoint: int | None, + max_retries: int, + retry_sleep_sec: float, + print_match_stats: bool, +) -> dict: + last_error = "" + for attempt in range(max_retries + 1): + try: + return run_matchup( + battle_format=battle_format, + num_battles=num_battles, + model_name=model_name, + team_set_a=team_set_a, + team_set_b=team_set_b, + gpu_a=gpu_a, + gpu_b=gpu_b, + work_dir=work_dir, + checkpoint=checkpoint, + print_match_stats=print_match_stats, + ) + except RuntimeError as exc: + last_error = str(exc) + if attempt >= max_retries: + break + time.sleep(retry_sleep_sec) + raise RuntimeError( + f"run_matchup failed for gpu pair ({gpu_a},{gpu_b}) after retries: {last_error}" + ) + + +def cmd_build_pool(args: argparse.Namespace) -> None: + pokemon_sets = get_eligible_pokemon( + format_id=args.format, + usage_month=args.usage_month, + usage_threshold=args.usage_threshold, + rank=args.rank, + replication_movesets_json=args.replication_movesets_json, + manual_sets_json=args.manual_sets_json, + strict_max_evs=args.strict_max_evs, + ) + + save_pool_artifact( + args.out, + format_id=args.format, + usage_month=args.usage_month, + usage_threshold=args.usage_threshold, + pokemon_sets=pokemon_sets, + metadata={ + "rank": args.rank, + "replication_movesets_json": ( + str(args.replication_movesets_json) + if args.replication_movesets_json + else None + ), + "manual_sets_json": ( + str(args.manual_sets_json) if args.manual_sets_json else None + ), + "strict_max_evs": bool(args.strict_max_evs), + }, + ) + + print(f"Saved pool with {len(pokemon_sets)} Pokemon to {args.out}") + print("Top by usage:") + for row in pokemon_sets[: min(15, len(pokemon_sets))]: + print(f" {row.species}: {row.usage * 100:.3f}%") + + +def cmd_simulate(args: argparse.Namespace) -> None: + pool_data, pokemon_sets, species, species_clause_keys = _load_pool(args.pool) + format_id = args.format or str(pool_data.get("format_id") or "") + if not format_id: + raise ValueError( + "Format missing: provide --format or include it in the pool artifact" + ) + + pool_ids = list(range(len(pokemon_sets))) + rng = random.Random(args.seed) + sampling_metadata: dict[str, object] = { + "strategy": args.sampling_strategy, + "team_size": int(args.team_size), + "replace": bool(args.replace), + } + + if args.sampling_strategy == "uniform": + sampler = make_uniform_matchup_sampler( + pool_ids, + team_size=args.team_size, + replace=args.replace, + rng=rng, + species_clause_keys=species_clause_keys, + ) + elif args.sampling_strategy == "active": + if args.active_model is None: + raise ValueError( + "--active-model is required when --sampling-strategy active" + ) + active_model = load_artifact(args.active_model) + _assert_model_pool_compat(active_model, pokemon_sets) + pair_eval = make_scorer(active_model) + sampler = make_active_matchup_sampler( + pool_ids, + pair_evaluator=pair_eval, + team_size=args.team_size, + replace=args.replace, + rng=rng, + candidate_pool_size=args.active_candidate_pool_size, + uniform_mix=args.active_uniform_mix, + min_uncertainty=args.active_min_uncertainty, + species_clause_keys=species_clause_keys, + ) + sampling_metadata.update( + { + "active_model": str(args.active_model), + "active_candidate_pool_size": int(args.active_candidate_pool_size), + "active_uniform_mix": float(args.active_uniform_mix), + "active_min_uncertainty": float(args.active_min_uncertainty), + } + ) + else: + raise ValueError(f"Unknown sampling strategy: {args.sampling_strategy}") + + heuristic_aliases = { + "simpleheuristicsplayer", + "simple_heuristics_player", + "simpleheuristics", + } + normalized_agent = str(args.agent).strip().lower() + resolved_backend = str(args.backend) + metamon_model_name: str | None = None + if resolved_backend == "poke_env" and normalized_agent not in heuristic_aliases: + resolved_backend = "metamon" + metamon_model_name = str(args.agent) + print( + "[simulate] Non-heuristic --agent detected under backend='poke_env'; " + f"routing simulation through backend='metamon' with model '{metamon_model_name}'." + ) + elif resolved_backend == "metamon": + metamon_model_name = str(args.agent) + if resolved_backend == "metamon": + sampling_metadata.update( + { + "metamon_model_name": metamon_model_name, + "metamon_checkpoint": args.checkpoint, + "metamon_gpu_a": int(args.gpu_a), + "metamon_gpu_b": int(args.gpu_b), + "metamon_work_dir": str(args.work_dir), + } + ) + + team_to_showdown = None + if resolved_backend in {"poke_env", "metamon"}: + team_to_showdown = lambda team: team_ids_to_showdown(team, pokemon_sets) + + examples = simulate_battles( + n=args.n, + sampler=sampler, + agent_class=args.agent, + format_id=format_id, + concurrency=args.concurrency, + seed=args.seed, + backend=resolved_backend, + team_to_showdown=team_to_showdown, + timeout_sec=args.timeout_sec, + max_retries=args.max_retries, + retry_sleep_sec=args.retry_sleep_sec, + metamon_model_name=metamon_model_name, + metamon_checkpoint=args.checkpoint, + metamon_gpu_a=args.gpu_a, + metamon_gpu_b=args.gpu_b, + metamon_work_dir=args.work_dir, + metamon_print_match_stats=args.print_match_stats, + incremental_out=args.out, + incremental_flush_every=args.flush_every, + progress_desc=( + f"Simulating {args.sampling_strategy} battles" + if args.sampling_strategy + else "Simulating battles" + ), + ) + + meta_path = args.metadata_out or args.out.with_suffix( + args.out.suffix + ".meta.json" + ) + save_simulation_metadata( + meta_path, + SimulationMetadata( + format_id=format_id, + agent_name=args.agent, + n_battles=args.n, + seed=args.seed, + backend=resolved_backend, + concurrency=args.concurrency, + sampling_strategy=args.sampling_strategy, + sampling_metadata=sampling_metadata, + ), + ) + + extra = { + "pokemon_index": { + species_name: idx for idx, species_name in enumerate(species) + }, + "team_size": args.team_size, + "replace": bool(args.replace), + "backend": resolved_backend, + "agent": args.agent, + "sampling_strategy": args.sampling_strategy, + "sampling_metadata": sampling_metadata, + } + with meta_path.open("r+", encoding="utf-8") as f: + payload = json.load(f) + payload.update(extra) + f.seek(0) + json.dump(payload, f, indent=2, sort_keys=True) + f.truncate() + + print(f"Simulated {len(examples)} battles -> {args.out}") + print(f"Saved simulation metadata -> {meta_path}") + + +def cmd_fit_baseline(args: argparse.Namespace) -> None: + pool_data, pokemon_sets, species, _ = _load_pool(args.pool) + examples = load_examples_jsonl(args.input) + + model = fit_baseline_model( + examples, + num_pokemon=len(pokemon_sets), + val_fraction=args.val_fraction, + split_seed=args.seed, + max_iter=args.max_iter, + ) + model = _maybe_attach_pool_metadata(model, pool_data, species) + + save_artifact(args.out, model) + print(f"Saved baseline model -> {args.out}") + + val_metrics = model.get("fit", {}).get("val_metrics") + if val_metrics is not None: + print( + "Validation metrics: " + + ", ".join(f"{k}={v:.6f}" for k, v in val_metrics.items()) + ) + _print_baseline_report(model, species) + + +def cmd_fit_interaction(args: argparse.Namespace) -> None: + pool_data, pokemon_sets, species, _ = _load_pool(args.pool) + examples = load_examples_jsonl(args.input) + + if args.tune_C: + c_values = _parse_float_csv(args.c_values) + else: + c_values = [float(args.C)] + + model = fit_interaction_model( + examples, + num_pokemon=len(pokemon_sets), + c_values=c_values, + val_fraction=args.val_fraction, + split_seed=args.seed, + max_iter=args.max_iter, + ) + model = _maybe_attach_pool_metadata(model, pool_data, species) + + save_artifact(args.out, model) + print(f"Saved interaction model -> {args.out}") + + tuning = model.get("fit", {}).get("tuning", []) + if tuning: + print("C tuning results:") + for row in tuning: + vm = row.get("val_metrics") + if vm is None: + print(f" C={row['C']:.6g}: no validation split") + else: + print( + f" C={row['C']:.6g}: log_loss={vm['log_loss']:.6f}, " + f"accuracy={vm['accuracy']:.6f}, brier={vm['brier']:.6f}" + ) + + print(f"Selected best C: {model['fit']['best_C']}") + _print_interaction_report(model, species, k_detail=args.detail_k) + + +def _resolve_init_team( + *, + init_mode: str, + init_team_text: str | None, + team_size: int, + theta: np.ndarray, + pool_size: int, + pokemon_sets, + seed: int, + species_clause_keys: dict[int, object], +) -> Team: + if init_mode == "top_theta": + return top_theta_init_team( + theta, + team_size=team_size, + species_clause_keys=species_clause_keys, + ) + if init_mode == "random": + rng = random.Random(seed) + return sample_team( + list(range(pool_size)), + team_size=team_size, + replace=False, + rng=rng, + species_clause_keys=species_clause_keys, + ) + if init_mode == "explicit": + if not init_team_text: + raise ValueError("--init-team is required when --init explicit") + return team_string_to_ids(init_team_text, pokemon_sets, team_size=team_size) + raise ValueError(f"Unknown init mode: {init_mode}") + + +def cmd_best_response(args: argparse.Namespace) -> None: + _, pokemon_sets, species, species_clause_keys = _load_pool(args.pool) + model = load_artifact(args.model) + _assert_model_pool_compat(model, pokemon_sets) + pair_eval = make_scorer(model) + + if args.vs_team_file is not None: + opponent_text = args.vs_team_file.read_text(encoding="utf-8") + elif args.vs is not None: + opponent_text = args.vs + else: + raise ValueError("Provide --vs or --vs-team-file") + + opponent = team_string_to_ids(opponent_text, pokemon_sets, team_size=args.team_size) + + theta = np.asarray(model["theta"], dtype=np.float64) + init_team = _resolve_init_team( + init_mode=args.init, + init_team_text=args.init_team, + team_size=args.team_size, + theta=theta, + pool_size=len(pokemon_sets), + pokemon_sets=pokemon_sets, + seed=args.seed, + species_clause_keys=species_clause_keys, + ) + + objective = objective_vs_fixed_opponent(pair_eval, opponent) + best_team, history, restart_runs = coordinate_ascent_multi_start( + objective, + primary_init=init_team, + pool_ids=list(range(len(pokemon_sets))), + team_size=args.team_size, + random_restarts=args.random_restarts, + seed=args.seed + 101, + species_clause_keys=species_clause_keys, + ) + + win_prob = float(pair_eval(best_team, opponent)) + + _print_team("Opponent team", opponent, species) + _print_team("Best response", best_team, species) + print(f"Predicted win probability: {win_prob:.6f}") + _print_restart_summary(restart_runs, species) + + print("Accepted swaps (objective monotonicity trace):") + for row in history: + if row["event"] == "init": + print(f" init objective={row['objective']:.6f}") + else: + print( + f" slot {row['slot']} out={species[row['out']]} in={species[row['in']]} " + f"objective={row['objective']:.6f}" + ) + + +def _optimize_metagame_team( + *, + model: dict, + pokemon_sets, + team_size: int, + n_opponents: int, + seed: int, + init_mode: str, + init_team_text: str | None, + random_restarts: int, + species_clause_keys: dict[int, object], +) -> tuple[Team, float, list[dict], list[Team], list[dict]]: + pair_eval = make_scorer(model) + theta = np.asarray(model["theta"], dtype=np.float64) + + init_team = _resolve_init_team( + init_mode=init_mode, + init_team_text=init_team_text, + team_size=team_size, + theta=theta, + pool_size=len(pokemon_sets), + pokemon_sets=pokemon_sets, + seed=seed, + species_clause_keys=species_clause_keys, + ) + + opponents = sample_opponent_teams( + list(range(len(pokemon_sets))), + n=n_opponents, + team_size=team_size, + seed=seed + 17, + replace=False, + species_clause_keys=species_clause_keys, + ) + objective = objective_vs_metagame(pair_eval, opponents) + best_team, history, restart_runs = coordinate_ascent_multi_start( + objective, + primary_init=init_team, + pool_ids=list(range(len(pokemon_sets))), + team_size=team_size, + random_restarts=random_restarts, + seed=seed + 271, + species_clause_keys=species_clause_keys, + ) + avg_prob = float(objective(best_team)) + return best_team, avg_prob, history, opponents, restart_runs + + +def cmd_optimize_metagame(args: argparse.Namespace) -> None: + _, pokemon_sets, species, species_clause_keys = _load_pool(args.pool) + model = load_artifact(args.model) + _assert_model_pool_compat(model, pokemon_sets) + + best_team, avg_prob, history, _, restart_runs = _optimize_metagame_team( + model=model, + pokemon_sets=pokemon_sets, + team_size=args.team_size, + n_opponents=args.N, + seed=args.seed, + init_mode=args.init, + init_team_text=args.init_team, + random_restarts=args.random_restarts, + species_clause_keys=species_clause_keys, + ) + + _print_team("Metagame-optimized team", best_team, species) + print(f"Predicted average win probability vs sampled metagame: {avg_prob:.6f}") + _print_restart_summary(restart_runs, species) + + print("Accepted swaps (objective monotonicity trace):") + for row in history: + if row["event"] == "init": + print(f" init objective={row['objective']:.6f}") + else: + print( + f" slot {row['slot']} out={species[row['out']]} in={species[row['in']]} " + f"objective={row['objective']:.6f}" + ) + + +def cmd_equilibrium(args: argparse.Namespace) -> None: + _, pokemon_sets, species, species_clause_keys = _load_pool(args.pool) + model = load_artifact(args.model) + _assert_model_pool_compat(model, pokemon_sets) + pair_eval = make_scorer(model) + + if args.seed_team_from == "metagame": + seed_team, avg_prob, _, _, seed_restart_runs = _optimize_metagame_team( + model=model, + pokemon_sets=pokemon_sets, + team_size=args.team_size, + n_opponents=args.metagame_N, + seed=args.seed, + init_mode="top_theta", + init_team_text=None, + random_restarts=args.metagame_random_restarts, + species_clause_keys=species_clause_keys, + ) + _print_team("Seed team (metagame optimized)", seed_team, species) + print(f"Seed-team average win probability: {avg_prob:.6f}") + _print_restart_summary(seed_restart_runs, species) + elif args.seed_team_from == "top_theta": + seed_team = top_theta_init_team( + np.asarray(model["theta"]), + team_size=args.team_size, + species_clause_keys=species_clause_keys, + ) + _print_team("Seed team (top theta)", seed_team, species) + else: + if not args.seed_team: + raise ValueError("--seed-team is required when --seed-team-from explicit") + seed_team = team_string_to_ids( + args.seed_team, pokemon_sets, team_size=args.team_size + ) + _print_team("Seed team (explicit)", seed_team, species) + + theta = np.asarray(model["theta"], dtype=np.float64) + init_for_br = top_theta_init_team( + theta, + team_size=args.team_size, + species_clause_keys=species_clause_keys, + ) + pool_ids = list(range(len(pokemon_sets))) + br_restart_traces: list[list[dict]] = [] + + if args.pool_expansion == "last_response": + br_counter = 0 + + def _best_response_to(team_last: Team) -> Team: + nonlocal br_counter + objective = objective_vs_fixed_opponent(pair_eval, team_last) + best, _, restart_runs = coordinate_ascent_multi_start( + objective, + primary_init=init_for_br, + pool_ids=pool_ids, + team_size=args.team_size, + random_restarts=args.br_random_restarts, + seed=args.seed + 10000 + br_counter, + species_clause_keys=species_clause_keys, + ) + br_counter += 1 + br_restart_traces.append(restart_runs) + return best + + strategies = build_strategy_pool( + seed_team, + best_response=_best_response_to, + max_size=args.max_size, + stop_on_cycle=True, + ) + oracle_iterations: list[dict] = [] + else: + br_counter = 0 + + def _best_response_to_mixture( + restricted_strategies: Sequence[Team], row_mixture: np.ndarray + ) -> Team: + nonlocal br_counter + objective = objective_vs_mixture( + pair_eval, restricted_strategies, row_mixture + ) + best, _, restart_runs = coordinate_ascent_multi_start( + objective, + primary_init=init_for_br, + pool_ids=pool_ids, + team_size=args.team_size, + random_restarts=args.br_random_restarts, + seed=args.seed + 20000 + br_counter, + species_clause_keys=species_clause_keys, + ) + br_counter += 1 + br_restart_traces.append(restart_runs) + return best + + strategies, oracle_iterations = build_strategy_pool_double_oracle( + seed_team, + pair_evaluator=pair_eval, + best_response_to_mixture=_best_response_to_mixture, + max_size=args.max_size, + stop_on_cycle=True, + exploitability_tol=args.exploitability_tol, + ) + + payoff = build_payoff_matrix(strategies, pair_eval) + antisym_err = payoff_antisymmetry_error(payoff) + eq = solve_zero_sum_equilibrium(payoff) + + row_mix = np.asarray(eq["row_mixture"], dtype=np.float64) + print("\nRestricted strategy pool:") + for idx, team in enumerate(strategies): + _print_team(f" t{idx}", team, species) + + if oracle_iterations: + print("\nDouble-oracle iterations:") + for row in oracle_iterations: + print( + f" iter={row['iteration']:>2} pool={row['pool_size']:>2} " + f"game_value={row['game_value']:+.6f} " + f"br_value={row['best_response_value']:+.6f} " + f"exploitability={row['exploitability']:+.6e}" + ) + + print(f"\nPayoff antisymmetry max error |A + A^T|_max = {antisym_err:.6e}") + + print("\nEquilibrium mixture (row player):") + support = [] + for idx, prob in enumerate(row_mix): + if prob > args.support_tol: + support.append((idx, float(prob))) + _print_team(f" w={prob:.6f} team t{idx}", strategies[idx], species) + + if not support: + print(" (No probabilities above support tolerance.)") + + print(f"Game value: {float(eq['game_value']):+.6f}") + + if args.out is not None: + payload = { + "strategies": [list(team) for team in strategies], + "strategy_species": [[species[i] for i in team] for team in strategies], + "payoff_matrix": payoff.tolist(), + "antisymmetry_error": float(antisym_err), + "equilibrium": { + "chosen_index": int(eq["chosen_index"]), + "row_mixture": row_mix.tolist(), + "col_mixture": np.asarray(eq["col_mixture"], dtype=np.float64).tolist(), + "game_value": float(eq["game_value"]), + }, + "pool_expansion": { + "mode": args.pool_expansion, + "br_random_restarts": int(args.br_random_restarts), + "exploitability_tol": float(args.exploitability_tol), + "double_oracle_iterations": oracle_iterations, + "br_restart_traces": br_restart_traces, + }, + } + args.out.parent.mkdir(parents=True, exist_ok=True) + args.out.write_text( + json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8" + ) + print(f"Saved equilibrium artifact -> {args.out}") + + +def cmd_checks(args: argparse.Namespace) -> None: + _, pokemon_sets, _, species_clause_keys = _load_pool(args.pool) + model = load_artifact(args.model) + _assert_model_pool_compat(model, pokemon_sets) + pair_eval = make_scorer(model) + + rng = random.Random(args.seed) + pool_ids = list(range(len(pokemon_sets))) + + sym_errors: list[float] = [] + for _ in range(args.num_pairs): + a = sample_team( + pool_ids, + team_size=args.team_size, + replace=False, + rng=rng, + species_clause_keys=species_clause_keys, + ) + b = sample_team( + pool_ids, + team_size=args.team_size, + replace=False, + rng=rng, + species_clause_keys=species_clause_keys, + ) + p_ab = float(pair_eval(a, b)) + p_ba = float(pair_eval(b, a)) + sym_errors.append(abs((p_ab + p_ba) - 1.0)) + + max_sym = max(sym_errors) if sym_errors else 0.0 + mean_sym = float(np.mean(sym_errors)) if sym_errors else 0.0 + + opp = sample_team( + pool_ids, + team_size=args.team_size, + replace=False, + rng=rng, + species_clause_keys=species_clause_keys, + ) + init = top_theta_init_team( + np.asarray(model["theta"]), + team_size=args.team_size, + species_clause_keys=species_clause_keys, + ) + objective = objective_vs_fixed_opponent(pair_eval, opp) + _, history = coordinate_ascent_best_team( + objective, + init_team=init, + pool_ids=pool_ids, + team_size=args.team_size, + species_clause_keys=species_clause_keys, + ) + + monotonic = True + prev = None + for row in history: + value = float(row["objective"]) + if prev is not None and value + 1e-12 < prev: + monotonic = False + break + prev = value + + strategies = [ + sample_team( + pool_ids, + team_size=args.team_size, + replace=False, + rng=rng, + species_clause_keys=species_clause_keys, + ) + for _ in range(args.payoff_pool_size) + ] + payoff = build_payoff_matrix(strategies, pair_eval) + antisym = payoff_antisymmetry_error(payoff) + + sim_rng_1 = random.Random(args.seed + 1000) + sim_rng_2 = random.Random(args.seed + 1000) + sampler_1 = make_uniform_matchup_sampler( + pool_ids, + team_size=args.team_size, + replace=False, + rng=sim_rng_1, + species_clause_keys=species_clause_keys, + ) + sampler_2 = make_uniform_matchup_sampler( + pool_ids, + team_size=args.team_size, + replace=False, + rng=sim_rng_2, + species_clause_keys=species_clause_keys, + ) + examples_1 = simulate_battles( + n=args.repro_n, + sampler=sampler_1, + agent_class="SimpleHeuristicsPlayer", + format_id=str(model.get("format_id") or "gen1ou"), + concurrency=1, + seed=args.seed, + backend="synthetic", + ) + examples_2 = simulate_battles( + n=args.repro_n, + sampler=sampler_2, + agent_class="SimpleHeuristicsPlayer", + format_id=str(model.get("format_id") or "gen1ou"), + concurrency=1, + seed=args.seed, + backend="synthetic", + ) + + reproducible = examples_1 == examples_2 + + print("Symmetry check:") + print(f" mean |p(A,B)+p(B,A)-1| = {mean_sym:.6e}") + print(f" max |p(A,B)+p(B,A)-1| = {max_sym:.6e}") + + print("Search monotonicity check:") + print(f" accepted swaps: {max(0, len(history) - 1)}") + print(f" monotonic objective: {monotonic}") + + print("Payoff antisymmetry check:") + print(f" max |A + A^T| = {antisym:.6e}") + + print("Reproducibility check (synthetic simulator):") + print(f" deterministic examples match: {reproducible}") + + +def cmd_run_all(args: argparse.Namespace) -> None: + if args.vs is not None and args.vs_team_file is not None: + raise ValueError("Provide at most one of --vs and --vs-team-file") + + if (args.num_batches is None) != (args.batch_size is None): + raise ValueError("Provide both --num-batches and --batch-size, or neither.") + if args.num_batches is not None and args.num_batches <= 0: + raise ValueError(f"--num-batches must be > 0, got {args.num_batches}") + if args.batch_size is not None and args.batch_size <= 0: + raise ValueError(f"--batch-size must be > 0, got {args.batch_size}") + + legacy_n = None + if args.num_batches is not None and args.batch_size is not None: + legacy_n = int(args.num_batches) * int(args.batch_size) + + n_uniform = int( + args.n_uniform if args.n_uniform is not None else (legacy_n or 5000) + ) + n_active = int(args.n_active if args.n_active is not None else (legacy_n or 5000)) + if n_uniform <= 0: + raise ValueError(f"--n-uniform must be > 0, got {n_uniform}") + if n_active < 0: + raise ValueError(f"--n-active must be >= 0, got {n_active}") + if args.eval_num_random_teams <= 0: + raise ValueError( + f"--eval-num-random-teams must be > 0, got {args.eval_num_random_teams}" + ) + used_active = n_active > 0 + + eval_battles_per_team = int( + args.eval_battles_per_team + if args.eval_battles_per_team is not None + else (args.batch_size or 16) + ) + if eval_battles_per_team <= 0: + raise ValueError( + f"--eval-battles-per-team must be > 0, got {eval_battles_per_team}" + ) + + eval_gpu_a = int( + args.eval_gpu_a if args.eval_gpu_a is not None else (args.gpu_a or 0) + ) + eval_gpu_b = int( + args.eval_gpu_b if args.eval_gpu_b is not None else (args.gpu_b or 1) + ) + sim_gpu_a = int(args.sim_gpu_a if args.sim_gpu_a is not None else (args.gpu_a or 0)) + sim_gpu_b = int(args.sim_gpu_b if args.sim_gpu_b is not None else (args.gpu_b or 1)) + eval_opponent_team_set = args.eval_opponent_team_set or args.opponent_team_set + eval_team_set = ( + args.eval_team_set or f"team_construction_pipeline_eval_{args.format}" + ) + eval_matchup_max_retries = int( + args.eval_matchup_max_retries + if args.eval_matchup_max_retries is not None + else args.max_retries + ) + eval_matchup_retry_sleep_sec = float( + args.eval_matchup_retry_sleep_sec + if args.eval_matchup_retry_sleep_sec is not None + else args.retry_sleep_sec + ) + + data_root = Path(args.data_root) + if args.reset_data_root and data_root.exists(): + shutil.rmtree(data_root) + data_root.mkdir(parents=True, exist_ok=True) + sim_work_dir = ( + args.sim_work_dir + if args.sim_work_dir is not None + else data_root / "team_construction_sim_battles" + ) + sim_work_dir.mkdir(parents=True, exist_ok=True) + + pool_path = data_root / "pool.json" + uniform_data_path = data_root / "battles_uniform.jsonl" + active_data_path = data_root / "battles_active.jsonl" + train_data_path = data_root / "battles_train.jsonl" + uniform_model_path = data_root / "interaction_uniform.pkl" + final_model_path = data_root / "interaction_final.pkl" + equilibrium_path = data_root / "equilibrium.json" + manifest_path = data_root / "run_all_manifest.json" + + phase_steps = ["build_pool", "simulate_uniform", "fit_uniform", "fit_final"] + if used_active: + phase_steps.insert(3, "simulate_active") + phase_steps.append("optimize_metagame") + if args.eval_enable: + phase_steps.append("kakuna_benchmark") + if args.vs is not None or args.vs_team_file is not None: + phase_steps.append("best_response") + if not args.skip_equilibrium: + phase_steps.append("equilibrium") + if not args.skip_checks: + phase_steps.append("checks") + phase_steps.append("write_manifest") + phase_bar = tqdm( + total=len(phase_steps), + desc="[run-all] phases", + unit="phase", + dynamic_ncols=True, + ) + + wandb_run = None + wandb_mod = None + stage_counter = 0 + if args.log_wandb: + try: + import wandb + except ImportError as exc: + raise ImportError( + "wandb is not installed. Install it or run without --log-wandb." + ) from exc + wandb_run = wandb.init( + project=args.wandb_project, + entity=args.wandb_entity, + name=args.wandb_run_name, + config={ + "pipeline": "run-all", + "format": args.format, + "usage_month": args.usage_month, + "usage_threshold": args.usage_threshold, + "rank": args.rank, + "backend": args.backend, + "agent": args.agent, + "sim_checkpoint": args.sim_checkpoint, + "sim_gpu_a": sim_gpu_a, + "sim_gpu_b": sim_gpu_b, + "sim_work_dir": str(sim_work_dir), + "sim_print_match_stats": bool(args.sim_print_match_stats), + "team_size": args.team_size, + "replace": bool(args.replace), + "seed": args.seed, + "concurrency": args.concurrency, + "timeout_sec": args.timeout_sec, + "max_retries": args.max_retries, + "retry_sleep_sec": args.retry_sleep_sec, + "flush_every": args.flush_every, + "n_uniform": n_uniform, + "n_active": n_active, + "val_fraction": args.val_fraction, + "max_iter": args.max_iter, + "tune_C": bool(args.tune_C), + "c_values": args.c_values, + "C": args.C, + "metagame_N": args.metagame_N, + "metagame_random_restarts": args.metagame_random_restarts, + "best_response_random_restarts": args.best_response_random_restarts, + "pool_expansion": args.pool_expansion, + "br_random_restarts": args.br_random_restarts, + "exploitability_tol": args.exploitability_tol, + "max_size": args.max_size, + "support_tol": args.support_tol, + "eval_enable": bool(args.eval_enable), + "eval_model_name": args.eval_model_name, + "eval_checkpoint": args.eval_checkpoint, + "eval_opponent_team_set": eval_opponent_team_set, + "eval_team_set": eval_team_set, + "eval_battles_per_team": eval_battles_per_team, + "eval_num_random_teams": args.eval_num_random_teams, + "eval_gpu_a": eval_gpu_a, + "eval_gpu_b": eval_gpu_b, + "eval_matchup_max_retries": eval_matchup_max_retries, + "eval_matchup_retry_sleep_sec": eval_matchup_retry_sleep_sec, + "eval_margin_tol": args.eval_margin_tol, + "fail_on_no_improvement": bool(args.fail_on_no_improvement), + "legacy_opponent_team_set": args.opponent_team_set, + "legacy_learner_team_set": args.learner_team_set, + "legacy_num_batches": args.num_batches, + "legacy_batch_size": args.batch_size, + "legacy_gpu_a": args.gpu_a, + "legacy_gpu_b": args.gpu_b, + "legacy_epsilon_start": args.epsilon_start, + "legacy_epsilon_end": args.epsilon_end, + "legacy_thompson_candidate_pool_size": args.thompson_candidate_pool_size, + "legacy_weight_team": args.weight_team, + "legacy_weight_pokemon": args.weight_pokemon, + "legacy_weight_moves": args.weight_moves, + }, + ) + wandb_mod = wandb + wandb.define_metric("run_all_stage") + wandb.define_metric("*", step_metric="run_all_stage") + + def _wandb_log(stage_name: str, **metrics: float | int | str | bool) -> None: + nonlocal stage_counter + if wandb_run is None: + return + stage_counter += 1 + payload = { + "run_all_stage": stage_counter, + "run_all_stage_name": stage_name, + } + payload.update(metrics) + wandb_run.log(payload) + + def _wandb_safe(callable_obj, *args, **kwargs) -> None: + if wandb_run is None or wandb_mod is None: + return + try: + callable_obj(*args, **kwargs) + except Exception as exc: + print( + f"[run-all] WARN: W&B rich logging failed ({type(exc).__name__}: {exc})" + ) + + def _phase_done(name: str) -> None: + phase_bar.update(1) + phase_bar.set_postfix_str(name) + + try: + print("[run-all] Stage: build pool") + cmd_build_pool( + argparse.Namespace( + format=args.format, + usage_month=args.usage_month, + usage_threshold=args.usage_threshold, + rank=args.rank, + replication_movesets_json=args.replication_movesets_json, + manual_sets_json=args.manual_sets_json, + strict_max_evs=args.strict_max_evs, + out=pool_path, + ) + ) + pool_data = load_pool_artifact(pool_path) + pokemon_sets = pool_pokemon_sets(pool_data) + species = [ps.species for ps in pokemon_sets] + pool_format = str(pool_data.get("format_id") or args.format) + species_clause_keys = build_species_clause_keys(pool_format, pokemon_sets) + _wandb_log("build_pool", pool_size=len(pokemon_sets)) + _wandb_safe( + _wandb_log_pool_snapshot, + wandb_run=wandb_run, + wandb_mod=wandb_mod, + pokemon_sets=pokemon_sets, + top_k=max(10, min(30, len(pokemon_sets))), + ) + _phase_done("build_pool") + + print("[run-all] Stage: simulate uniform dataset") + cmd_simulate( + argparse.Namespace( + pool=pool_path, + n=n_uniform, + seed=args.seed, + team_size=args.team_size, + replace=args.replace, + format=args.format, + agent=args.agent, + backend=args.backend, + sampling_strategy="uniform", + active_model=None, + active_candidate_pool_size=args.active_candidate_pool_size, + active_uniform_mix=args.active_uniform_mix, + active_min_uncertainty=args.active_min_uncertainty, + concurrency=args.concurrency, + timeout_sec=args.timeout_sec, + max_retries=args.max_retries, + retry_sleep_sec=args.retry_sleep_sec, + checkpoint=args.sim_checkpoint, + gpu_a=sim_gpu_a, + gpu_b=sim_gpu_b, + work_dir=sim_work_dir, + print_match_stats=args.sim_print_match_stats, + flush_every=args.flush_every, + out=uniform_data_path, + metadata_out=None, + ) + ) + uniform_rows = _count_jsonl_rows(uniform_data_path) + _wandb_log("simulate_uniform", uniform_rows=uniform_rows) + _phase_done("simulate_uniform") + + print("[run-all] Stage: fit interaction model on uniform dataset") + cmd_fit_interaction( + argparse.Namespace( + input=uniform_data_path, + pool=pool_path, + out=uniform_model_path, + val_fraction=args.val_fraction, + seed=args.seed, + max_iter=args.max_iter, + tune_C=args.tune_C, + c_values=args.c_values, + C=args.C, + detail_k=args.detail_k, + ) + ) + uniform_model = load_artifact(uniform_model_path) + uniform_fit = uniform_model.get("fit", {}) + _wandb_log( + "fit_uniform", + uniform_best_C=float(uniform_fit.get("best_C", 0.0)), + uniform_n_examples=int(uniform_fit.get("n_examples", 0)), + ) + _wandb_safe( + _wandb_log_model_snapshot, + wandb_run=wandb_run, + wandb_mod=wandb_mod, + stage_prefix="uniform_model", + model=uniform_model, + species=species, + top_k=max(10, min(30, len(species))), + ) + _phase_done("fit_uniform") + + if used_active: + print("[run-all] Stage: simulate active dataset") + cmd_simulate( + argparse.Namespace( + pool=pool_path, + n=n_active, + seed=args.seed + 1, + team_size=args.team_size, + replace=args.replace, + format=args.format, + agent=args.agent, + backend=args.backend, + sampling_strategy="active", + active_model=uniform_model_path, + active_candidate_pool_size=args.active_candidate_pool_size, + active_uniform_mix=args.active_uniform_mix, + active_min_uncertainty=args.active_min_uncertainty, + concurrency=args.concurrency, + timeout_sec=args.timeout_sec, + max_retries=args.max_retries, + retry_sleep_sec=args.retry_sleep_sec, + checkpoint=args.sim_checkpoint, + gpu_a=sim_gpu_a, + gpu_b=sim_gpu_b, + work_dir=sim_work_dir, + print_match_stats=args.sim_print_match_stats, + flush_every=args.flush_every, + out=active_data_path, + metadata_out=None, + ) + ) + + total_rows = _merge_jsonl_files( + [uniform_data_path, active_data_path], out=train_data_path + ) + print( + f"[run-all] Merged uniform+active datasets -> {train_data_path} " + f"(rows={total_rows})" + ) + _wandb_log( + "simulate_active", + active_rows=_count_jsonl_rows(active_data_path), + merged_rows=total_rows, + ) + _phase_done("simulate_active") + + print("[run-all] Stage: refit interaction model on merged dataset") + cmd_fit_interaction( + argparse.Namespace( + input=train_data_path, + pool=pool_path, + out=final_model_path, + val_fraction=args.val_fraction, + seed=args.seed + 1, + max_iter=args.max_iter, + tune_C=args.tune_C, + c_values=args.c_values, + C=args.C, + detail_k=args.detail_k, + ) + ) + else: + train_data_path = uniform_data_path + shutil.copyfile(uniform_model_path, final_model_path) + print( + "[run-all] Active stage disabled (n_active=0); " + f"reusing uniform model -> {final_model_path}" + ) + _wandb_log("simulate_active", active_rows=0, merged_rows=uniform_rows) + + final_model = load_artifact(final_model_path) + final_fit = final_model.get("fit", {}) + _wandb_log( + "fit_final", + final_best_C=float(final_fit.get("best_C", 0.0)), + final_n_examples=int(final_fit.get("n_examples", 0)), + ) + _wandb_safe( + _wandb_log_model_snapshot, + wandb_run=wandb_run, + wandb_mod=wandb_mod, + stage_prefix="final_model", + model=final_model, + species=species, + top_k=max(10, min(30, len(species))), + ) + _phase_done("fit_final") + + print("[run-all] Stage: metagame optimization") + best_team, avg_prob, history, _, restart_runs = _optimize_metagame_team( + model=final_model, + pokemon_sets=pokemon_sets, + team_size=args.team_size, + n_opponents=args.metagame_N, + seed=args.seed, + init_mode=args.init, + init_team_text=args.init_team, + random_restarts=args.metagame_random_restarts, + species_clause_keys=species_clause_keys, + ) + _print_team("Metagame-optimized team", best_team, species) + print(f"Predicted average win probability vs sampled metagame: {avg_prob:.6f}") + _print_restart_summary(restart_runs, species) + print("Accepted swaps (objective monotonicity trace):") + for row in history: + if row["event"] == "init": + print(f" init objective={row['objective']:.6f}") + else: + print( + f" slot {row['slot']} out={species[row['out']]} in={species[row['in']]} " + f"objective={row['objective']:.6f}" + ) + _wandb_log( + "optimize_metagame", + metagame_N=int(args.metagame_N), + metagame_avg_prob=float(avg_prob), + ) + _wandb_safe( + _wandb_log_metagame_outputs, + wandb_run=wandb_run, + wandb_mod=wandb_mod, + best_team=best_team, + avg_prob=avg_prob, + history=history, + restart_runs=restart_runs, + species=species, + ) + _phase_done("optimize_metagame") + + benchmark: dict | None = None + if args.eval_enable: + print("[run-all] Stage: Kakuna benchmark vs random-team baseline") + if args.backend == "synthetic": + reason = "backend is synthetic" + print(f"[run-all] Skipping Kakuna benchmark ({reason})") + benchmark = {"enabled": True, "ran": False, "skipped_reason": reason} + _wandb_log("kakuna_benchmark", skipped=True, skipped_reason=reason) + _phase_done("kakuna_benchmark") + else: + if not eval_opponent_team_set: + raise ValueError( + "--eval-opponent-team-set (or --opponent-team-set) is required " + "when --eval-enable is set." + ) + cache_dir_env = os.environ.get("METAMON_CACHE_DIR") + if not cache_dir_env: + raise ValueError( + "METAMON_CACHE_DIR must be set to run Kakuna benchmark evaluation." + ) + cache_dir = Path(cache_dir_env) + eval_work_dir = ( + args.eval_work_dir + if args.eval_work_dir is not None + else data_root / "team_construction_eval_battles" + ) + eval_work_dir.mkdir(parents=True, exist_ok=True) + eval_team_dir = _init_custom_teamset_dir( + cache_dir, eval_team_set, args.format + ) + + pipeline_showdown = team_ids_to_showdown(best_team, pokemon_sets) + _write_custom_team(eval_team_dir, args.format, pipeline_showdown) + pipeline_result = _run_matchup_with_retry( + battle_format=args.format, + num_battles=eval_battles_per_team, + model_name=args.eval_model_name, + team_set_a=eval_team_set, + team_set_b=eval_opponent_team_set, + gpu_a=eval_gpu_a, + gpu_b=eval_gpu_b, + work_dir=eval_work_dir, + checkpoint=args.eval_checkpoint, + max_retries=eval_matchup_max_retries, + retry_sleep_sec=eval_matchup_retry_sleep_sec, + print_match_stats=args.eval_print_match_stats, + ) + pipeline_wr = float(pipeline_result["acceptor_summary"]["win_rate"]) + + rng_eval = random.Random(args.seed + 98765) + random_wrs: list[float] = [] + random_rows: list[dict] = [] + pool_ids = list(range(len(pokemon_sets))) + random_eval_iter = tqdm( + range(args.eval_num_random_teams), + desc="[run-all] random baseline", + unit="team", + dynamic_ncols=True, + leave=False, + ) + for i in random_eval_iter: + team = sample_team( + pool_ids, + team_size=args.team_size, + replace=False, + rng=rng_eval, + species_clause_keys=species_clause_keys, + ) + _write_custom_team( + eval_team_dir, + args.format, + team_ids_to_showdown(team, pokemon_sets), + ) + result = _run_matchup_with_retry( + battle_format=args.format, + num_battles=eval_battles_per_team, + model_name=args.eval_model_name, + team_set_a=eval_team_set, + team_set_b=eval_opponent_team_set, + gpu_a=eval_gpu_a, + gpu_b=eval_gpu_b, + work_dir=eval_work_dir, + checkpoint=args.eval_checkpoint, + max_retries=eval_matchup_max_retries, + retry_sleep_sec=eval_matchup_retry_sleep_sec, + print_match_stats=False, + ) + wr = float(result["acceptor_summary"]["win_rate"]) + random_wrs.append(wr) + random_rows.append( + { + "team_ids": list(team), + "team_species": [species[idx] for idx in team], + "win_rate": wr, + } + ) + print( + f"[run-all] random baseline {i + 1}/{args.eval_num_random_teams} " + f"win_rate={wr:.3f}" + ) + + random_mean = float(np.mean(random_wrs)) + random_std = float(np.std(random_wrs, ddof=0)) + margin = float(pipeline_wr - random_mean) + outperform = bool(margin > float(args.eval_margin_tol)) + print( + "[run-all] benchmark summary: " + f"pipeline_wr={pipeline_wr:.3f} random_mean={random_mean:.3f} " + f"random_std={random_std:.3f} margin={margin:+.3f} " + f"outperform={outperform}" + ) + + benchmark = { + "enabled": True, + "ran": True, + "model_name": args.eval_model_name, + "checkpoint": args.eval_checkpoint, + "opponent_team_set": eval_opponent_team_set, + "team_set_a": eval_team_set, + "battles_per_team": eval_battles_per_team, + "num_random_teams": int(args.eval_num_random_teams), + "gpu_a": eval_gpu_a, + "gpu_b": eval_gpu_b, + "pipeline_team_ids": list(best_team), + "pipeline_team_species": [species[idx] for idx in best_team], + "pipeline_win_rate": pipeline_wr, + "random_mean_win_rate": random_mean, + "random_std_win_rate": random_std, + "margin_vs_random_mean": margin, + "margin_tol": float(args.eval_margin_tol), + "outperform_random": outperform, + "random_samples": random_rows, + } + _wandb_log( + "kakuna_benchmark", + pipeline_win_rate=pipeline_wr, + random_mean_win_rate=random_mean, + random_std_win_rate=random_std, + margin_vs_random_mean=margin, + margin_tol=float(args.eval_margin_tol), + outperform_random=outperform, + ) + _wandb_safe( + _wandb_log_benchmark_outputs, + wandb_run=wandb_run, + wandb_mod=wandb_mod, + benchmark=benchmark, + ) + _phase_done("kakuna_benchmark") + if args.fail_on_no_improvement and not outperform: + raise RuntimeError( + "Pipeline team did not beat random-team baseline on Kakuna benchmark. " + "Revisit model/search settings." + ) + else: + benchmark = {"enabled": False, "ran": False} + _wandb_log("kakuna_benchmark", skipped=True, skipped_reason="disabled") + + if args.vs is not None or args.vs_team_file is not None: + print("[run-all] Optional stage: best-response vs provided team") + cmd_best_response( + argparse.Namespace( + model=final_model_path, + pool=pool_path, + team_size=args.team_size, + vs=args.vs, + vs_team_file=args.vs_team_file, + init=args.init, + init_team=args.init_team, + seed=args.seed, + random_restarts=args.best_response_random_restarts, + ) + ) + _wandb_log("best_response", ran=True) + _phase_done("best_response") + + if not args.skip_equilibrium: + print("[run-all] Stage: equilibrium solve") + cmd_equilibrium( + argparse.Namespace( + model=final_model_path, + pool=pool_path, + team_size=args.team_size, + seed=args.seed, + seed_team_from=args.seed_team_from, + seed_team=args.seed_team, + pool_expansion=args.pool_expansion, + metagame_N=args.metagame_N, + metagame_random_restarts=args.metagame_random_restarts, + br_random_restarts=args.br_random_restarts, + exploitability_tol=args.exploitability_tol, + max_size=args.max_size, + support_tol=args.support_tol, + out=equilibrium_path, + ) + ) + if equilibrium_path.exists(): + eq_payload = json.loads(equilibrium_path.read_text(encoding="utf-8")) + row_mix = np.asarray( + eq_payload["equilibrium"]["row_mixture"], dtype=np.float64 + ) + support_size = int(np.sum(row_mix > float(args.support_tol))) + _wandb_safe( + _wandb_log_equilibrium_outputs, + wandb_run=wandb_run, + wandb_mod=wandb_mod, + eq_payload=eq_payload, + support_tol=float(args.support_tol), + ) + else: + support_size = 0 + _wandb_log("equilibrium", support_size=support_size) + _phase_done("equilibrium") + else: + print("[run-all] Skipping equilibrium stage (--skip-equilibrium)") + _wandb_log("equilibrium", skipped=True) + + if not args.skip_checks: + print("[run-all] Final checks") + cmd_checks( + argparse.Namespace( + model=final_model_path, + pool=pool_path, + team_size=args.team_size, + seed=args.seed, + num_pairs=args.check_num_pairs, + payoff_pool_size=args.check_payoff_pool_size, + repro_n=args.check_repro_n, + ) + ) + _wandb_log("checks", ran=True) + _phase_done("checks") + else: + print("[run-all] Skipping checks (--skip-checks)") + _wandb_log("checks", skipped=True) + + manifest = { + "format": args.format, + "usage_month": args.usage_month, + "backend": args.backend, + "agent": args.agent, + "seed": int(args.seed), + "team_size": int(args.team_size), + "n_uniform": int(n_uniform), + "n_active": int(n_active), + "used_active": bool(used_active), + "simulation": { + "checkpoint": args.sim_checkpoint, + "gpu_a": sim_gpu_a, + "gpu_b": sim_gpu_b, + "work_dir": str(sim_work_dir), + "print_match_stats": bool(args.sim_print_match_stats), + }, + "eval": { + "enabled": bool(args.eval_enable), + "model_name": args.eval_model_name, + "checkpoint": args.eval_checkpoint, + "opponent_team_set": eval_opponent_team_set, + "team_set_a": eval_team_set, + "battles_per_team": eval_battles_per_team, + "num_random_teams": int(args.eval_num_random_teams), + "gpu_a": eval_gpu_a, + "gpu_b": eval_gpu_b, + "matchup_max_retries": eval_matchup_max_retries, + "matchup_retry_sleep_sec": eval_matchup_retry_sleep_sec, + "margin_tol": float(args.eval_margin_tol), + "fail_on_no_improvement": bool(args.fail_on_no_improvement), + "result": benchmark, + }, + "legacy_args": { + "opponent_team_set": args.opponent_team_set, + "learner_team_set": args.learner_team_set, + "num_batches": args.num_batches, + "batch_size": args.batch_size, + "gpu_a": args.gpu_a, + "gpu_b": args.gpu_b, + "epsilon_start": args.epsilon_start, + "epsilon_end": args.epsilon_end, + "thompson_candidate_pool_size": args.thompson_candidate_pool_size, + "weight_team": args.weight_team, + "weight_pokemon": args.weight_pokemon, + "weight_moves": args.weight_moves, + }, + "paths": { + "pool": str(pool_path), + "uniform_dataset": str(uniform_data_path), + "active_dataset": str(active_data_path) if used_active else None, + "train_dataset": str(train_data_path), + "uniform_model": str(uniform_model_path), + "final_model": str(final_model_path), + "equilibrium": ( + str(equilibrium_path) if not args.skip_equilibrium else None + ), + }, + } + manifest_path.write_text( + json.dumps(manifest, indent=2, sort_keys=True), encoding="utf-8" + ) + _wandb_log("complete", manifest_path=str(manifest_path)) + _wandb_safe( + _wandb_log_run_artifact, + wandb_run=wandb_run, + wandb_mod=wandb_mod, + artifact_name=( + f"team-construction-{args.format}-{args.usage_month}-" + f"{int(time.time())}" + ), + file_paths=[ + pool_path, + uniform_data_path, + active_data_path if used_active else None, + train_data_path, + uniform_model_path, + final_model_path, + equilibrium_path if not args.skip_equilibrium else None, + manifest_path, + ], + ) + _phase_done("write_manifest") + print(f"[run-all] Done. Manifest -> {manifest_path}") + finally: + phase_bar.close() + if wandb_run is not None: + wandb_run.finish() + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description=( + "End-to-end team-construction pipeline: pool build, simulation, " + "logistic fitting, coordinate-ascent search, and restricted-game equilibrium." + ) + ) + sub = parser.add_subparsers(dest="command", required=True) + + p = sub.add_parser( + "build-pool", help="Build eligible Pokemon pool and standardized sets" + ) + p.add_argument("--format", required=True, help="Battle format, e.g. gen1ou") + p.add_argument( + "--usage-month", + required=True, + help="Usage month in YYYY-MM (paper replication uses 2025-07)", + ) + p.add_argument("--usage-threshold", type=float, default=0.001) + p.add_argument("--rank", type=int, default=1500) + p.add_argument("--replication-movesets-json", type=Path, default=None) + p.add_argument("--manual-sets-json", type=Path, default=None) + p.add_argument("--strict-max-evs", action="store_true") + p.add_argument("--out", type=Path, required=True) + p.set_defaults(func=cmd_build_pool) + + p = sub.add_parser("simulate", help="Generate battle dataset via simulation") + p.add_argument("--pool", type=Path, required=True) + p.add_argument("--n", type=int, required=True) + p.add_argument("--seed", type=int, default=0) + p.add_argument("--team-size", type=int, default=6) + p.add_argument("--replace", action="store_true") + p.add_argument("--format", default=None) + p.add_argument("--agent", default="SimpleHeuristicsPlayer") + p.add_argument( + "--backend", choices=["poke_env", "metamon", "synthetic"], default="poke_env" + ) + p.add_argument( + "--sampling-strategy", + choices=["uniform", "active"], + default="uniform", + help="uniform=random team pairs, active=model-guided uncertain pairs", + ) + p.add_argument( + "--active-model", + type=Path, + default=None, + help="Model artifact used for active sampling uncertainty scores", + ) + p.add_argument("--active-candidate-pool-size", type=int, default=256) + p.add_argument("--active-uniform-mix", type=float, default=0.25) + p.add_argument("--active-min-uncertainty", type=float, default=1e-6) + p.add_argument("--concurrency", type=int, default=1) + p.add_argument("--timeout-sec", type=float, default=240.0) + p.add_argument("--max-retries", type=int, default=2) + p.add_argument("--retry-sleep-sec", type=float, default=2.0) + p.add_argument("--checkpoint", type=int, default=None) + p.add_argument("--gpu-a", type=int, default=0) + p.add_argument("--gpu-b", type=int, default=1) + p.add_argument( + "--work-dir", + type=Path, + default=Path("/tmp/team_prediction/team_construction_battles"), + help="Used by backend=metamon (matchup worker scratch/results directory).", + ) + p.add_argument("--print-match-stats", action="store_true") + p.add_argument("--flush-every", type=int, default=50) + p.add_argument("--out", type=Path, required=True) + p.add_argument("--metadata-out", type=Path, default=None) + p.set_defaults(func=cmd_simulate) + + p = sub.add_parser("fit-baseline", help="Fit no-interaction logistic model") + p.add_argument("--input", type=Path, required=True) + p.add_argument("--pool", type=Path, required=True) + p.add_argument("--out", type=Path, required=True) + p.add_argument("--val-fraction", type=float, default=0.15) + p.add_argument("--seed", type=int, default=0) + p.add_argument("--max-iter", type=int, default=2000) + p.set_defaults(func=cmd_fit_baseline) + + p = sub.add_parser("fit-interaction", help="Fit interaction logistic model") + p.add_argument("--input", type=Path, required=True) + p.add_argument("--pool", type=Path, required=True) + p.add_argument("--out", type=Path, required=True) + p.add_argument("--val-fraction", type=float, default=0.15) + p.add_argument("--seed", type=int, default=0) + p.add_argument("--max-iter", type=int, default=3000) + p.add_argument("--tune-C", action="store_true") + p.add_argument("--c-values", default="0.01,0.1,1,10,100") + p.add_argument("--C", type=float, default=1.0) + p.add_argument("--detail-k", type=int, default=3) + p.set_defaults(func=cmd_fit_interaction) + + p = sub.add_parser( + "best-response", help="Find best response to a fixed opponent team" + ) + p.add_argument("--model", type=Path, required=True) + p.add_argument("--pool", type=Path, required=True) + p.add_argument("--team-size", type=int, default=6) + p.add_argument( + "--vs", type=str, default=None, help="Opponent team as names or team string" + ) + p.add_argument("--vs-team-file", type=Path, default=None) + p.add_argument( + "--init", choices=["top_theta", "random", "explicit"], default="top_theta" + ) + p.add_argument("--init-team", type=str, default=None) + p.add_argument("--seed", type=int, default=0) + p.add_argument("--random-restarts", type=int, default=8) + p.set_defaults(func=cmd_best_response) + + p = sub.add_parser( + "optimize-metagame", + help="Optimize average win probability against sampled metagame teams", + ) + p.add_argument("--model", type=Path, required=True) + p.add_argument("--pool", type=Path, required=True) + p.add_argument("--team-size", type=int, default=6) + p.add_argument("--N", type=int, default=100) + p.add_argument("--seed", type=int, default=0) + p.add_argument( + "--init", choices=["top_theta", "random", "explicit"], default="top_theta" + ) + p.add_argument("--init-team", type=str, default=None) + p.add_argument("--random-restarts", type=int, default=8) + p.set_defaults(func=cmd_optimize_metagame) + + p = sub.add_parser( + "equilibrium", + help="Expand restricted strategy pool via iterated BR and solve zero-sum equilibrium", + ) + p.add_argument("--model", type=Path, required=True) + p.add_argument("--pool", type=Path, required=True) + p.add_argument("--team-size", type=int, default=6) + p.add_argument("--seed", type=int, default=0) + p.add_argument( + "--seed-team-from", + choices=["metagame", "top_theta", "explicit"], + default="metagame", + ) + p.add_argument("--seed-team", type=str, default=None) + p.add_argument( + "--pool-expansion", + choices=["double_oracle", "last_response"], + default="double_oracle", + ) + p.add_argument("--metagame-N", type=int, default=100) + p.add_argument("--metagame-random-restarts", type=int, default=8) + p.add_argument("--br-random-restarts", type=int, default=8) + p.add_argument("--exploitability-tol", type=float, default=1e-6) + p.add_argument("--max-size", type=int, default=25) + p.add_argument("--support-tol", type=float, default=1e-6) + p.add_argument("--out", type=Path, default=None) + p.set_defaults(func=cmd_equilibrium) + + p = sub.add_parser( + "run-all", + help="Single-command end-to-end run: pool -> simulate -> fit -> optimize -> equilibrium -> checks", + ) + p.add_argument("--data-root", type=Path, required=True) + p.add_argument( + "--reset-data-root", + action=argparse.BooleanOptionalAction, + default=False, + help="Delete --data-root before running (use --no-reset-data-root to keep existing files).", + ) + p.add_argument( + "--format", + "--battle-format", + dest="format", + required=True, + help="Battle format, e.g. gen9ou", + ) + p.add_argument("--usage-month", required=True, help="Usage month in YYYY-MM") + p.add_argument("--usage-threshold", type=float, default=0.001) + p.add_argument("--rank", type=int, default=1500) + p.add_argument("--replication-movesets-json", type=Path, default=None) + p.add_argument("--manual-sets-json", type=Path, default=None) + p.add_argument("--strict-max-evs", action="store_true") + + p.add_argument("--opponent-team-set", default=None) + p.add_argument("--learner-team-set", default=None) + p.add_argument("--num-batches", type=int, default=None) + p.add_argument("--batch-size", type=int, default=None) + p.add_argument("--gpu-a", type=int, default=None) + p.add_argument("--gpu-b", type=int, default=None) + p.add_argument("--epsilon-start", type=float, default=None) + p.add_argument("--epsilon-end", type=float, default=None) + p.add_argument("--thompson-candidate-pool-size", type=int, default=None) + p.add_argument("--weight-team", type=float, default=None) + p.add_argument("--weight-pokemon", type=float, default=None) + p.add_argument("--weight-moves", type=float, default=None) + + p.add_argument( + "--backend", choices=["poke_env", "metamon", "synthetic"], default="poke_env" + ) + p.add_argument("--agent", default="Kakuna") + p.add_argument("--team-size", type=int, default=6) + p.add_argument("--replace", action="store_true") + p.add_argument("--seed", type=int, default=0) + p.add_argument("--concurrency", type=int, default=1) + p.add_argument("--timeout-sec", type=float, default=240.0) + p.add_argument( + "--max-retries", + "--matchup-max-retries", + dest="max_retries", + type=int, + default=2, + ) + p.add_argument( + "--retry-sleep-sec", + "--matchup-retry-sleep-sec", + dest="retry_sleep_sec", + type=float, + default=2.0, + ) + p.add_argument("--sim-checkpoint", type=int, default=None) + p.add_argument("--sim-gpu-a", type=int, default=None) + p.add_argument("--sim-gpu-b", type=int, default=None) + p.add_argument("--sim-work-dir", type=Path, default=None) + p.add_argument("--sim-print-match-stats", action="store_true") + p.add_argument("--flush-every", type=int, default=50) + + p.add_argument("--n-uniform", type=int, default=None) + p.add_argument("--n-active", type=int, default=None) + p.add_argument("--active-candidate-pool-size", type=int, default=256) + p.add_argument("--active-uniform-mix", type=float, default=0.25) + p.add_argument("--active-min-uncertainty", type=float, default=1e-6) + + p.add_argument("--val-fraction", type=float, default=0.15) + p.add_argument("--max-iter", type=int, default=3000) + p.add_argument( + "--tune-C", + action=argparse.BooleanOptionalAction, + default=True, + help="Tune interaction regularization C over --c-values.", + ) + p.add_argument("--c-values", default="0.01,0.1,1,10,100") + p.add_argument("--C", type=float, default=1.0) + p.add_argument("--detail-k", type=int, default=3) + + p.add_argument( + "--init", choices=["top_theta", "random", "explicit"], default="top_theta" + ) + p.add_argument("--init-team", type=str, default=None) + p.add_argument("--metagame-N", type=int, default=100) + p.add_argument("--metagame-random-restarts", type=int, default=16) + p.add_argument("--best-response-random-restarts", type=int, default=16) + + p.add_argument("--vs", type=str, default=None) + p.add_argument("--vs-team-file", type=Path, default=None) + + p.add_argument( + "--seed-team-from", + choices=["metagame", "top_theta", "explicit"], + default="metagame", + ) + p.add_argument("--seed-team", type=str, default=None) + p.add_argument( + "--pool-expansion", + choices=["double_oracle", "last_response"], + default="double_oracle", + ) + p.add_argument("--br-random-restarts", type=int, default=16) + p.add_argument("--exploitability-tol", type=float, default=1e-6) + p.add_argument("--max-size", type=int, default=25) + p.add_argument("--support-tol", type=float, default=1e-6) + p.add_argument("--skip-equilibrium", action="store_true") + + p.add_argument("--skip-checks", action="store_true") + p.add_argument("--check-num-pairs", type=int, default=64) + p.add_argument("--check-payoff-pool-size", type=int, default=8) + p.add_argument("--check-repro-n", type=int, default=32) + + p.add_argument( + "--eval-enable", + action=argparse.BooleanOptionalAction, + default=True, + help="Run Kakuna benchmark comparing pipeline team vs random-team baseline.", + ) + p.add_argument("--eval-model-name", default="Kakuna") + p.add_argument("--eval-checkpoint", type=int, default=None) + p.add_argument("--eval-opponent-team-set", default=None) + p.add_argument("--eval-team-set", default=None) + p.add_argument("--eval-battles-per-team", type=int, default=None) + p.add_argument("--eval-num-random-teams", type=int, default=8) + p.add_argument("--eval-gpu-a", type=int, default=None) + p.add_argument("--eval-gpu-b", type=int, default=None) + p.add_argument("--eval-work-dir", type=Path, default=None) + p.add_argument("--eval-matchup-max-retries", type=int, default=None) + p.add_argument("--eval-matchup-retry-sleep-sec", type=float, default=None) + p.add_argument("--eval-margin-tol", type=float, default=0.0) + p.add_argument("--eval-print-match-stats", action="store_true") + p.add_argument("--fail-on-no-improvement", action="store_true") + + p.add_argument("--log-wandb", action="store_true") + p.add_argument("--wandb-project", default="team_construction") + p.add_argument( + "--wandb-entity", + default=os.environ.get("METAMON_WANDB_ENTITY", None), + ) + p.add_argument("--wandb-run-name", default=None) + p.set_defaults(func=cmd_run_all) + + p = sub.add_parser("checks", help="Run minimal correctness checks") + p.add_argument("--model", type=Path, required=True) + p.add_argument("--pool", type=Path, required=True) + p.add_argument("--team-size", type=int, default=6) + p.add_argument("--seed", type=int, default=0) + p.add_argument("--num-pairs", type=int, default=64) + p.add_argument("--payoff-pool-size", type=int, default=8) + p.add_argument("--repro-n", type=int, default=32) + p.set_defaults(func=cmd_checks) + + return parser + + +def main(argv: Sequence[str] | None = None) -> None: + parser = build_parser() + args = parser.parse_args(list(argv) if argv is not None else None) + args.func(args) + + +if __name__ == "__main__": + main(sys.argv[1:]) diff --git a/metamon/backend/team_construction/coordinate_ascent.py b/metamon/backend/team_construction/coordinate_ascent.py new file mode 100644 index 0000000000..34985e985d --- /dev/null +++ b/metamon/backend/team_construction/coordinate_ascent.py @@ -0,0 +1,315 @@ +from __future__ import annotations + +import random +from typing import Callable, Mapping, Sequence + +from .core import Team, canonical_team +from .simulation import sample_team + +Evaluator = Callable[[Team], float] +PairEvaluator = Callable[[Team, Team], float] + + +def _team_is_species_clause_legal( + team: Sequence[int], + species_clause_keys: Mapping[int, object] | None, +) -> bool: + if species_clause_keys is None: + return True + keys = [species_clause_keys.get(int(member), int(member)) for member in team] + return len(set(keys)) == len(keys) + + +def top_theta_init_team( + theta: Sequence[float], + team_size: int = 6, + species_clause_keys: Mapping[int, object] | None = None, +) -> Team: + ranked = sorted(range(len(theta)), key=lambda idx: float(theta[idx]), reverse=True) + if len(ranked) < team_size: + raise ValueError(f"Need at least {team_size} Pokemon, got {len(ranked)}") + if species_clause_keys is None: + return canonical_team(ranked[:team_size], team_size=team_size) + selected: list[int] = [] + seen_keys: set[object] = set() + for idx in ranked: + key = species_clause_keys.get(int(idx), int(idx)) + if key in seen_keys: + continue + selected.append(int(idx)) + seen_keys.add(key) + if len(selected) == team_size: + break + if len(selected) < team_size: + raise ValueError( + "Need at least " + f"{team_size} unique species-clause groups, got {len(selected)}" + ) + return canonical_team(selected, team_size=team_size) + + +def coordinate_ascent_best_team( + evaluator: Evaluator, + init_team: Team, + pool_ids: Sequence[int], + *, + team_size: int = 6, + tol: float = 1e-12, + species_clause_keys: Mapping[int, object] | None = None, +) -> tuple[Team, list[dict]]: + """Coordinate-ascent local search as specified in the PDF workflow.""" + + pool = sorted({int(x) for x in pool_ids}) + if len(pool) < team_size: + raise ValueError(f"Pool has only {len(pool)} Pokemon, need {team_size}") + if species_clause_keys is not None: + unique_clause_groups = { + species_clause_keys.get(member, member) for member in pool + } + if len(unique_clause_groups) < team_size: + raise ValueError( + "Pool has only " + f"{len(unique_clause_groups)} unique species-clause groups, need {team_size}" + ) + pool_set = set(pool) + + team = list(canonical_team(init_team, team_size=team_size)) + if any(idx not in pool_set for idx in team): + raise ValueError( + "init_team contains Pokemon IDs not in pool_ids: " + + ", ".join(str(idx) for idx in team if idx not in pool_set) + ) + if not _team_is_species_clause_legal(team, species_clause_keys): + raise ValueError("init_team violates species clause.") + current = canonical_team(team, team_size=team_size) + current_obj = float(evaluator(current)) + + history = [ + { + "event": "init", + "team": list(current), + "objective": current_obj, + } + ] + + pos = 0 + while pos < team_size: + incumbent = team[pos] + occupied_keys: set[object] = set() + if species_clause_keys is not None: + occupied_keys = { + species_clause_keys.get(member, member) + for idx, member in enumerate(team) + if idx != pos + } + best_candidate = incumbent + best_team = current + best_obj = current_obj + + for candidate in pool: + if candidate == incumbent: + continue + if candidate in team: + continue + if ( + species_clause_keys is not None + and species_clause_keys.get(candidate, candidate) in occupied_keys + ): + continue + + trial = list(team) + trial[pos] = candidate + trial_team = canonical_team(trial, team_size=team_size) + trial_obj = float(evaluator(trial_team)) + + if trial_obj > best_obj + tol: + best_obj = trial_obj + best_candidate = candidate + best_team = trial_team + + if best_candidate != incumbent: + team[pos] = best_candidate + current = best_team + current_obj = best_obj + history.append( + { + "event": "swap", + "slot": pos, + "out": int(incumbent), + "in": int(best_candidate), + "objective": current_obj, + "team": list(current), + } + ) + pos = 0 + else: + pos += 1 + + return current, history + + +def coordinate_ascent_multi_start( + evaluator: Evaluator, + *, + primary_init: Team, + pool_ids: Sequence[int], + team_size: int = 6, + random_restarts: int = 0, + seed: int = 0, + tol: float = 1e-12, + species_clause_keys: Mapping[int, object] | None = None, +) -> tuple[Team, list[dict], list[dict]]: + """Run coordinate ascent from multiple starts and keep the best local optimum.""" + + if random_restarts < 0: + raise ValueError(f"random_restarts must be >= 0, got {random_restarts}") + + pool = sorted({int(x) for x in pool_ids}) + primary = canonical_team(primary_init, team_size=team_size) + + rng = random.Random(seed) + init_teams: list[Team] = [primary] + seen = {primary} + target_starts = 1 + random_restarts + attempts = 0 + max_attempts = max(1000, target_starts * 100) + while len(init_teams) < target_starts and attempts < max_attempts: + attempts += 1 + candidate = sample_team( + pool, + team_size=team_size, + replace=False, + rng=rng, + species_clause_keys=species_clause_keys, + ) + if candidate in seen: + continue + init_teams.append(candidate) + seen.add(candidate) + + best_team: Team | None = None + best_obj = float("-inf") + best_history: list[dict] = [] + best_restart_idx = 0 + run_summaries: list[dict] = [] + + for restart_idx, init in enumerate(init_teams): + final_team, history = coordinate_ascent_best_team( + evaluator, + init_team=init, + pool_ids=pool, + team_size=team_size, + tol=tol, + species_clause_keys=species_clause_keys, + ) + final_obj = float(history[-1]["objective"]) + accepted_swaps = max(0, len(history) - 1) + run_summaries.append( + { + "restart_index": restart_idx, + "start_team": list(init), + "final_team": list(final_team), + "objective": final_obj, + "accepted_swaps": accepted_swaps, + } + ) + + better = final_obj > best_obj + tol + tied = abs(final_obj - best_obj) <= tol and ( + best_team is None or tuple(final_team) < tuple(best_team) + ) + if better or tied: + best_team = final_team + best_obj = final_obj + best_history = history + best_restart_idx = restart_idx + + if best_team is None: + raise RuntimeError( + "coordinate_ascent_multi_start failed to produce any candidate" + ) + + for row in run_summaries: + row["selected"] = row["restart_index"] == best_restart_idx + + return best_team, best_history, run_summaries + + +def objective_vs_fixed_opponent( + pair_evaluator: PairEvaluator, + opponent_team: Team, +) -> Evaluator: + def _objective(team: Team) -> float: + return float(pair_evaluator(team, opponent_team)) + + return _objective + + +def objective_vs_metagame( + pair_evaluator: PairEvaluator, + opponent_teams: Sequence[Team], +) -> Evaluator: + if not opponent_teams: + raise ValueError("opponent_teams cannot be empty") + + def _objective(team: Team) -> float: + score = 0.0 + for opp in opponent_teams: + score += float(pair_evaluator(team, opp)) + return score / len(opponent_teams) + + return _objective + + +def objective_vs_mixture( + pair_evaluator: PairEvaluator, + opponent_teams: Sequence[Team], + opponent_weights: Sequence[float], +) -> Evaluator: + if not opponent_teams: + raise ValueError("opponent_teams cannot be empty") + if len(opponent_teams) != len(opponent_weights): + raise ValueError( + f"opponent_teams/opponent_weights length mismatch: " + f"{len(opponent_teams)} vs {len(opponent_weights)}" + ) + + weights = [float(w) for w in opponent_weights] + tol = 1e-12 + if any(w < -tol for w in weights): + raise ValueError("opponent_weights must be nonnegative") + weights = [max(0.0, w) for w in weights] + total = sum(weights) + if total <= tol: + raise ValueError("sum(opponent_weights) must be > 0") + normalized = [w / total for w in weights] + + def _objective(team: Team) -> float: + score = 0.0 + for weight, opp in zip(normalized, opponent_teams): + score += weight * float(pair_evaluator(team, opp)) + return score + + return _objective + + +def sample_opponent_teams( + pool_ids: Sequence[int], + *, + n: int, + team_size: int, + seed: int, + replace: bool = False, + species_clause_keys: Mapping[int, object] | None = None, +) -> list[Team]: + rng = random.Random(seed) + return [ + sample_team( + pool_ids, + team_size=team_size, + replace=replace, + rng=rng, + species_clause_keys=species_clause_keys, + ) + for _ in range(n) + ] diff --git a/metamon/backend/team_construction/core.py b/metamon/backend/team_construction/core.py new file mode 100644 index 0000000000..6a47883b20 --- /dev/null +++ b/metamon/backend/team_construction/core.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +from dataclasses import dataclass +import math +from typing import Iterable, Sequence + +Team = tuple[int, ...] + + +@dataclass(frozen=True) +class PokemonSet: + """One canonical set used to represent a Pokemon species in team search/simulation.""" + + species: str + moves: tuple[str, ...] + showdown_set: str + usage: float = 0.0 + ability: str | None = None + + +@dataclass(frozen=True) +class BattleExample: + """One supervised training example for the team-vs-team win model.""" + + team_A: Team + team_B: Team + y: int + + def swapped(self) -> "BattleExample": + return BattleExample(team_A=self.team_B, team_B=self.team_A, y=1 - int(self.y)) + + +def canonical_team(team: Iterable[int], team_size: int | None = None) -> Team: + """Canonical team representation: sorted tuple of unique Pokemon IDs.""" + + values = tuple(int(x) for x in team) + if len(values) != len(set(values)): + raise ValueError(f"Team contains duplicate Pokemon IDs: {values}") + if team_size is not None and len(values) != team_size: + raise ValueError(f"Expected team size {team_size}, got {len(values)}") + return tuple(sorted(values)) + + +def parse_int_team(raw: Sequence[int] | str, team_size: int | None = None) -> Team: + """Parse comma/space-separated team IDs or pass-through existing integer sequences.""" + + if isinstance(raw, str): + parts = [p.strip() for p in raw.replace(",", " ").split() if p.strip()] + values = [int(p) for p in parts] + else: + values = [int(x) for x in raw] + return canonical_team(values, team_size=team_size) + + +def battle_example_to_json_dict(example: BattleExample) -> dict: + return { + "team_A": list(example.team_A), + "team_B": list(example.team_B), + "y": int(example.y), + } + + +def battle_example_from_json_dict(data: dict) -> BattleExample: + team_a = canonical_team(data["team_A"]) + team_b = canonical_team(data["team_B"]) + y = int(data["y"]) + if y not in (0, 1): + raise ValueError(f"Label y must be 0/1, got {y}") + return BattleExample(team_A=team_a, team_B=team_b, y=y) + + +def sigmoid(x: float) -> float: + if x >= 0: + z = math.exp(-x) + return 1.0 / (1.0 + z) + z = math.exp(x) + return z / (1.0 + z) diff --git a/metamon/backend/team_construction/feature_baseline.py b/metamon/backend/team_construction/feature_baseline.py new file mode 100644 index 0000000000..d138e948cd --- /dev/null +++ b/metamon/backend/team_construction/feature_baseline.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +import numpy as np + +from .core import BattleExample, Team +from .feature_sparse import feature_dicts_to_csr + + +def build_baseline_feature_dict( + team_a: Team, + team_b: Team, + num_pokemon: int, +) -> dict[int, float]: + out: dict[int, float] = {} + for idx in team_a: + if idx < 0 or idx >= num_pokemon: + raise IndexError(f"Pokemon ID out of range: {idx}") + out[idx] = out.get(idx, 0.0) + 1.0 + for idx in team_b: + if idx < 0 or idx >= num_pokemon: + raise IndexError(f"Pokemon ID out of range: {idx}") + out[idx] = out.get(idx, 0.0) - 1.0 + return out + + +def build_baseline_matrix(examples: list[BattleExample], num_pokemon: int): + rows = [ + build_baseline_feature_dict(ex.team_A, ex.team_B, num_pokemon) + for ex in examples + ] + x = feature_dicts_to_csr(rows, n_features=num_pokemon) + y = np.array([ex.y for ex in examples], dtype=np.int64) + return x, y diff --git a/metamon/backend/team_construction/feature_interaction.py b/metamon/backend/team_construction/feature_interaction.py new file mode 100644 index 0000000000..9359a42a95 --- /dev/null +++ b/metamon/backend/team_construction/feature_interaction.py @@ -0,0 +1,152 @@ +from __future__ import annotations + +from dataclasses import dataclass + +import numpy as np + +from .core import BattleExample, Team +from .feature_sparse import feature_dicts_to_csr + + +@dataclass(frozen=True) +class InteractionFeatureLayout: + num_pokemon: int + + @property + def main_offset(self) -> int: + return 0 + + @property + def num_main(self) -> int: + return self.num_pokemon + + @property + def synergy_offset(self) -> int: + return self.main_offset + self.num_main + + @property + def num_synergy(self) -> int: + return self.num_pokemon * (self.num_pokemon - 1) // 2 + + @property + def matchup_offset(self) -> int: + return self.synergy_offset + self.num_synergy + + @property + def num_matchup(self) -> int: + return self.num_pokemon * (self.num_pokemon - 1) + + @property + def n_features(self) -> int: + return self.matchup_offset + self.num_matchup + + def synergy_index(self, i: int, k: int) -> int: + if i == k: + raise ValueError("Synergy index undefined for identical IDs") + if i > k: + i, k = k, i + start = i * (2 * self.num_pokemon - i - 1) // 2 + return self.synergy_offset + start + (k - i - 1) + + def matchup_index(self, i: int, j: int) -> int: + if i == j: + raise ValueError("Matchup index undefined for identical IDs") + j_compact = j if j < i else j - 1 + return self.matchup_offset + i * (self.num_pokemon - 1) + j_compact + + +def _add_synergy_terms( + out: dict[int, float], + team: Team, + sign: float, + layout: InteractionFeatureLayout, +) -> None: + for i in range(len(team)): + for j in range(i + 1, len(team)): + idx = layout.synergy_index(team[i], team[j]) + out[idx] = out.get(idx, 0.0) + sign + + +def _add_matchup_terms( + out: dict[int, float], + team_a: Team, + team_b: Team, + layout: InteractionFeatureLayout, +) -> None: + for i in team_a: + for j in team_b: + if i == j: + continue + idx = layout.matchup_index(i, j) + out[idx] = out.get(idx, 0.0) + 1.0 + + +def build_interaction_feature_dict( + team_a: Team, + team_b: Team, + layout: InteractionFeatureLayout, +) -> dict[int, float]: + out: dict[int, float] = {} + + for idx in team_a: + out[idx] = out.get(idx, 0.0) + 1.0 + for idx in team_b: + out[idx] = out.get(idx, 0.0) - 1.0 + + _add_synergy_terms(out, team_a, +1.0, layout) + _add_synergy_terms(out, team_b, -1.0, layout) + + _add_matchup_terms(out, team_a, team_b, layout) + + return out + + +def build_interaction_matrix( + examples: list[BattleExample], layout: InteractionFeatureLayout +): + rows = [ + build_interaction_feature_dict(ex.team_A, ex.team_B, layout) for ex in examples + ] + x = feature_dicts_to_csr(rows, n_features=layout.n_features) + y = np.array([ex.y for ex in examples], dtype=np.int64) + return x, y + + +def synergy_vector_to_matrix( + layout: InteractionFeatureLayout, alpha: np.ndarray +) -> np.ndarray: + matrix = np.zeros((layout.num_pokemon, layout.num_pokemon), dtype=np.float64) + ptr = 0 + for i in range(layout.num_pokemon): + for k in range(i + 1, layout.num_pokemon): + value = float(alpha[ptr]) + matrix[i, k] = value + matrix[k, i] = value + ptr += 1 + return matrix + + +def matchup_vector_to_matrix( + layout: InteractionFeatureLayout, beta: np.ndarray +) -> np.ndarray: + matrix = np.zeros((layout.num_pokemon, layout.num_pokemon), dtype=np.float64) + ptr = 0 + for i in range(layout.num_pokemon): + for j in range(layout.num_pokemon): + if i == j: + continue + matrix[i, j] = float(beta[ptr]) + ptr += 1 + return matrix + + +def matchup_matrix_to_vector( + layout: InteractionFeatureLayout, matrix: np.ndarray +) -> np.ndarray: + values: list[float] = [] + for i in range(layout.num_pokemon): + for j in range(layout.num_pokemon): + if i == j: + continue + values.append(float(matrix[i, j])) + return np.asarray(values, dtype=np.float64) diff --git a/metamon/backend/team_construction/feature_sparse.py b/metamon/backend/team_construction/feature_sparse.py new file mode 100644 index 0000000000..0a935f1dec --- /dev/null +++ b/metamon/backend/team_construction/feature_sparse.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from typing import Sequence + + +def feature_dicts_to_csr(feature_dicts: Sequence[dict[int, float]], n_features: int): + """Convert sparse dict rows into a scipy CSR matrix.""" + + try: + from scipy import sparse + except ImportError as exc: + raise ImportError( + "scipy is required for sparse feature matrices. Install scipy to continue." + ) from exc + + rows: list[int] = [] + cols: list[int] = [] + data: list[float] = [] + for row_idx, feat_dict in enumerate(feature_dicts): + for col_idx, value in feat_dict.items(): + if value == 0: + continue + rows.append(row_idx) + cols.append(int(col_idx)) + data.append(float(value)) + + return sparse.csr_matrix( + (data, (rows, cols)), shape=(len(feature_dicts), n_features) + ) diff --git a/metamon/backend/team_construction/matchup.py b/metamon/backend/team_construction/matchup.py new file mode 100644 index 0000000000..2310df5ac2 --- /dev/null +++ b/metamon/backend/team_construction/matchup.py @@ -0,0 +1,297 @@ +import argparse +import csv +import os +import re +import subprocess +import sys +import time +import uuid +from pathlib import Path +from typing import Dict, List + + +def _run_serve_matchup( + *, + model_name: str, + username: str, + opponent_username: str, + role: str, + battle_format: str, + n_battles: int, + team_set: str, + gpu_id: int, + save_results_to: Path, + checkpoint: int | None = None, +) -> subprocess.Popen: + cmd = [ + sys.executable, + "-m", + "metamon.rl.evaluate.serve_matchup", + "--model_name", + model_name, + "--username", + username, + "--opponent_username", + opponent_username, + "--role", + role, + "--format", + battle_format, + "--n_battles", + str(n_battles), + "--team_set", + team_set, + "--battle_backend", + "metamon", + "--temperature", + "1.0", + "--save_results_to", + str(save_results_to), + ] + if checkpoint is not None: + cmd += ["--checkpoint", str(checkpoint)] + + env = os.environ.copy() + env["CUDA_VISIBLE_DEVICES"] = str(gpu_id) + return subprocess.Popen(cmd, env=env) + + +def _norm_key(key: str) -> str: + return re.sub(r"[^a-z0-9]", "", key.lower()) + + +def _row_get(row: dict, key: str) -> str: + if key in row and row[key] is not None: + return str(row[key]) + wanted = _norm_key(key) + for k, v in row.items(): + if k is None: + continue + if _norm_key(str(k)) == wanted and v is not None: + return str(v) + return "" + + +def _canonical_result(raw: str) -> str: + value = raw.strip().upper() + if value in {"WIN", "WON", "1"}: + return "WIN" + if value in {"DRAW", "TIE", "0.5"}: + return "DRAW" + if value in {"LOSS", "LOSE", "LOST", "0"}: + return "LOSS" + return "UNKNOWN" + + +def _as_int(value: str, default: int = 0) -> int: + try: + return int(str(value).strip()) + except Exception: + return default + + +def _read_player_rows(results_dir: Path, player_username: str) -> List[dict]: + rows: List[dict] = [] + for csv_file in sorted(results_dir.rglob("*.csv")): + with csv_file.open("r", newline="", encoding="utf-8") as f: + reader = csv.DictReader(f) + for row in reader: + player = _row_get(row, "Player Username").strip() + if player == player_username: + rows.append(row) + return rows + + +def _summarize(rows: List[dict]) -> Dict[str, float]: + wins = 0 + draws = 0 + losses = 0 + unknown = 0 + for row in rows: + result = _canonical_result(_row_get(row, "Result")) + if result == "WIN": + wins += 1 + elif result == "DRAW": + draws += 1 + elif result == "LOSS": + losses += 1 + else: + unknown += 1 + scored = wins + draws + losses + win_rate = (wins + 0.5 * draws) / scored if scored else 0.0 + return { + "wins": wins, + "draws": draws, + "losses": losses, + "unknown": unknown, + "win_rate": win_rate, + "logged_battles": scored, + } + + +def _match_records(rows: List[dict]) -> List[dict]: + out: List[dict] = [] + for i, row in enumerate(rows, start=1): + out.append( + { + "match_idx": i, + "battle_id": _row_get(row, "Battle ID").strip(), + "result": _canonical_result(_row_get(row, "Result")), + "turn_count": _as_int(_row_get(row, "Turn Count"), default=0), + "team_file_path": _row_get(row, "Team File").strip(), + "team_file": Path(_row_get(row, "Team File").strip()).name, + } + ) + return out + + +def run_matchup( + *, + battle_format: str, + num_battles: int, + model_name: str, + team_set_a: str, + team_set_b: str, + gpu_a: int, + gpu_b: int, + work_dir: Path, + checkpoint: int | None = None, + print_match_stats: bool = False, +) -> Dict[str, object]: + run_id = uuid.uuid4().hex[:10] + acceptor_username = f"tca{run_id}" + challenger_username = f"tcb{run_id}" + acceptor_results = work_dir / f"results_acceptor_{run_id}" + challenger_results = work_dir / f"results_challenger_{run_id}" + acceptor_results.mkdir(parents=True, exist_ok=True) + challenger_results.mkdir(parents=True, exist_ok=True) + + print( + f"[matchup] format={battle_format} n={num_battles} model={model_name} " + f"team_set_a={team_set_a} team_set_b={team_set_b}" + ) + print( + f"[matchup] acceptor={acceptor_username} gpu={gpu_a} " + f"challenger={challenger_username} gpu={gpu_b}" + ) + start = time.time() + acceptor_proc = _run_serve_matchup( + model_name=model_name, + username=acceptor_username, + opponent_username=challenger_username, + role="acceptor", + battle_format=battle_format, + n_battles=num_battles, + team_set=team_set_a, + gpu_id=gpu_a, + save_results_to=acceptor_results, + checkpoint=checkpoint, + ) + time.sleep(2.0) + challenger_proc = _run_serve_matchup( + model_name=model_name, + username=challenger_username, + opponent_username=acceptor_username, + role="challenger", + battle_format=battle_format, + n_battles=num_battles, + team_set=team_set_b, + gpu_id=gpu_b, + save_results_to=challenger_results, + checkpoint=checkpoint, + ) + + rc_challenger = challenger_proc.wait() + rc_acceptor = acceptor_proc.wait() + elapsed = time.time() - start + if rc_challenger != 0 or rc_acceptor != 0: + raise RuntimeError( + f"Battle workers failed (acceptor_rc={rc_acceptor}, challenger_rc={rc_challenger})." + ) + + acceptor_rows = _read_player_rows(acceptor_results, acceptor_username) + challenger_rows = _read_player_rows(challenger_results, challenger_username) + if not acceptor_rows: + raise RuntimeError(f"No result rows found for {acceptor_username}") + if not challenger_rows: + raise RuntimeError(f"No result rows found for {challenger_username}") + + acceptor_summary = _summarize(acceptor_rows) + challenger_summary = _summarize(challenger_rows) + acceptor_matches = _match_records(acceptor_rows) + challenger_matches = _match_records(challenger_rows) + + print("[matchup] done") + print( + f"[acceptor] W/D/L/U = {acceptor_summary['wins']}/{acceptor_summary['draws']}/" + f"{acceptor_summary['losses']}/{acceptor_summary['unknown']} " + f"win_rate={acceptor_summary['win_rate']:.3f}" + ) + print( + f"[challenger] W/D/L/U = {challenger_summary['wins']}/{challenger_summary['draws']}/" + f"{challenger_summary['losses']}/{challenger_summary['unknown']} " + f"win_rate={challenger_summary['win_rate']:.3f}" + ) + print(f"[matchup] elapsed={elapsed:.1f}s") + + if print_match_stats: + running = 0.0 + for m in acceptor_matches: + if m["result"] == "WIN": + running += 1.0 + elif m["result"] == "DRAW": + running += 0.5 + print( + f"[acceptor match {m['match_idx']:02d}] id={m['battle_id']} " + f"result={m['result']} turns={m['turn_count']} " + f"team={m['team_file']} running_wr={running / m['match_idx']:.3f}" + ) + + return { + "run_id": run_id, + "elapsed_sec": elapsed, + "acceptor_results_dir": str(acceptor_results), + "challenger_results_dir": str(challenger_results), + "acceptor_summary": acceptor_summary, + "challenger_summary": challenger_summary, + "acceptor_matches": acceptor_matches, + "challenger_matches": challenger_matches, + } + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Run one standalone Kakuna-vs-Kakuna matchup batch." + ) + parser.add_argument("--battle-format", default="gen1ou") + parser.add_argument("--num-battles", type=int, default=16) + parser.add_argument("--model-name", default="Kakuna") + parser.add_argument("--team-set-a", default="competitive") + parser.add_argument("--team-set-b", default="competitive") + parser.add_argument("--gpu-a", type=int, default=0) + parser.add_argument("--gpu-b", type=int, default=1) + parser.add_argument("--checkpoint", type=int, default=None) + parser.add_argument( + "--work-dir", + type=Path, + default=Path("/tmp/team_prediction/team_construction_battles"), + ) + parser.add_argument("--print-match-stats", action="store_true") + args = parser.parse_args() + + run_matchup( + battle_format=args.battle_format, + num_battles=args.num_battles, + model_name=args.model_name, + team_set_a=args.team_set_a, + team_set_b=args.team_set_b, + gpu_a=args.gpu_a, + gpu_b=args.gpu_b, + work_dir=args.work_dir, + checkpoint=args.checkpoint, + print_match_stats=args.print_match_stats, + ) + + +if __name__ == "__main__": + main() diff --git a/metamon/backend/team_construction/model_fit.py b/metamon/backend/team_construction/model_fit.py new file mode 100644 index 0000000000..4d9ff36a89 --- /dev/null +++ b/metamon/backend/team_construction/model_fit.py @@ -0,0 +1,266 @@ +from __future__ import annotations + +from typing import Iterable, Sequence + +import numpy as np + +from .core import BattleExample +from .feature_baseline import build_baseline_matrix +from .feature_interaction import ( + InteractionFeatureLayout, + build_interaction_matrix, + matchup_matrix_to_vector, + matchup_vector_to_matrix, +) +from .simulation import augment_swap_symmetry, split_before_augmentation + + +def _require_sklearn_logreg(): + try: + from sklearn.linear_model import LogisticRegression + except ImportError as exc: + raise ImportError( + "scikit-learn is required for model fitting. Install scikit-learn to continue." + ) from exc + return LogisticRegression + + +def _binary_metrics(y_true: np.ndarray, probs: np.ndarray) -> dict[str, float]: + probs = np.clip(probs.astype(np.float64), 1e-9, 1.0 - 1e-9) + y = y_true.astype(np.float64) + log_loss = float(-np.mean(y * np.log(probs) + (1.0 - y) * np.log(1.0 - probs))) + preds = (probs >= 0.5).astype(np.int64) + accuracy = float(np.mean(preds == y_true)) + brier = float(np.mean((probs - y) ** 2)) + return { + "log_loss": log_loss, + "accuracy": accuracy, + "brier": brier, + } + + +def _require_two_classes(y: np.ndarray, *, context: str) -> None: + labels = np.unique(y) + if labels.size < 2: + raise ValueError( + f"{context} requires both classes 0/1, but observed labels={labels.tolist()}" + ) + + +def _center_theta(theta: np.ndarray) -> np.ndarray: + if theta.size == 0: + return theta + return theta - float(np.mean(theta)) + + +def _center_alpha(alpha: np.ndarray) -> np.ndarray: + if alpha.size == 0: + return alpha + return alpha - float(np.mean(alpha)) + + +def _center_and_antisymmetrize_beta( + layout: InteractionFeatureLayout, + beta: np.ndarray, +) -> np.ndarray: + matrix = matchup_vector_to_matrix(layout, beta) + mask = ~np.eye(layout.num_pokemon, dtype=bool) + offdiag = matrix[mask] + if offdiag.size > 0: + matrix[mask] = offdiag - float(np.mean(offdiag)) + + matrix = 0.5 * (matrix - matrix.T) + np.fill_diagonal(matrix, 0.0) + return matchup_matrix_to_vector(layout, matrix) + + +def fit_baseline_model( + examples: Sequence[BattleExample], + *, + num_pokemon: int, + val_fraction: float = 0.15, + split_seed: int = 0, + max_iter: int = 2000, +) -> dict: + if not examples: + raise ValueError("Need at least one training example to fit baseline model.") + LogisticRegression = _require_sklearn_logreg() + + train_orig, val_orig = split_before_augmentation( + examples, val_fraction=val_fraction, seed=split_seed + ) + if not train_orig: + raise ValueError( + "Training split is empty. Lower --val-fraction or provide more examples." + ) + train_aug = augment_swap_symmetry(train_orig) + val_aug = augment_swap_symmetry(val_orig) + + x_train, y_train = build_baseline_matrix(train_aug, num_pokemon=num_pokemon) + _require_two_classes(y_train, context="Baseline training") + model = LogisticRegression(max_iter=max_iter) + model.fit(x_train, y_train) + + val_metrics = None + if val_aug: + x_val, y_val = build_baseline_matrix(val_aug, num_pokemon=num_pokemon) + val_probs = model.predict_proba(x_val)[:, 1] + val_metrics = _binary_metrics(y_val, val_probs) + + full_aug = augment_swap_symmetry(list(examples)) + x_full, y_full = build_baseline_matrix(full_aug, num_pokemon=num_pokemon) + _require_two_classes(y_full, context="Baseline refit") + final_model = LogisticRegression(max_iter=max_iter) + final_model.fit(x_full, y_full) + + theta = final_model.coef_[0].astype(np.float64) + theta = _center_theta(theta) + intercept = float(final_model.intercept_[0]) + + return { + "model_type": "baseline", + "num_pokemon": int(num_pokemon), + "theta": theta, + "intercept": intercept, + "fit": { + "val_fraction": float(val_fraction), + "split_seed": int(split_seed), + "max_iter": int(max_iter), + "n_examples": int(len(examples)), + "n_train_original": int(len(train_orig)), + "n_val_original": int(len(val_orig)), + "n_train_augmented": int(len(train_aug)), + "n_val_augmented": int(len(val_aug)), + "val_metrics": val_metrics, + }, + } + + +def fit_interaction_model( + examples: Sequence[BattleExample], + *, + num_pokemon: int, + c_values: Iterable[float] = (0.01, 0.1, 1.0, 10.0, 100.0), + val_fraction: float = 0.15, + split_seed: int = 0, + max_iter: int = 3000, +) -> dict: + if not examples: + raise ValueError("Need at least one training example to fit interaction model.") + LogisticRegression = _require_sklearn_logreg() + + layout = InteractionFeatureLayout(num_pokemon=num_pokemon) + c_grid = [float(c) for c in c_values] + if not c_grid: + raise ValueError("c_values must contain at least one candidate") + + train_orig, val_orig = split_before_augmentation( + examples, val_fraction=val_fraction, seed=split_seed + ) + if not train_orig: + raise ValueError( + "Training split is empty. Lower --val-fraction or provide more examples." + ) + train_aug = augment_swap_symmetry(train_orig) + val_aug = augment_swap_symmetry(val_orig) + + x_train, y_train = build_interaction_matrix(train_aug, layout=layout) + _require_two_classes(y_train, context="Interaction training") + x_val = y_val = None + if val_aug: + x_val, y_val = build_interaction_matrix(val_aug, layout=layout) + + tuning_rows: list[dict] = [] + best_c = c_grid[0] + best_loss = float("inf") + + for c in c_grid: + model = LogisticRegression( + penalty="l2", + C=c, + max_iter=max_iter, + fit_intercept=False, + solver="lbfgs", + ) + model.fit(x_train, y_train) + + if x_val is not None and y_val is not None: + val_probs = model.predict_proba(x_val)[:, 1] + metrics = _binary_metrics(y_val, val_probs) + val_loss = metrics["log_loss"] + else: + metrics = None + val_loss = 0.0 + + row = { + "C": float(c), + "val_metrics": metrics, + "val_loss": float(val_loss), + } + tuning_rows.append(row) + + if val_loss < best_loss: + best_loss = val_loss + best_c = c + + full_aug = augment_swap_symmetry(list(examples)) + x_full, y_full = build_interaction_matrix(full_aug, layout=layout) + _require_two_classes(y_full, context="Interaction refit") + final_model = LogisticRegression( + penalty="l2", + C=best_c, + max_iter=max_iter, + fit_intercept=False, + solver="lbfgs", + ) + final_model.fit(x_full, y_full) + + coef = final_model.coef_[0].astype(np.float64) + + start = layout.main_offset + end = start + layout.num_main + theta = coef[start:end] + + start = layout.synergy_offset + end = start + layout.num_synergy + alpha = coef[start:end] + + start = layout.matchup_offset + end = start + layout.num_matchup + beta = coef[start:end] + + theta = _center_theta(theta) + alpha = _center_alpha(alpha) + beta = _center_and_antisymmetrize_beta(layout, beta) + + return { + "model_type": "interaction", + "num_pokemon": int(num_pokemon), + "layout": { + "num_pokemon": int(layout.num_pokemon), + "main_offset": int(layout.main_offset), + "num_main": int(layout.num_main), + "synergy_offset": int(layout.synergy_offset), + "num_synergy": int(layout.num_synergy), + "matchup_offset": int(layout.matchup_offset), + "num_matchup": int(layout.num_matchup), + "n_features": int(layout.n_features), + }, + "theta": theta, + "alpha": alpha, + "beta": beta, + "intercept": 0.0, + "fit": { + "val_fraction": float(val_fraction), + "split_seed": int(split_seed), + "max_iter": int(max_iter), + "C_candidates": c_grid, + "best_C": float(best_c), + "tuning": tuning_rows, + "n_examples": int(len(examples)), + "n_train_original": int(len(train_orig)), + "n_val_original": int(len(val_orig)), + "n_train_augmented": int(len(train_aug)), + "n_val_augmented": int(len(val_aug)), + }, + } diff --git a/metamon/backend/team_construction/model_scoring.py b/metamon/backend/team_construction/model_scoring.py new file mode 100644 index 0000000000..4f568112f9 --- /dev/null +++ b/metamon/backend/team_construction/model_scoring.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Callable + +import numpy as np + +from .core import Team +from .feature_interaction import ( + InteractionFeatureLayout, + matchup_vector_to_matrix, + synergy_vector_to_matrix, +) + + +def _sigmoid(x: float) -> float: + if x >= 0: + z = np.exp(-x) + return float(1.0 / (1.0 + z)) + z = np.exp(x) + return float(z / (1.0 + z)) + + +@dataclass +class BaselineScorer: + theta: np.ndarray + intercept: float + + def logit(self, team_a: Team, team_b: Team) -> float: + return float( + self.intercept + + np.sum(self.theta[list(team_a)], dtype=np.float64) + - np.sum(self.theta[list(team_b)], dtype=np.float64) + ) + + def predict(self, team_a: Team, team_b: Team) -> float: + return _sigmoid(self.logit(team_a, team_b)) + + +@dataclass +class InteractionScorer: + theta: np.ndarray + synergy_matrix: np.ndarray + matchup_matrix: np.ndarray + intercept: float = 0.0 + + def __post_init__(self) -> None: + self._intrinsic_cache: dict[Team, float] = {} + + def _team_intrinsic(self, team: Team) -> float: + cached = self._intrinsic_cache.get(team) + if cached is not None: + return cached + + ids = list(team) + value = float(np.sum(self.theta[ids], dtype=np.float64)) + for i in range(len(ids)): + for j in range(i + 1, len(ids)): + value += float(self.synergy_matrix[ids[i], ids[j]]) + + self._intrinsic_cache[team] = value + return value + + def logit(self, team_a: Team, team_b: Team) -> float: + a = list(team_a) + b = list(team_b) + intrinsic = self._team_intrinsic(team_a) - self._team_intrinsic(team_b) + matchup = float(np.sum(self.matchup_matrix[np.ix_(a, b)], dtype=np.float64)) + return float(self.intercept + intrinsic + matchup) + + def predict(self, team_a: Team, team_b: Team) -> float: + return _sigmoid(self.logit(team_a, team_b)) + + +def make_scorer(model_artifact: dict) -> Callable[[Team, Team], float]: + model_type = model_artifact.get("model_type") + if model_type == "baseline": + scorer = BaselineScorer( + theta=np.asarray(model_artifact["theta"], dtype=np.float64), + intercept=float(model_artifact.get("intercept", 0.0)), + ) + return scorer.predict + + if model_type == "interaction": + num_pokemon = int(model_artifact["num_pokemon"]) + layout = InteractionFeatureLayout(num_pokemon=num_pokemon) + theta = np.asarray(model_artifact["theta"], dtype=np.float64) + alpha = np.asarray(model_artifact["alpha"], dtype=np.float64) + beta = np.asarray(model_artifact["beta"], dtype=np.float64) + scorer = InteractionScorer( + theta=theta, + synergy_matrix=synergy_vector_to_matrix(layout, alpha), + matchup_matrix=matchup_vector_to_matrix(layout, beta), + intercept=float(model_artifact.get("intercept", 0.0)), + ) + return scorer.predict + + raise ValueError(f"Unknown model_type in artifact: {model_type}") + + +def predict_win_prob(team_A_ids: Team, team_B_ids: Team, params: dict) -> float: + return float(make_scorer(params)(team_A_ids, team_B_ids)) + + +def interaction_matrices( + model_artifact: dict, +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + if model_artifact.get("model_type") != "interaction": + raise ValueError("interaction_matrices requires an interaction model artifact") + + num_pokemon = int(model_artifact["num_pokemon"]) + layout = InteractionFeatureLayout(num_pokemon=num_pokemon) + theta = np.asarray(model_artifact["theta"], dtype=np.float64) + alpha = np.asarray(model_artifact["alpha"], dtype=np.float64) + beta = np.asarray(model_artifact["beta"], dtype=np.float64) + synergy_matrix = synergy_vector_to_matrix(layout, alpha) + matchup_matrix = matchup_vector_to_matrix(layout, beta) + return theta, synergy_matrix, matchup_matrix diff --git a/metamon/backend/team_construction/pokemon_pool.py b/metamon/backend/team_construction/pokemon_pool.py new file mode 100644 index 0000000000..e3cd037a08 --- /dev/null +++ b/metamon/backend/team_construction/pokemon_pool.py @@ -0,0 +1,463 @@ +from __future__ import annotations + +import datetime as dt +import json +from pathlib import Path +from typing import Any, Sequence + +from metamon.backend import format_to_gen +from metamon.backend.replay_parser.str_parsing import pokemon_name +from metamon.backend.showdown_dex.dex import Dex +from metamon.backend.team_prediction.usage_stats import ( + DEFAULT_USAGE_RANK, + get_usage_stats, +) +from metamon.backend.team_construction.teams.parse import parse_species_name + +from .core import PokemonSet, Team, canonical_team + + +def _month_to_date(usage_month: str) -> dt.date: + try: + year, month = usage_month.split("-") + return dt.date(year=int(year), month=int(month), day=1) + except Exception as exc: + raise ValueError(f"usage_month must be YYYY-MM, got '{usage_month}'") from exc + + +def _norm(name: str) -> str: + return pokemon_name(name) + + +def _sorted_top_items(raw: dict[str, float], k: int) -> list[str]: + banned = {"", "other", "nothing"} + cleaned = [(str(name).strip(), float(value)) for name, value in raw.items()] + cleaned = [ + (name, value) + for name, value in cleaned + if _norm(name) not in banned and value > 0 + ] + cleaned.sort(key=lambda item: (item[1], item[0]), reverse=True) + return [name for name, _ in cleaned[:k]] + + +def _top_ability( + stats_entry: dict[str, Any], gen: int, dex_entry: dict[str, Any] +) -> str | None: + if gen <= 2: + return "No Ability" + + abilities = stats_entry.get("abilities", {}) or {} + top = _sorted_top_items(abilities, k=1) + if top: + return top[0] + + dex_abilities = dex_entry.get("abilities", {}) or {} + if dex_abilities: + ordered = sorted(dex_abilities.items(), key=lambda kv: kv[0]) + return str(ordered[0][1]) + return None + + +def _required_item( + stats_entry: dict[str, Any], dex_entry: dict[str, Any] +) -> str | None: + required_item = dex_entry.get("requiredItem") + if isinstance(required_item, str) and required_item.strip(): + return required_item.strip() + + required_items = dex_entry.get("requiredItems") + if isinstance(required_items, list): + options = [str(item).strip() for item in required_items if str(item).strip()] + if not options: + return None + + option_by_norm = {_norm(item): item for item in options} + top_items = _sorted_top_items(stats_entry.get("items", {}) or {}, k=16) + for item in top_items: + chosen = option_by_norm.get(_norm(item)) + if chosen is not None: + return chosen + return options[0] + + return None + + +def _ensure_required_move(moves: list[str], dex_entry: dict[str, Any]) -> list[str]: + required_move = dex_entry.get("requiredMove") + if not isinstance(required_move, str): + return moves + required_move = required_move.strip() + if not required_move: + return moves + if any(_norm(move) == _norm(required_move) for move in moves): + return moves + if len(moves) >= 4: + return [required_move, *moves[:3]] + return [required_move, *moves] + + +def build_standardized_showdown_set( + *, + species: str, + moves: list[str], + gen: int, + ability: str | None, + item: str | None = None, + nature: str = "Serious", + strict_max_evs: bool = False, +) -> str: + """Create a standardized per-species Showdown export block. + + In early generations we keep the set minimal because EV/nature syntax differs. + """ + + if len(moves) < 4: + raise ValueError(f"Need at least 4 moves for {species}, got {moves}") + + header = f"{species} @ {item}" if gen >= 2 and item else species + lines: list[str] = [header] + + resolved_ability = str(ability).strip() if ability else "" + if gen <= 2 and not resolved_ability: + resolved_ability = "No Ability" + if resolved_ability: + lines.append(f"Ability: {resolved_ability}") + + if gen >= 3: + if strict_max_evs: + lines.append( + "EVs: 252 HP / 252 Atk / 252 Def / 252 SpA / 252 SpD / 252 Spe" + ) + else: + lines.append("EVs: 84 HP / 84 Atk / 84 Def / 84 SpA / 84 SpD / 84 Spe") + lines.append(f"{nature} Nature") + + for move in moves[:4]: + lines.append(f"- {move}") + return "\n".join(lines) + + +def _load_replication_moves(path: Path) -> dict[str, list[str]]: + payload = json.loads(path.read_text(encoding="utf-8")) + out: dict[str, list[str]] = {} + + if isinstance(payload, dict): + for species, moves in payload.items(): + if isinstance(moves, dict): + raw_moves = moves.get("moves", []) + else: + raw_moves = moves + if not isinstance(raw_moves, list): + continue + cleaned = [str(m).strip() for m in raw_moves if str(m).strip()] + if cleaned: + out[_norm(species)] = cleaned + return out + + if isinstance(payload, list): + for row in payload: + if not isinstance(row, dict): + continue + species = str(row.get("species", "")).strip() + moves = row.get("moves", []) + if not species or not isinstance(moves, list): + continue + cleaned = [str(m).strip() for m in moves if str(m).strip()] + if cleaned: + out[_norm(species)] = cleaned + return out + + raise ValueError(f"Replication moves file must be a dict or list of dicts: {path}") + + +def _load_manual_sets(path: Path, gen: int) -> list[PokemonSet]: + payload = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(payload, list): + raise ValueError("Manual sets file must be a JSON list.") + + dex = Dex.from_gen(gen) + out: list[PokemonSet] = [] + for row in payload: + if not isinstance(row, dict): + continue + species = str(row.get("species", "")).strip() + moves = row.get("moves", []) + if not species or not isinstance(moves, list): + continue + clean_moves = [str(m).strip() for m in moves if str(m).strip()] + try: + dex_entry = dex.get_pokedex_entry(species) + except KeyError: + dex_entry = {} + clean_moves = _ensure_required_move(clean_moves, dex_entry=dex_entry) + if len(clean_moves) < 4: + raise ValueError( + f"Manual set for {species} must provide at least 4 moves, got {clean_moves}" + ) + showdown_set = str(row.get("showdown_set", "")).strip() + ability = row.get("ability") + resolved_ability = ( + str(ability).strip() + if ability + else _top_ability({}, gen=gen, dex_entry=dex_entry) + ) + item = row.get("item") or _required_item({}, dex_entry=dex_entry) + if not showdown_set: + showdown_set = build_standardized_showdown_set( + species=species, + moves=clean_moves, + gen=gen, + ability=resolved_ability, + item=str(item) if item else None, + ) + out.append( + PokemonSet( + species=species, + moves=tuple(clean_moves[:4]), + showdown_set=showdown_set, + usage=float(row.get("usage", 0.0) or 0.0), + ability=resolved_ability, + ) + ) + + if not out: + raise ValueError(f"No valid manual sets found in {path}") + return out + + +def get_eligible_pokemon( + format_id: str, + usage_month: str, + usage_threshold: float, + *, + rank: int = DEFAULT_USAGE_RANK, + replication_movesets_json: Path | None = None, + manual_sets_json: Path | None = None, + strict_max_evs: bool = False, +) -> list[PokemonSet]: + """Build the eligible Pokemon pool with one standardized set per species.""" + + gen = format_to_gen(format_id) + + if manual_sets_json is not None: + return _load_manual_sets(manual_sets_json, gen=gen) + + month_date = _month_to_date(usage_month) + stats = get_usage_stats( + format_id, + start_date=month_date, + end_date=month_date, + rank=rank, + ) + raw_movesets = stats.movesets + if not raw_movesets: + raise ValueError( + f"No usage stats available for {format_id} at {usage_month} (rank {rank})." + ) + + replication_moves: dict[str, list[str]] = {} + if replication_movesets_json is not None: + replication_moves = _load_replication_moves(replication_movesets_json) + + dex = Dex.from_gen(gen) + total = sum(float(entry.get("count", 0)) for entry in raw_movesets.values()) + if total <= 0: + raise ValueError( + f"Usage stats for {format_id} at {usage_month} have zero total count." + ) + + out: list[PokemonSet] = [] + for species_id, entry in raw_movesets.items(): + usage = float(entry.get("count", 0.0)) / total + if usage < usage_threshold: + continue + + try: + dex_entry = dex.get_pokedex_entry(species_id) + species_name = str(dex_entry.get("name", species_id)) + except KeyError: + dex_entry = {} + species_name = species_id + + override_moves = replication_moves.get( + _norm(species_name) + ) or replication_moves.get(_norm(species_id)) + if override_moves is not None: + moves = override_moves[:4] + else: + moves = _sorted_top_items(entry.get("moves", {}) or {}, k=4) + + moves = _ensure_required_move(moves, dex_entry=dex_entry) + if len(moves) < 4: + continue + + ability = _top_ability(entry, gen=gen, dex_entry=dex_entry) + item = _required_item(entry, dex_entry=dex_entry) + showdown = build_standardized_showdown_set( + species=species_name, + moves=moves, + gen=gen, + ability=ability, + item=item, + strict_max_evs=strict_max_evs, + ) + out.append( + PokemonSet( + species=species_name, + moves=tuple(moves), + showdown_set=showdown, + usage=usage, + ability=ability, + ) + ) + + out.sort(key=lambda p: (p.usage, p.species), reverse=True) + if not out: + raise ValueError( + "Filtering removed all Pokemon. Try lowering --usage-threshold or supplying manual sets." + ) + return out + + +def save_pool_artifact( + out_path: Path, + *, + format_id: str, + usage_month: str, + usage_threshold: float, + pokemon_sets: list[PokemonSet], + metadata: dict[str, Any] | None = None, +) -> None: + out_path.parent.mkdir(parents=True, exist_ok=True) + payload = { + "format_id": format_id, + "usage_month": usage_month, + "usage_threshold": float(usage_threshold), + "pool_size": len(pokemon_sets), + "pokemon_sets": [ + { + "id": idx, + "species": item.species, + "usage": float(item.usage), + "ability": item.ability, + "moves": list(item.moves), + "showdown_set": item.showdown_set, + } + for idx, item in enumerate(pokemon_sets) + ], + "metadata": metadata or {}, + } + out_path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8") + + +def load_pool_artifact(path: Path) -> dict[str, Any]: + data = json.loads(path.read_text(encoding="utf-8")) + if "pokemon_sets" not in data or not isinstance(data["pokemon_sets"], list): + raise ValueError(f"Invalid pool artifact: {path}") + return data + + +def pool_pokemon_sets(data: dict[str, Any]) -> list[PokemonSet]: + out: list[PokemonSet] = [] + for row in data["pokemon_sets"]: + out.append( + PokemonSet( + species=str(row["species"]), + moves=tuple(str(m) for m in row.get("moves", [])), + showdown_set=str(row["showdown_set"]), + usage=float(row.get("usage", 0.0) or 0.0), + ability=(str(row["ability"]) if row.get("ability") else None), + ) + ) + return out + + +def build_species_to_id_map(pokemon_sets: list[PokemonSet]) -> dict[str, int]: + species_to_id: dict[str, int] = {} + for idx, pset in enumerate(pokemon_sets): + species_to_id[_norm(pset.species)] = idx + return species_to_id + + +def build_species_clause_keys( + format_id: str, + pokemon_sets: Sequence[PokemonSet], +) -> dict[int, object]: + gen = format_to_gen(format_id) + dex = Dex.from_gen(gen) + + out: dict[int, object] = {} + for idx, pset in enumerate(pokemon_sets): + default_key: object = f"name:{_norm(pset.species)}" + try: + dex_entry = dex.get_pokedex_entry(pset.species) + except KeyError: + out[idx] = default_key + continue + + num = dex_entry.get("num") + if isinstance(num, int): + out[idx] = f"num:{num}" + continue + + base_species = dex_entry.get("baseSpecies") + if isinstance(base_species, str) and base_species.strip(): + out[idx] = f"base:{_norm(base_species)}" + continue + + out[idx] = default_key + return out + + +def extract_species_from_team_string(team_text: str) -> list[str]: + blocks = [block for block in team_text.split("\n\n") if block.strip()] + out: list[str] = [] + for block in blocks: + species = parse_species_name(block) + if species: + out.append(species) + return out + + +def team_string_to_ids( + team_text_or_names: str, + pokemon_sets: list[PokemonSet], + *, + team_size: int | None = None, +) -> Team: + species_to_id = build_species_to_id_map(pokemon_sets) + text = team_text_or_names.strip() + + if "\n" in text: + species_names = extract_species_from_team_string(text) + elif "," in text: + species_names = [chunk.strip() for chunk in text.split(",") if chunk.strip()] + else: + species_names = [chunk.strip() for chunk in text.split() if chunk.strip()] + + if not species_names: + raise ValueError("Could not parse species from team input.") + + team_ids: list[int] = [] + missing: list[str] = [] + for name in species_names: + idx = species_to_id.get(_norm(name)) + if idx is None: + missing.append(name) + continue + team_ids.append(idx) + + if missing: + raise ValueError( + "Unrecognized species in team input: " + + ", ".join(missing) + + ". Rebuild the pool or use matching species names." + ) + + return canonical_team(team_ids, team_size=team_size) + + +def team_ids_to_showdown(team: Team, pokemon_sets: list[PokemonSet]) -> str: + blocks = [pokemon_sets[idx].showdown_set.strip() for idx in team] + return "\n\n".join(blocks) diff --git a/metamon/backend/team_construction/restricted_game.py b/metamon/backend/team_construction/restricted_game.py new file mode 100644 index 0000000000..54569f58f8 --- /dev/null +++ b/metamon/backend/team_construction/restricted_game.py @@ -0,0 +1,234 @@ +from __future__ import annotations + +from typing import Callable, Sequence + +import numpy as np + +from .core import Team, canonical_team + +PairEvaluator = Callable[[Team, Team], float] +BestResponseFn = Callable[[Team], Team] +MixtureBestResponseFn = Callable[[Sequence[Team], np.ndarray], Team] + + +def build_strategy_pool( + seed_team: Team, + *, + best_response: BestResponseFn, + max_size: int, + stop_on_cycle: bool = True, +) -> list[Team]: + if max_size <= 0: + raise ValueError(f"max_size must be > 0, got {max_size}") + + pool: list[Team] = [canonical_team(seed_team)] + seen = {pool[0]} + + while len(pool) < max_size: + last = pool[-1] + br = canonical_team(best_response(last)) + + if br in seen: + if stop_on_cycle: + break + return pool + + pool.append(br) + seen.add(br) + + return pool + + +def expected_payoff_vs_mixture( + team: Team, + strategies: Sequence[Team], + mixture: np.ndarray, + pair_evaluator: PairEvaluator, +) -> float: + if len(strategies) != int(mixture.shape[0]): + raise ValueError( + f"strategies/mixture length mismatch: {len(strategies)} vs {mixture.shape[0]}" + ) + + weights = np.asarray(mixture, dtype=np.float64) + tol = 1e-12 + if np.any(weights < -tol): + raise ValueError("mixture contains negative probabilities") + weights = np.clip(weights, 0.0, None) + total = float(np.sum(weights)) + if total <= tol: + raise ValueError("mixture probabilities sum to zero") + weights = weights / total + + value = 0.0 + for weight, opponent in zip(weights, strategies): + p_win = float(pair_evaluator(team, opponent)) + value += float(weight) * (2.0 * p_win - 1.0) + return float(value) + + +def build_strategy_pool_double_oracle( + seed_team: Team, + *, + pair_evaluator: PairEvaluator, + best_response_to_mixture: MixtureBestResponseFn, + max_size: int, + stop_on_cycle: bool = True, + exploitability_tol: float = 1e-6, +) -> tuple[list[Team], list[dict]]: + """Expand restricted strategies via best response to the current equilibrium mixture.""" + + if max_size <= 0: + raise ValueError(f"max_size must be > 0, got {max_size}") + + pool: list[Team] = [canonical_team(seed_team)] + seen = {pool[0]} + iterations: list[dict] = [] + + while len(pool) < max_size: + payoff = build_payoff_matrix(pool, pair_evaluator) + eq = solve_zero_sum_equilibrium(payoff) + row_mix = np.asarray(eq["row_mixture"], dtype=np.float64) + br = canonical_team(best_response_to_mixture(pool, row_mix)) + br_value = expected_payoff_vs_mixture(br, pool, row_mix, pair_evaluator) + game_value = float(eq["game_value"]) + exploitability = float(br_value - game_value) + + iterations.append( + { + "iteration": len(iterations), + "pool_size": len(pool), + "game_value": game_value, + "best_response_value": br_value, + "exploitability": exploitability, + "best_response": list(br), + "row_mixture": row_mix.tolist(), + } + ) + + if exploitability <= exploitability_tol: + break + + if br in seen: + if stop_on_cycle: + break + return pool, iterations + + pool.append(br) + seen.add(br) + + return pool, iterations + + +def build_payoff_matrix( + strategies: Sequence[Team], pair_evaluator: PairEvaluator +) -> np.ndarray: + k = len(strategies) + if k == 0: + raise ValueError("strategies cannot be empty") + + payoff = np.zeros((k, k), dtype=np.float64) + + for i in range(k): + payoff[i, i] = 0.0 + for j in range(i + 1, k): + p_ij = float(pair_evaluator(strategies[i], strategies[j])) + value = 2.0 * p_ij - 1.0 + payoff[i, j] = value + payoff[j, i] = -value + + return payoff + + +def payoff_antisymmetry_error(payoff: np.ndarray) -> float: + return float(np.max(np.abs(payoff + payoff.T))) + + +def _normalize_mixture(mixture: np.ndarray, *, tol: float = 1e-12) -> np.ndarray: + weights = np.asarray(mixture, dtype=np.float64).reshape(-1) + if weights.size == 0: + raise ValueError("mixture cannot be empty") + weights = np.where(np.isfinite(weights), weights, 0.0) + weights = np.clip(weights, 0.0, None) + total = float(np.sum(weights)) + if total <= tol: + return np.full(weights.shape, 1.0 / float(weights.size), dtype=np.float64) + return weights / total + + +def solve_zero_sum_equilibrium( + payoff: np.ndarray, + *, + symmetric_tol: float = 1e-6, +) -> dict: + matrix = np.asarray(payoff, dtype=np.float64) + if matrix.ndim != 2 or matrix.shape[0] != matrix.shape[1]: + raise ValueError(f"payoff must be square, got shape {matrix.shape}") + + try: + import nashpy as nash + except ImportError as exc: + raise ImportError( + "nashpy is required for equilibrium solving. Install nashpy to continue." + ) from exc + + game = nash.Game(matrix, -matrix) + solver = "support_enumeration" + + try: + equilibria = list(game.support_enumeration()) + except Exception: + equilibria = [] + + if not equilibria: + solver = "vertex_enumeration" + try: + equilibria = list(game.vertex_enumeration()) + except Exception: + equilibria = [] + + if equilibria: + chosen_idx = 0 + for idx, (eq_row, eq_col) in enumerate(equilibria): + if np.allclose(eq_row, eq_col, atol=symmetric_tol, rtol=0.0): + chosen_idx = idx + break + + sigma_row, sigma_col = equilibria[chosen_idx] + sigma_row = _normalize_mixture(np.asarray(sigma_row, dtype=np.float64)) + sigma_col = _normalize_mixture(np.asarray(sigma_col, dtype=np.float64)) + else: + solver = "linear_program" + try: + sigma_row, sigma_col = game.linear_program() + except Exception as exc: + raise RuntimeError( + "nashpy failed to compute equilibrium via support_enumeration, " + "vertex_enumeration, and linear_program" + ) from exc + + chosen_idx = -1 + sigma_row = _normalize_mixture(np.asarray(sigma_row, dtype=np.float64)) + sigma_col = _normalize_mixture(np.asarray(sigma_col, dtype=np.float64)) + + if payoff_antisymmetry_error(matrix) <= symmetric_tol: + sigma_sym = _normalize_mixture(0.5 * (sigma_row + sigma_col)) + sigma_row = sigma_sym + sigma_col = sigma_sym + + value = float(np.dot(sigma_row, matrix @ sigma_col)) + + return { + "chosen_index": int(chosen_idx), + "solver": solver, + "row_mixture": sigma_row, + "col_mixture": sigma_col, + "game_value": value, + "all_equilibria": [ + { + "row": np.asarray(r, dtype=np.float64), + "col": np.asarray(c, dtype=np.float64), + } + for r, c in equilibria + ], + } diff --git a/metamon/backend/team_construction/simulation.py b/metamon/backend/team_construction/simulation.py new file mode 100644 index 0000000000..3953a643b9 --- /dev/null +++ b/metamon/backend/team_construction/simulation.py @@ -0,0 +1,794 @@ +from __future__ import annotations + +import asyncio +import concurrent.futures +import hashlib +import json +import os +import random +import shutil +import time +import uuid +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Callable, Iterable, Mapping, Sequence + +from tqdm import tqdm + +from .core import ( + BattleExample, + Team, + battle_example_from_json_dict, + battle_example_to_json_dict, +) + + +@dataclass(frozen=True) +class SimulationMetadata: + format_id: str + agent_name: str + n_battles: int + seed: int + backend: str + concurrency: int + sampling_strategy: str = "uniform" + sampling_metadata: dict | None = None + + +def sample_team( + pool_ids: Sequence[int], + team_size: int = 6, + replace: bool = False, + rng: random.Random | None = None, + species_clause_keys: Mapping[int, object] | None = None, +) -> Team: + """Sample one legal team as a sorted tuple of distinct Pokemon IDs.""" + + if team_size <= 0: + raise ValueError(f"team_size must be > 0, got {team_size}") + pool = sorted({int(x) for x in pool_ids}) + if not pool: + raise ValueError("pool_ids is empty") + if rng is None: + rng = random.Random() + + if species_clause_keys is not None: + members_by_key: dict[object, list[int]] = {} + for member in pool: + key = species_clause_keys.get(member, member) + members_by_key.setdefault(key, []).append(member) + keys = sorted(members_by_key.keys(), key=str) + if len(keys) < team_size: + raise ValueError( + "Pool has too few unique species-clause groups for sampling: " + f"{len(keys)} < team_size={team_size}" + ) + chosen_keys = rng.sample(keys, k=team_size) + sampled = [int(rng.choice(members_by_key[key])) for key in chosen_keys] + else: + if len(pool) < team_size: + raise ValueError( + f"Pool too small for sampling: {len(pool)} < team_size={team_size}" + ) + if replace: + sampled = [] + seen: set[int] = set() + max_attempts = max(100, 10 * team_size) + attempts = 0 + while len(sampled) < team_size and attempts < max_attempts: + attempts += 1 + pick = int(rng.choice(pool)) + if pick in seen: + continue + sampled.append(pick) + seen.add(pick) + if len(sampled) < team_size: + remaining = [x for x in pool if x not in seen] + rng.shuffle(remaining) + sampled.extend(remaining[: team_size - len(sampled)]) + else: + sampled = rng.sample(pool, k=team_size) + + if len(set(sampled)) != len(sampled): + raise ValueError("Duplicate Pokemon in sampled team. Use replace=False.") + if species_clause_keys is not None: + clause_members = [species_clause_keys.get(member, member) for member in sampled] + if len(set(clause_members)) != len(clause_members): + raise ValueError("Sampled team violates species clause.") + return tuple(sorted(int(x) for x in sampled)) + + +def make_uniform_matchup_sampler( + pool_ids: Sequence[int], + *, + team_size: int, + replace: bool, + rng: random.Random, + species_clause_keys: Mapping[int, object] | None = None, +) -> Callable[[], tuple[Team, Team]]: + """Uniform metagame sampler used by the paper-style training setup.""" + + def _sample() -> tuple[Team, Team]: + return ( + sample_team( + pool_ids, + team_size=team_size, + replace=replace, + rng=rng, + species_clause_keys=species_clause_keys, + ), + sample_team( + pool_ids, + team_size=team_size, + replace=replace, + rng=rng, + species_clause_keys=species_clause_keys, + ), + ) + + return _sample + + +def make_active_matchup_sampler( + pool_ids: Sequence[int], + *, + pair_evaluator: Callable[[Team, Team], float], + team_size: int, + replace: bool, + rng: random.Random, + candidate_pool_size: int = 256, + uniform_mix: float = 0.25, + min_uncertainty: float = 1e-6, + species_clause_keys: Mapping[int, object] | None = None, +) -> Callable[[], tuple[Team, Team]]: + """Model-guided active sampler that prioritizes uncertain team-vs-team pairs.""" + + if candidate_pool_size < 2: + raise ValueError(f"candidate_pool_size must be >= 2, got {candidate_pool_size}") + if not 0.0 <= uniform_mix <= 1.0: + raise ValueError(f"uniform_mix must be in [0,1], got {uniform_mix}") + if min_uncertainty <= 0.0: + raise ValueError(f"min_uncertainty must be > 0, got {min_uncertainty}") + + bank: list[Team] = [] + seen: set[Team] = set() + attempts = 0 + max_attempts = max(1000, candidate_pool_size * 20) + while len(bank) < candidate_pool_size and attempts < max_attempts: + attempts += 1 + team = sample_team( + pool_ids, + team_size=team_size, + replace=replace, + rng=rng, + species_clause_keys=species_clause_keys, + ) + if team in seen: + continue + bank.append(team) + seen.add(team) + + if len(bank) < 2: + return make_uniform_matchup_sampler( + pool_ids, + team_size=team_size, + replace=replace, + rng=rng, + species_clause_keys=species_clause_keys, + ) + + pairs: list[tuple[Team, Team]] = [] + weights: list[float] = [] + for i in range(len(bank)): + for j in range(i + 1, len(bank)): + a = bank[i] + b = bank[j] + p = float(pair_evaluator(a, b)) + if not (p == p) or p < 0.0 or p > 1.0: + p = 0.5 + p = min(max(p, 1e-9), 1.0 - 1e-9) + uncertainty = 4.0 * p * (1.0 - p) + pairs.append((a, b)) + weights.append(max(min_uncertainty, uncertainty)) + + if not pairs: + return make_uniform_matchup_sampler( + pool_ids, + team_size=team_size, + replace=replace, + rng=rng, + species_clause_keys=species_clause_keys, + ) + + idxs = list(range(len(pairs))) + + def _sample() -> tuple[Team, Team]: + if rng.random() < uniform_mix: + return ( + sample_team( + pool_ids, + team_size=team_size, + replace=replace, + rng=rng, + species_clause_keys=species_clause_keys, + ), + sample_team( + pool_ids, + team_size=team_size, + replace=replace, + rng=rng, + species_clause_keys=species_clause_keys, + ), + ) + chosen = rng.choices(idxs, weights=weights, k=1)[0] + a, b = pairs[chosen] + return (a, b) if rng.random() < 0.5 else (b, a) + + return _sample + + +def _synthetic_outcome(team_a: Team, team_b: Team, seed: int) -> int: + """Deterministic fallback simulator useful for CI and correctness tests.""" + + key = f"{seed}|{','.join(map(str, team_a))}|{','.join(map(str, team_b))}".encode( + "utf-8" + ) + digest = hashlib.sha256(key).digest() + draw = int.from_bytes(digest[:8], byteorder="big", signed=False) + rng = random.Random(draw) + return 1 if rng.random() < 0.5 else 0 + + +def _run_single_poke_env_battle( + *, + team_a_showdown: str, + team_b_showdown: str, + format_id: str, + timeout_sec: float, +) -> int: + """Play one battle between two identical heuristic policies; return y in {0,1}.""" + + from poke_env import AccountConfiguration, LocalhostServerConfiguration + from poke_env.player.baselines import SimpleHeuristicsPlayer + + async def _main() -> int: + username_a = f"tcA-{uuid.uuid4().hex[:12]}" + username_b = f"tcB-{uuid.uuid4().hex[:12]}" + player_a = SimpleHeuristicsPlayer( + battle_format=format_id, + team=team_a_showdown, + account_configuration=AccountConfiguration(username_a, None), + server_configuration=LocalhostServerConfiguration, + max_concurrent_battles=1, + ) + player_b = SimpleHeuristicsPlayer( + battle_format=format_id, + team=team_b_showdown, + account_configuration=AccountConfiguration(username_b, None), + server_configuration=LocalhostServerConfiguration, + max_concurrent_battles=1, + ) + + await player_a.battle_against(player_b, n_battles=1) + return 1 if int(getattr(player_a, "n_won_battles", 0)) > 0 else 0 + + loop = asyncio.new_event_loop() + try: + asyncio.set_event_loop(loop) + return int( + loop.run_until_complete(asyncio.wait_for(_main(), timeout=timeout_sec)) + ) + finally: + try: + loop.run_until_complete(loop.shutdown_asyncgens()) + except Exception: + pass + asyncio.set_event_loop(None) + loop.close() + + +def _run_single_metamon_battle( + *, + team_a_showdown: str, + team_b_showdown: str, + format_id: str, + model_name: str, + seed: int, + gpu_a: int, + gpu_b: int, + work_dir: Path | None, + checkpoint: int | None, + print_match_stats: bool, +) -> int: + """Play one battle using metamon pretrained policies; return y in {0,1}. + + Team A is assigned to the acceptor role, so y=1 means Team A wins. + """ + + from .matchup import run_matchup + + cache_dir_env = os.environ.get("METAMON_CACHE_DIR") + if not cache_dir_env: + raise ValueError( + "METAMON_CACHE_DIR must be set for backend='metamon' simulation." + ) + cache_dir = Path(cache_dir_env) + teams_root = cache_dir / "teams" + + run_id = uuid.uuid4().hex[:10] + team_set_a = f"team_construction_sim_a_{run_id}" + team_set_b = f"team_construction_sim_b_{run_id}" + team_dir_a = teams_root / team_set_a / format_id + team_dir_b = teams_root / team_set_b / format_id + + team_dir_a.mkdir(parents=True, exist_ok=True) + team_dir_b.mkdir(parents=True, exist_ok=True) + (team_dir_a / f"team_0001.{format_id}_team").write_text( + team_a_showdown.strip() + "\n", + encoding="utf-8", + ) + (team_dir_b / f"team_0001.{format_id}_team").write_text( + team_b_showdown.strip() + "\n", + encoding="utf-8", + ) + + try: + result = run_matchup( + battle_format=format_id, + num_battles=1, + model_name=model_name, + team_set_a=team_set_a, + team_set_b=team_set_b, + gpu_a=gpu_a, + gpu_b=gpu_b, + work_dir=( + work_dir or Path("/tmp/team_prediction/team_construction_battles") + ), + checkpoint=checkpoint, + print_match_stats=print_match_stats, + ) + + acceptor_matches = result.get("acceptor_matches", []) + if acceptor_matches: + outcome = str(acceptor_matches[0].get("result", "")).strip().upper() + if outcome == "WIN": + return 1 + if outcome == "LOSS": + return 0 + if outcome == "DRAW": + # Preserve binary labels while avoiding systematic draw bias. + return 1 if random.Random(seed).random() < 0.5 else 0 + + wr = float(result["acceptor_summary"]["win_rate"]) + if wr > 0.5: + return 1 + if wr < 0.5: + return 0 + return 1 if random.Random(seed + 17).random() < 0.5 else 0 + finally: + shutil.rmtree(team_dir_a.parent, ignore_errors=True) + shutil.rmtree(team_dir_b.parent, ignore_errors=True) + + +def _result_to_binary(result_raw: str, *, seed: int) -> int: + outcome = str(result_raw).strip().upper() + if outcome == "WIN": + return 1 + if outcome == "LOSS": + return 0 + if outcome == "DRAW": + return 1 if random.Random(seed).random() < 0.5 else 0 + raise ValueError(f"Unknown matchup result '{result_raw}'") + + +def _simulate_metamon_batch( + *, + pairs: Sequence[tuple[Team, Team]], + team_to_showdown: Callable[[Team], str], + format_id: str, + model_name: str, + seed: int, + gpu_a: int, + gpu_b: int, + work_dir: Path | None, + checkpoint: int | None, + print_match_stats: bool, + max_retries: int, + retry_sleep_sec: float, +) -> list[BattleExample]: + """Run all metamon battles in one matchup call to avoid per-battle agent reinit.""" + + from .matchup import run_matchup + + if not pairs: + return [] + + cache_dir_env = os.environ.get("METAMON_CACHE_DIR") + if not cache_dir_env: + raise ValueError( + "METAMON_CACHE_DIR must be set for backend='metamon' simulation." + ) + cache_dir = Path(cache_dir_env) + teams_root = cache_dir / "teams" + + run_id = uuid.uuid4().hex[:10] + team_set_a = f"team_construction_sim_batch_a_{run_id}" + team_set_b = f"team_construction_sim_batch_b_{run_id}" + team_dir_a = teams_root / team_set_a / format_id + team_dir_b = teams_root / team_set_b / format_id + team_dir_a.mkdir(parents=True, exist_ok=True) + team_dir_b.mkdir(parents=True, exist_ok=True) + + def _write_team_bank( + team_dir: Path, + teams: Sequence[Team], + ) -> tuple[dict[str, Team], dict[str, Team]]: + by_path: dict[str, Team] = {} + by_name: dict[str, Team] = {} + for idx, team in enumerate(teams, start=1): + team_file = team_dir / f"team_{idx:04d}.{format_id}_team" + team_file.write_text( + team_to_showdown(team).strip() + "\n", encoding="utf-8" + ) + resolved = str(team_file.resolve()) + by_path[resolved] = team + by_name[team_file.name] = team + return by_path, by_name + + try: + sampled_a = [a for a, _ in pairs] + sampled_b = [b for _, b in pairs] + a_by_path, a_by_name = _write_team_bank(team_dir_a, sampled_a) + b_by_path, b_by_name = _write_team_bank(team_dir_b, sampled_b) + + last_error: Exception | None = None + result: dict | None = None + for attempt in range(max_retries + 1): + try: + result = run_matchup( + battle_format=format_id, + num_battles=len(pairs), + model_name=model_name, + team_set_a=team_set_a, + team_set_b=team_set_b, + gpu_a=gpu_a, + gpu_b=gpu_b, + work_dir=( + work_dir + or Path("/tmp/team_prediction/team_construction_battles") + ), + checkpoint=checkpoint, + print_match_stats=print_match_stats, + ) + break + except Exception as exc: + last_error = exc + if attempt >= max_retries: + raise RuntimeError( + f"batch metamon simulation failed after retries: {last_error}" + ) from last_error + time.sleep(retry_sleep_sec) + + assert result is not None + acceptor_matches = result.get("acceptor_matches", []) + challenger_matches = result.get("challenger_matches", []) + if not acceptor_matches or not challenger_matches: + raise RuntimeError("metamon batch simulation produced empty match logs.") + + n = min(len(pairs), len(acceptor_matches), len(challenger_matches)) + if n <= 0: + raise RuntimeError("metamon batch simulation produced zero usable matches.") + + examples: list[BattleExample] = [] + for idx in range(n): + a_row = acceptor_matches[idx] + b_row = challenger_matches[idx] + + a_path_raw = str(a_row.get("team_file_path", "")) + b_path_raw = str(b_row.get("team_file_path", "")) + a_path = str(Path(a_path_raw).resolve()) if a_path_raw else "" + b_path = str(Path(b_path_raw).resolve()) if b_path_raw else "" + + team_a = a_by_path.get(a_path) + team_b = b_by_path.get(b_path) + if team_a is None and a_path_raw: + team_a = a_by_name.get(Path(a_path_raw).name) + if team_b is None and b_path_raw: + team_b = b_by_name.get(Path(b_path_raw).name) + if team_a is None or team_b is None: + raise RuntimeError( + f"Could not map team files back to sampled teams: " + f"a='{a_path_raw}', b='{b_path_raw}'" + ) + + y = _result_to_binary(str(a_row.get("result", "")), seed=seed + idx) + examples.append(BattleExample(team_A=team_a, team_B=team_b, y=int(y))) + + if n < len(pairs): + raise RuntimeError( + f"metamon batch simulation returned fewer matches ({n}) " + f"than requested ({len(pairs)})." + ) + return examples + finally: + shutil.rmtree(team_dir_a.parent, ignore_errors=True) + shutil.rmtree(team_dir_b.parent, ignore_errors=True) + + +def _simulate_one( + *, + team_a: Team, + team_b: Team, + seed: int, + backend: str, + format_id: str, + team_to_showdown: Callable[[Team], str] | None, + timeout_sec: float, + max_retries: int, + retry_sleep_sec: float, + metamon_model_name: str | None = None, + metamon_checkpoint: int | None = None, + metamon_gpu_a: int = 0, + metamon_gpu_b: int = 1, + metamon_work_dir: Path | None = None, + metamon_print_match_stats: bool = False, +) -> BattleExample: + last_error: Exception | None = None + for attempt in range(max_retries + 1): + try: + if backend == "synthetic": + y = _synthetic_outcome(team_a, team_b, seed=seed) + elif backend == "poke_env": + if team_to_showdown is None: + raise ValueError( + "team_to_showdown is required for poke_env simulation" + ) + y = _run_single_poke_env_battle( + team_a_showdown=team_to_showdown(team_a), + team_b_showdown=team_to_showdown(team_b), + format_id=format_id, + timeout_sec=timeout_sec, + ) + elif backend == "metamon": + if team_to_showdown is None: + raise ValueError( + "team_to_showdown is required for metamon simulation" + ) + if not metamon_model_name: + raise ValueError( + "metamon_model_name is required for backend='metamon'" + ) + y = _run_single_metamon_battle( + team_a_showdown=team_to_showdown(team_a), + team_b_showdown=team_to_showdown(team_b), + format_id=format_id, + model_name=metamon_model_name, + seed=seed, + gpu_a=metamon_gpu_a, + gpu_b=metamon_gpu_b, + work_dir=metamon_work_dir, + checkpoint=metamon_checkpoint, + print_match_stats=metamon_print_match_stats, + ) + else: + raise ValueError(f"Unknown simulation backend '{backend}'") + return BattleExample(team_A=team_a, team_B=team_b, y=int(y)) + except Exception as exc: + last_error = exc + if attempt >= max_retries: + break + time.sleep(retry_sleep_sec) + + raise RuntimeError( + f"simulate battle failed after retries for teams {team_a} vs {team_b}: {last_error}" + ) + + +def simulate_battles( + n: int, + sampler: Callable[[], tuple[Team, Team]], + agent_class: str, + format_id: str, + concurrency: int, + *, + seed: int = 0, + backend: str = "poke_env", + team_to_showdown: Callable[[Team], str] | None = None, + timeout_sec: float = 240.0, + max_retries: int = 2, + retry_sleep_sec: float = 2.0, + metamon_model_name: str | None = None, + metamon_checkpoint: int | None = None, + metamon_gpu_a: int = 0, + metamon_gpu_b: int = 1, + metamon_work_dir: Path | None = None, + metamon_print_match_stats: bool = False, + incremental_out: Path | None = None, + incremental_flush_every: int = 50, + show_progress: bool = True, + progress_desc: str | None = None, +) -> list[BattleExample]: + """Run team-vs-team simulations and return supervised battle examples. + + backends: + - synthetic: deterministic CI/debug fallback + - poke_env: SimpleHeuristicsPlayer vs SimpleHeuristicsPlayer + - metamon: pretrained policy (e.g., Kakuna) vs itself via run_matchup + """ + + if n <= 0: + return [] + if concurrency <= 0: + raise ValueError(f"concurrency must be >= 1, got {concurrency}") + if backend == "poke_env": + normalized = agent_class.strip().lower() + valid = { + "simpleheuristicsplayer", + "simple_heuristics_player", + "simpleheuristics", + } + if normalized not in valid: + raise ValueError( + "Only SimpleHeuristicsPlayer is currently supported for backend='poke_env'. " + f"Got agent_class='{agent_class}'." + ) + elif backend == "metamon": + if not metamon_model_name: + raise ValueError( + "metamon_model_name is required for backend='metamon'. " + "Pass a pretrained model name like 'Kakuna'." + ) + if team_to_showdown is None: + raise ValueError("team_to_showdown is required for backend='metamon'") + + pairs = [sampler() for _ in range(n)] + + if backend == "metamon": + examples = _simulate_metamon_batch( + pairs=pairs, + team_to_showdown=team_to_showdown, + format_id=format_id, + model_name=metamon_model_name, + seed=seed, + gpu_a=metamon_gpu_a, + gpu_b=metamon_gpu_b, + work_dir=metamon_work_dir, + checkpoint=metamon_checkpoint, + print_match_stats=metamon_print_match_stats, + max_retries=max_retries, + retry_sleep_sec=retry_sleep_sec, + ) + if incremental_out is not None: + incremental_out.parent.mkdir(parents=True, exist_ok=True) + with incremental_out.open("w", encoding="utf-8") as out_handle: + for idx, example in enumerate(examples, start=1): + out_handle.write( + json.dumps(battle_example_to_json_dict(example)) + "\n" + ) + if idx % max(1, incremental_flush_every) == 0: + out_handle.flush() + return examples + + out_handle = None + if incremental_out is not None: + incremental_out.parent.mkdir(parents=True, exist_ok=True) + out_handle = incremental_out.open("w", encoding="utf-8") + + examples: list[BattleExample] = [] + + def _job(payload: tuple[int, Team, Team]) -> BattleExample: + idx, team_a, team_b = payload + return _simulate_one( + team_a=team_a, + team_b=team_b, + seed=seed + idx, + backend=backend, + format_id=format_id, + team_to_showdown=team_to_showdown, + timeout_sec=timeout_sec, + max_retries=max_retries, + retry_sleep_sec=retry_sleep_sec, + metamon_model_name=metamon_model_name, + metamon_checkpoint=metamon_checkpoint, + metamon_gpu_a=metamon_gpu_a, + metamon_gpu_b=metamon_gpu_b, + metamon_work_dir=metamon_work_dir, + metamon_print_match_stats=metamon_print_match_stats, + ) + + jobs = [(idx, pair[0], pair[1]) for idx, pair in enumerate(pairs)] + + executor = None + try: + if concurrency == 1: + iterator = map(_job, jobs) + else: + executor = concurrent.futures.ThreadPoolExecutor(max_workers=concurrency) + iterator = executor.map(_job, jobs) + + with tqdm( + total=len(jobs), + desc=progress_desc or f"Simulating {n} battles", + unit="battle", + dynamic_ncols=True, + disable=not show_progress, + ) as pbar: + for idx, example in enumerate(iterator, start=1): + examples.append(example) + if out_handle is not None: + out_handle.write( + json.dumps(battle_example_to_json_dict(example)) + "\n" + ) + if idx % max(1, incremental_flush_every) == 0: + out_handle.flush() + pbar.update(1) + finally: + if out_handle is not None: + out_handle.flush() + out_handle.close() + if executor is not None: + executor.shutdown(wait=True) + + return examples + + +def augment_swap_symmetry(examples: Sequence[BattleExample]) -> list[BattleExample]: + """Add swapped team/order examples (x, y) -> (swap(x), 1-y).""" + + out: list[BattleExample] = [] + for example in examples: + out.append(example) + out.append(example.swapped()) + return out + + +def split_before_augmentation( + examples: Sequence[BattleExample], + *, + val_fraction: float, + seed: int, +) -> tuple[list[BattleExample], list[BattleExample]]: + """Split originals first so augmented pairs stay in the same split.""" + + if not 0.0 <= val_fraction < 1.0: + raise ValueError(f"val_fraction must be in [0, 1), got {val_fraction}") + + rng = random.Random(seed) + indices = list(range(len(examples))) + rng.shuffle(indices) + n_val = int(round(len(indices) * val_fraction)) + val_idx = set(indices[:n_val]) + + train: list[BattleExample] = [] + val: list[BattleExample] = [] + for idx, example in enumerate(examples): + if idx in val_idx: + val.append(example) + else: + train.append(example) + return train, val + + +def save_examples_jsonl(path: Path, examples: Iterable[BattleExample]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8") as f: + for ex in examples: + f.write(json.dumps(battle_example_to_json_dict(ex)) + "\n") + + +def load_examples_jsonl(path: Path) -> list[BattleExample]: + out: list[BattleExample] = [] + with path.open("r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line: + continue + out.append(battle_example_from_json_dict(json.loads(line))) + return out + + +def save_simulation_metadata(path: Path, metadata: SimulationMetadata) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps(asdict(metadata), indent=2, sort_keys=True), encoding="utf-8" + ) diff --git a/metamon/backend/team_construction/teams/parse.py b/metamon/backend/team_construction/teams/parse.py new file mode 100644 index 0000000000..ef2851bac5 --- /dev/null +++ b/metamon/backend/team_construction/teams/parse.py @@ -0,0 +1,246 @@ +import argparse +import csv +import re +import sys +from collections import defaultdict +from pathlib import Path +from typing import Dict, Iterable, List, Optional, Set, Tuple + +FORMAT_RE = re.compile(r"\.(gen[0-9][a-z0-9]*)_team$", re.IGNORECASE) + + +def infer_format( + team_file: Path, fallback_format: Optional[str] = None +) -> Optional[str]: + match = FORMAT_RE.search(team_file.name) + if match: + return match.group(1).lower() + if fallback_format: + return fallback_format.lower() + return None + + +def team_id_from_filename(team_file: Path) -> str: + name = team_file.name + match = FORMAT_RE.search(name) + if match: + base = name[: match.start()] + else: + base = team_file.stem + team_num_match = re.fullmatch(r"team_(\d+)", base) + if team_num_match: + return team_num_match.group(1) + return base + + +def sort_team_ids(team_ids: Iterable[str]) -> List[str]: + def key_fn(value: str): + num_match = re.search(r"\d+", value) + if num_match: + return (0, int(num_match.group()), value) + return (1, value) + + return sorted(team_ids, key=key_fn) + + +def parse_species_name(block: str) -> Optional[str]: + lines = [line.strip() for line in block.splitlines() if line.strip()] + if not lines: + return None + + header = lines[0] + if header.startswith(("-", "Ability:", "EVs:", "IVs:", "Level:", "Tera Type:")): + return None + + if " @ " in header: + header = header.split(" @ ", 1)[0].strip() + + form_match = re.search(r"\(([^()]*)\)\s*$", header) + if form_match: + candidate = form_match.group(1).strip() + if candidate: + return candidate + + if header.endswith(")"): + return None + + return header + + +def parse_moveset(block: str) -> Tuple[str, ...]: + moves: List[str] = [] + for line in block.splitlines(): + line = line.strip() + if not line.startswith("-"): + continue + move = line[1:].strip() + if move: + moves.append(move) + return tuple(moves) + + +def parse_team_file(team_file: Path) -> List[Tuple[str, Tuple[str, ...]]]: + content = team_file.read_text(encoding="utf-8", errors="replace") + blocks = [block for block in content.split("\n\n") if block.strip()] + parsed: List[Tuple[str, Tuple[str, ...]]] = [] + seen: Set[str] = set() + for block in blocks: + name = parse_species_name(block) + if not name or name in seen: + continue + seen.add(name) + parsed.append((name, parse_moveset(block))) + return parsed + + +def parse_teams( + team_dir: Path, fallback_format: Optional[str] +) -> Tuple[Dict[str, Set[str]], Dict[str, Dict[Tuple[str, ...], Set[str]]]]: + pokemon_to_teams: Dict[str, Set[str]] = defaultdict(set) + pokemon_to_movesets: Dict[str, Dict[Tuple[str, ...], Set[str]]] = defaultdict( + lambda: defaultdict(set) + ) + team_files = sorted(team_dir.rglob("*.gen*_team")) + + if not team_files: + raise FileNotFoundError(f"No team files found under: {team_dir}") + + failed_files: List[str] = [] + + for team_file in team_files: + format_name = infer_format(team_file, fallback_format=fallback_format) + if not format_name: + failed_files.append(f"{team_file} (cannot infer format)") + continue + + try: + parsed_entries = parse_team_file(team_file) + except Exception as exc: + failed_files.append(f"{team_file} ({exc})") + continue + + if not parsed_entries: + failed_files.append(f"{team_file} (no Pokemon parsed)") + continue + + team_id = team_id_from_filename(team_file) + for pokemon_name, moveset in parsed_entries: + pokemon_to_teams[pokemon_name].add(team_id) + pokemon_to_movesets[pokemon_name][moveset].add(team_id) + + if failed_files: + print("Skipped team files:", file=sys.stderr) + for line in failed_files: + print(f" - {line}", file=sys.stderr) + + if not pokemon_to_teams: + raise RuntimeError("No Pokemon names were parsed from the provided team files.") + + return pokemon_to_teams, pokemon_to_movesets + + +def write_team_index_csv( + pokemon_to_teams: Dict[str, Set[str]], output_csv: Path +) -> None: + output_csv.parent.mkdir(parents=True, exist_ok=True) + with output_csv.open("w", newline="", encoding="utf-8") as f: + writer = csv.writer(f) + writer.writerow(["pokemon_name", "team_count", "team_ids"]) + for pokemon_name in sorted(pokemon_to_teams): + team_ids = sort_team_ids(pokemon_to_teams[pokemon_name]) + writer.writerow([pokemon_name, len(team_ids), ",".join(team_ids)]) + + +def write_moveset_index_csv( + pokemon_to_movesets: Dict[str, Dict[Tuple[str, ...], Set[str]]], output_csv: Path +) -> None: + output_csv.parent.mkdir(parents=True, exist_ok=True) + with output_csv.open("w", newline="", encoding="utf-8") as f: + writer = csv.writer(f) + writer.writerow(["pokemon_name", "moveset", "team_count", "team_ids"]) + for pokemon_name in sorted(pokemon_to_movesets): + rows: List[Tuple[int, str, str, str]] = [] + for moveset, team_ids in pokemon_to_movesets[pokemon_name].items(): + ordered_team_ids = sort_team_ids(team_ids) + moveset_str = " / ".join(moveset) + rows.append( + ( + -len(ordered_team_ids), + moveset_str, + str(len(ordered_team_ids)), + ",".join(ordered_team_ids), + ) + ) + for _, moveset_str, team_count, team_id_str in sorted(rows): + writer.writerow([pokemon_name, moveset_str, team_count, team_id_str]) + + +def main() -> None: + parser = argparse.ArgumentParser( + description=( + "Create CSV indexes from team files: " + "pokemon->team IDs and pokemon->movesets." + ) + ) + parser.add_argument( + "team_dir", type=Path, help="Directory containing showdown team files." + ) + parser.add_argument( + "-o", + "--team-output", + type=Path, + default=Path("/tmp/team_construction/gen1ou_pokemon_team_index.csv"), + help=( + "Output path for pokemon->team index CSV. " + "Default: /tmp/team_construction/gen1ou_pokemon_team_index.csv" + ), + ) + parser.add_argument( + "--moveset-output", + type=Path, + default=Path("/tmp/team_construction/gen1ou_pokemon_moveset_index.csv"), + help=( + "Output path for pokemon->moveset index CSV. " + "Default: /tmp/team_construction/gen1ou_pokemon_moveset_index.csv" + ), + ) + parser.add_argument( + "--skip-team-index", + action="store_true", + help="Do not write the pokemon->team index CSV.", + ) + parser.add_argument( + "--skip-moveset-index", + action="store_true", + help="Do not write the pokemon->moveset index CSV.", + ) + parser.add_argument( + "--format", + type=str, + default=None, + help="Optional fallback format name (e.g., gen1ou) if not inferable from filename.", + ) + + args = parser.parse_args() + if args.skip_team_index and args.skip_moveset_index: + raise ValueError("Both outputs were skipped. Enable at least one output CSV.") + + pokemon_to_teams, pokemon_to_movesets = parse_teams( + args.team_dir, fallback_format=args.format + ) + + if not args.skip_team_index: + write_team_index_csv(pokemon_to_teams, args.team_output) + print( + f"Wrote {len(pokemon_to_teams)} Pokemon rows to {args.team_output} from {args.team_dir}" + ) + + if not args.skip_moveset_index: + write_moveset_index_csv(pokemon_to_movesets, args.moveset_output) + print( + "Wrote moveset index rows to " f"{args.moveset_output} from {args.team_dir}" + ) + + +if __name__ == "__main__": + main() diff --git a/metamon/backend/team_construction/teams/retrieval.py b/metamon/backend/team_construction/teams/retrieval.py new file mode 100644 index 0000000000..fe52a7a305 --- /dev/null +++ b/metamon/backend/team_construction/teams/retrieval.py @@ -0,0 +1,312 @@ +import argparse +import csv +import itertools +import re +import sys +from pathlib import Path +from typing import Dict, Iterable, List, Optional, Sequence, Set, Tuple + +TEAM_FILE_RE = re.compile(r"^(team_\d+)\.(gen[0-9][a-z0-9]*)_team$", re.IGNORECASE) + + +def normalize_name(name: str) -> str: + return name.strip().lower() + + +def normalize_move(move: str) -> str: + return move.strip().lower() + + +def load_index(csv_path: Path) -> Tuple[Dict[str, Set[str]], Dict[str, str]]: + """Load pokemon -> team_ids mapping from CSV. + + Returns: + teams_by_name: normalized pokemon name -> team id set + canonical_name: normalized pokemon name -> original cased pokemon name + """ + teams_by_name: Dict[str, Set[str]] = {} + canonical_name: Dict[str, str] = {} + + with csv_path.open("r", newline="", encoding="utf-8") as f: + reader = csv.DictReader(f) + required_cols = {"pokemon_name", "team_ids"} + if not required_cols.issubset(reader.fieldnames or set()): + raise ValueError( + f"CSV {csv_path} must have columns: {sorted(required_cols)}" + ) + + for row in reader: + raw_name = (row.get("pokemon_name") or "").strip() + key = normalize_name(raw_name) + if not key: + continue + + raw_team_ids = (row.get("team_ids") or "").strip() + team_ids = {t.strip() for t in raw_team_ids.split(",") if t.strip()} + teams_by_name[key] = team_ids + canonical_name[key] = raw_name + + if not teams_by_name: + raise ValueError(f"CSV {csv_path} contained no usable pokemon rows.") + return teams_by_name, canonical_name + + +def load_moveset_index( + csv_path: Path, +) -> Dict[str, Dict[str, List[Set[str]]]]: + """Load moveset index as team_id -> pokemon_name -> list[move-set].""" + moves_by_team: Dict[str, Dict[str, List[Set[str]]]] = {} + with csv_path.open("r", newline="", encoding="utf-8") as f: + reader = csv.DictReader(f) + required_cols = {"pokemon_name", "moveset", "team_ids"} + if not required_cols.issubset(reader.fieldnames or set()): + raise ValueError( + f"CSV {csv_path} must have columns: {sorted(required_cols)}" + ) + + for row in reader: + raw_name = (row.get("pokemon_name") or "").strip() + name_key = normalize_name(raw_name) + if not name_key: + continue + + raw_moveset = (row.get("moveset") or "").strip() + move_set = { + normalize_move(m) for m in raw_moveset.split("/") if normalize_move(m) + } + + raw_team_ids = (row.get("team_ids") or "").strip() + team_ids = [t.strip() for t in raw_team_ids.split(",") if t.strip()] + for team_id in team_ids: + by_pokemon = moves_by_team.setdefault(team_id, {}) + by_pokemon.setdefault(name_key, []).append(move_set) + + return moves_by_team + + +def build_team_file_lookup(team_dir: Path) -> Dict[str, Path]: + """Map team IDs (e.g. '0001') to concrete team files under team_dir.""" + lookup: Dict[str, Path] = {} + for path in sorted(team_dir.rglob("*.gen*_team")): + match = TEAM_FILE_RE.match(path.name) + if not match: + continue + team_id = match.group(1).split("_", 1)[1] + lookup[team_id] = path + return lookup + + +def dedupe_names(names: Sequence[str]) -> List[str]: + normalized = [normalize_name(n) for n in names if normalize_name(n)] + seen: Set[str] = set() + unique_names: List[str] = [] + for n in normalized: + if n not in seen: + unique_names.append(n) + seen.add(n) + return unique_names + + +def parse_moveset_specs(specs: Sequence[str]) -> Dict[str, Set[str]]: + """Parse repeated --moveset-spec values. + + Format: "PokemonName:Move1|Move2|Move3" + """ + out: Dict[str, Set[str]] = {} + for spec in specs: + if ":" not in spec: + raise ValueError( + f"Invalid --moveset-spec '{spec}'. Expected format: Pokemon:Move1|Move2" + ) + raw_name, raw_moves = spec.split(":", 1) + name_key = normalize_name(raw_name) + if not name_key: + raise ValueError(f"Invalid --moveset-spec '{spec}': empty Pokemon name.") + moves = {normalize_move(m) for m in raw_moves.split("|") if normalize_move(m)} + if not moves: + raise ValueError( + f"Invalid --moveset-spec '{spec}': provide at least one move." + ) + out[name_key] = moves + return out + + +def moves_matched_ratio( + team_id: str, + moveset_requests: Dict[str, Set[str]], + moves_by_team: Dict[str, Dict[str, List[Set[str]]]], +) -> float: + if not moveset_requests: + return 0.0 + + team_moves = moves_by_team.get(team_id, {}) + matched = 0 + total = 0 + for pokemon_name, wanted_moves in moveset_requests.items(): + total += len(wanted_moves) + moveset_options = team_moves.get(pokemon_name, []) + if not moveset_options: + continue + best_overlap = 0 + for option in moveset_options: + best_overlap = max(best_overlap, len(wanted_moves & option)) + matched += best_overlap + if total == 0: + return 0.0 + return matched / total + + +def best_match_by_names_then_moves( + names: Sequence[str], + teams_by_name: Dict[str, Set[str]], + moveset_requests: Dict[str, Set[str]], + moves_by_team: Dict[str, Dict[str, List[Set[str]]]], +) -> Tuple[Optional[str], List[str], int, float]: + """Return best team using (name match count, moves matched ratio) ranking.""" + unique_names = dedupe_names(names) + if not unique_names: + return None, [], 0, 0.0 + available = [n for n in unique_names if n in teams_by_name and teams_by_name[n]] + if not available: + return None, [], 0, 0.0 + + for k in range(len(available), 0, -1): + best_choice: Optional[Tuple[str, Tuple[str, ...], float]] = None + for combo in itertools.combinations(available, k): + intersection = set(teams_by_name[combo[0]]) + for n in combo[1:]: + intersection &= teams_by_name[n] + if not intersection: + break + if not intersection: + continue + for team_id in sorted(intersection): + ratio = moves_matched_ratio(team_id, moveset_requests, moves_by_team) + if best_choice is None: + best_choice = (team_id, combo, ratio) + continue + _, _, best_ratio = best_choice + if ratio > best_ratio: + best_choice = (team_id, combo, ratio) + if best_choice is not None: + team_id, combo, ratio = best_choice + return team_id, list(combo), k, ratio + + return None, [], 0, 0.0 + + +def retrieve_team( + pokemon_names: Sequence[str], + csv_path: Path, + team_dir: Path, + moveset_csv_path: Optional[Path] = None, + moveset_specs: Optional[Sequence[str]] = None, +) -> Tuple[Path, str, List[str], int, float]: + teams_by_name, canonical_name = load_index(csv_path) + moveset_requests = parse_moveset_specs(moveset_specs or []) + moves_by_team: Dict[str, Dict[str, List[Set[str]]]] = {} + if moveset_requests: + if moveset_csv_path is None: + raise ValueError( + "Moveset specs were provided but --moveset-csv is missing." + ) + moves_by_team = load_moveset_index(moveset_csv_path) + + team_file_lookup = build_team_file_lookup(team_dir) + if not team_file_lookup: + raise FileNotFoundError(f"No team files found under: {team_dir}") + + team_id, matched_norm_names, match_size, moves_ratio = ( + best_match_by_names_then_moves( + pokemon_names, teams_by_name, moveset_requests, moves_by_team + ) + ) + if team_id is None: + raise ValueError("Could not match any provided pokemon names to indexed teams.") + + team_file = team_file_lookup.get(team_id) + if team_file is None: + raise FileNotFoundError( + f"Matched team id {team_id}, but no corresponding file was found in {team_dir}" + ) + + team_text = team_file.read_text(encoding="utf-8", errors="replace") + matched_names = [canonical_name.get(n, n) for n in matched_norm_names] + return team_file, team_text, matched_names, match_size, moves_ratio + + +def main() -> None: + parser = argparse.ArgumentParser( + description=( + "Retrieve a preconstructed team matching up to six pokemon names. " + "If no 6-way match exists, automatically falls back to 5, 4, 3, etc." + ) + ) + parser.add_argument( + "pokemon_names", + nargs="+", + help="Pokemon names to match (usually 6 names).", + ) + parser.add_argument( + "--csv", + type=Path, + default=Path("/tmp/team_construction/gen1ou_pokemon_team_index.csv"), + help=( + "Path to pokemon index CSV. Default: /tmp/team_construction/gen1ou_pokemon_team_index.csv" + ), + ) + parser.add_argument( + "--team-dir", + type=Path, + required=True, + help="Directory containing team files (*.gen*_team).", + ) + parser.add_argument( + "--moveset-csv", + type=Path, + default=Path("/tmp/team_construction/gen1ou_pokemon_moveset_index.csv"), + help=( + "Path to pokemon moveset index CSV. " + "Used only when --moveset-spec is provided." + ), + ) + parser.add_argument( + "--moveset-spec", + action="append", + default=[], + help=( + "Optional move constraints. Repeat as needed. " + "Format: Pokemon:Move1|Move2|Move3" + ), + ) + parser.add_argument( + "--show-team", + action="store_true", + help="Print full team content after selection.", + ) + + args = parser.parse_args() + + try: + team_file, team_text, matched_names, match_size, moves_ratio = retrieve_team( + pokemon_names=args.pokemon_names, + csv_path=args.csv, + team_dir=args.team_dir, + moveset_csv_path=args.moveset_csv, + moveset_specs=args.moveset_spec, + ) + except Exception as exc: + print(f"Error: {exc}", file=sys.stderr) + sys.exit(1) + + print(f"Selected team file: {team_file}") + print(f"Matched {match_size} pokemon: {', '.join(matched_names)}") + if args.moveset_spec: + print(f"Moves matched: {moves_ratio * 100:.1f}%") + if args.show_team: + print("\n--- TEAM ---") + print(team_text.rstrip()) + + +if __name__ == "__main__": + main() diff --git a/metamon/backend/team_construction/train.py b/metamon/backend/team_construction/train.py new file mode 100644 index 0000000000..e502411a16 --- /dev/null +++ b/metamon/backend/team_construction/train.py @@ -0,0 +1,1020 @@ +import argparse +import concurrent.futures +import csv +import itertools +import json +import os +import random +import re +import shutil +import sys +import time +from pathlib import Path +from typing import Dict, List, Sequence + +from metamon.backend.team_construction.matchup import run_matchup +from metamon.backend.team_construction.teams.parse import ( + parse_teams, + parse_team_file, + team_id_from_filename, + write_moveset_index_csv, + write_team_index_csv, +) +from metamon.backend.team_construction.update import ( + ensure_feature_bandit_state, + feature_posterior_mean, + load_pokemon_pool, + load_state, + posterior_mean, + save_state, + thompson_sample, + thompson_score_team, + update_from_feature_match, + update_from_match, +) + +TEAM_FILE_RE = re.compile(r"^team_(\d+)\.(gen[0-9][a-z0-9]*)_team$", re.IGNORECASE) + + +def _init_custom_teamset_dir( + cache_dir: Path, set_name: str, battle_format: str +) -> Path: + target_dir = cache_dir / "teams" / set_name / battle_format + target_dir.mkdir(parents=True, exist_ok=True) + for old in target_dir.glob(f"*.{battle_format}_team"): + old.unlink() + return target_dir + + +def _set_custom_team( + team_dir: Path, battle_format: str, source_team_file: Path +) -> None: + shutil.copyfile(source_team_file, team_dir / f"team_0001.{battle_format}_team") + + +def _resolve_team_source_dir( + explicit_source_dir: Path | None, + cache_dir: Path | None, + team_set: str, + battle_format: str, +) -> Path: + if explicit_source_dir is not None: + return explicit_source_dir + if cache_dir is None: + raise ValueError( + "Provide --team-source-dir or set METAMON_CACHE_DIR to infer team source." + ) + return cache_dir / "teams" / team_set / battle_format + + +def _team_features_from_file( + team_file: Path, + team_feature_cache: Dict[str, tuple[List[str], List[str]]], +) -> tuple[List[str], List[str]]: + key = str(team_file) + if key in team_feature_cache: + return team_feature_cache[key] + parsed = parse_team_file(team_file) + names = [name for name, _ in parsed if name] + moves: List[str] = [] + for _, move_tuple in parsed: + moves.extend([m for m in move_tuple if m]) + team_feature_cache[key] = (names, moves) + return names, moves + + +def _team_id_for_path(path: Path, source_team_dir: Path) -> str: + base = team_id_from_filename(path) + try: + rel = str(path.relative_to(source_team_dir)) + except ValueError: + rel = path.name + return f"{base}:{rel}" + + +def _build_team_pool( + source_team_dir: Path, + battle_format: str, + team_feature_cache: Dict[str, tuple[List[str], List[str]]], +) -> List[dict]: + team_files = sorted(source_team_dir.rglob(f"*.{battle_format}_team")) + if not team_files: + raise FileNotFoundError( + f"No .{battle_format}_team files found under {source_team_dir}" + ) + + pool: List[dict] = [] + for team_file in team_files: + pokemon, moves = _team_features_from_file(team_file, team_feature_cache) + if not pokemon: + continue + pool.append( + { + "team_id": _team_id_for_path(team_file, source_team_dir), + "team_file": team_file, + "pokemon": pokemon, + "moves": moves, + } + ) + if not pool: + raise RuntimeError(f"No parsable teams found in {source_team_dir}") + return pool + + +def _select_candidate_team( + *, + pool: List[dict], + state: dict, + epsilon: float, + rng: random.Random, + candidate_pool_size: int, + weight_team: float, + weight_pokemon: float, + weight_moves: float, +) -> tuple[dict, str]: + if not pool: + raise ValueError("Team pool is empty.") + + if rng.random() < epsilon: + return rng.choice(pool), "explore_random" + + candidates = pool + if 0 < candidate_pool_size < len(pool): + candidates = rng.sample(pool, k=candidate_pool_size) + + best_team = candidates[0] + best_score = thompson_score_team( + state, + team_id=best_team["team_id"], + pokemon_names=best_team["pokemon"], + moves=best_team["moves"], + rng=rng, + weight_team=weight_team, + weight_pokemon=weight_pokemon, + weight_moves=weight_moves, + ) + for candidate in candidates[1:]: + score = thompson_score_team( + state, + team_id=candidate["team_id"], + pokemon_names=candidate["pokemon"], + moves=candidate["moves"], + rng=rng, + weight_team=weight_team, + weight_pokemon=weight_pokemon, + weight_moves=weight_moves, + ) + if score > best_score: + best_score = score + best_team = candidate + return best_team, "exploit_thompson" + + +def _winner_from_result(result: str) -> str: + r = result.upper().strip() + if r == "WIN": + return "a" + if r == "LOSS": + return "b" + return "draw" + + +def _parse_gpu_list(raw: str) -> List[int]: + out = [int(x.strip()) for x in raw.split(",") if x.strip()] + if not out: + raise ValueError("GPU list cannot be empty") + return out + + +def _hardlink_or_copy(src: Path, dst: Path) -> None: + if dst.exists(): + return + try: + os.link(src, dst) + except OSError: + shutil.copyfile(src, dst) + + +def _mirror_teamset_to_local_cache( + source_team_dir: Path, + local_cache_dir: Path, + team_set_name: str, + battle_format: str, +) -> Path: + dst_dir = local_cache_dir / "teams" / team_set_name / battle_format + dst_dir.mkdir(parents=True, exist_ok=True) + for src in sorted(source_team_dir.rglob(f"*.{battle_format}_team")): + _hardlink_or_copy(src, dst_dir / src.name) + return dst_dir + + +def _load_team_index_mapping(index_csv: Path) -> Dict[str, set[str]]: + mapping: Dict[str, set[str]] = {} + with index_csv.open("r", newline="", encoding="utf-8") as f: + reader = csv.DictReader(f) + for row in reader: + name = (row.get("pokemon_name") or "").strip().lower() + if not name: + continue + ids = { + x.strip() for x in (row.get("team_ids") or "").split(",") if x.strip() + } + if ids: + mapping[name] = ids + return mapping + + +def _build_team_file_lookup(team_dir: Path) -> Dict[str, Path]: + lookup: Dict[str, Path] = {} + for path in sorted(team_dir.rglob("*.gen*_team")): + match = TEAM_FILE_RE.match(path.name) + if match: + lookup[match.group(1)] = path + return lookup + + +def _retrieve_team_fast( + candidate_names: Sequence[str], + team_ids_by_name: Dict[str, set[str]], + team_file_by_id: Dict[str, Path], +) -> tuple[Path, List[str], int]: + seen = set() + ordered: List[str] = [] + for name in candidate_names: + key = name.strip().lower() + if key and key not in seen: + ordered.append(key) + seen.add(key) + available = [n for n in ordered if n in team_ids_by_name and team_ids_by_name[n]] + if not available: + raise ValueError("No available names found in team index mapping.") + + for k in range(len(available), 0, -1): + for combo in itertools.combinations(available, k): + intersection = set(team_ids_by_name[combo[0]]) + for name in combo[1:]: + intersection &= team_ids_by_name[name] + if not intersection: + break + if not intersection: + continue + for team_id in sorted(intersection): + path = team_file_by_id.get(team_id) + if path is not None: + return path, list(combo), k + raise ValueError("Could not retrieve a matching team from candidate names.") + + +def _team_names_from_file(team_file: Path, cache: Dict[str, List[str]]) -> List[str]: + key = str(team_file) + if key in cache: + return cache[key] + content = team_file.read_text(encoding="utf-8", errors="replace") + blocks = [block for block in content.split("\n\n") if block.strip()] + names: List[str] = [] + for block in blocks: + header = block.splitlines()[0].strip() + if " @ " in header: + header = header.split(" @ ", 1)[0].strip() + if header.endswith(")") and "(" in header: + header = header.rsplit("(", 1)[1].rstrip(")").strip() + names.append(header) + deduped: List[str] = [] + seen = set() + for name in names: + if name and name not in seen: + deduped.append(name) + seen.add(name) + cache[key] = deduped + return deduped + + +def _select_candidate_names( + *, + pool: List[str], + state: dict, + team_size: int, + epsilon: float, + rng: random.Random, +) -> tuple[List[str], str]: + if len(pool) < team_size: + raise ValueError(f"Need at least {team_size} pokemon in pool, got {len(pool)}") + if rng.random() < epsilon: + return rng.sample(pool, k=team_size), "explore_random" + scored = [(name, thompson_sample(state, name, rng)) for name in pool] + scored.sort(key=lambda item: item[1], reverse=True) + return [name for name, _ in scored[:team_size]], "exploit_thompson" + + +def _run_matchup_with_retry( + *, + battle_format: str, + num_battles: int, + model_name: str, + team_set_a: str, + team_set_b: str, + gpu_a: int, + gpu_b: int, + work_dir: Path, + checkpoint: int | None, + print_match_stats: bool, + max_retries: int, + retry_sleep_sec: float, +) -> Dict[str, object]: + last_error = "" + for attempt in range(max_retries + 1): + try: + return run_matchup( + battle_format=battle_format, + num_battles=num_battles, + model_name=model_name, + team_set_a=team_set_a, + team_set_b=team_set_b, + gpu_a=gpu_a, + gpu_b=gpu_b, + work_dir=work_dir, + checkpoint=checkpoint, + print_match_stats=print_match_stats, + ) + except RuntimeError as exc: + last_error = str(exc) + if attempt >= max_retries: + break + time.sleep(retry_sleep_sec) + raise RuntimeError( + f"run_matchup failed for gpu pair ({gpu_a},{gpu_b}) after retries: {last_error}" + ) + + +def _run_single_mode(args: argparse.Namespace) -> None: + rng = random.Random(args.seed) + + if args.reset_tmp and args.data_root.exists(): + print(f"[setup] deleting {args.data_root}") + shutil.rmtree(args.data_root) + args.data_root.mkdir(parents=True, exist_ok=True) + + cache_dir_env = os.environ.get("METAMON_CACHE_DIR") + cache_dir = Path(cache_dir_env) if cache_dir_env else None + source_team_dir = _resolve_team_source_dir( + explicit_source_dir=args.team_source_dir, + cache_dir=cache_dir, + team_set=args.opponent_team_set, + battle_format=args.battle_format, + ) + print(f"[setup] source teams: {source_team_dir}") + + team_index_csv = args.data_root / f"{args.battle_format}_pokemon_team_index.csv" + moveset_index_csv = ( + args.data_root / f"{args.battle_format}_pokemon_moveset_index.csv" + ) + state_path = args.data_root / "name_bandit_state.json" + history_path = args.data_root / "match_history.jsonl" + batch_history_path = args.data_root / "batch_history.jsonl" + matchup_work_dir = args.data_root / "team_construction_battles" + + if args.reset_tmp or not (team_index_csv.exists() and moveset_index_csv.exists()): + print("[setup] parsing teams -> csv indexes") + pokemon_to_teams, pokemon_to_movesets = parse_teams( + source_team_dir, fallback_format=None + ) + write_team_index_csv(pokemon_to_teams, team_index_csv) + write_moveset_index_csv(pokemon_to_movesets, moveset_index_csv) + else: + print("[setup] using cached csv indexes") + + team_feature_cache: Dict[str, tuple[List[str], List[str]]] = {} + team_pool = _build_team_pool( + source_team_dir=source_team_dir, + battle_format=args.battle_format, + team_feature_cache=team_feature_cache, + ) + print(f"[setup] parsed team pool size={len(team_pool)}") + + state = load_state(state_path) + state = ensure_feature_bandit_state(state if not args.reset_tmp else None) + save_state(state_path, state) + + wandb_run = None + if args.log_wandb: + try: + import wandb + except ImportError as exc: + raise ImportError( + "wandb is not installed. Install it or run without --log-wandb." + ) from exc + wandb_run = wandb.init( + project=args.wandb_project, + entity=args.wandb_entity, + name=args.wandb_run_name, + config={ + "execution_mode": "single", + "battle_format": args.battle_format, + "opponent_team_set": args.opponent_team_set, + "learner_team_set": args.learner_team_set, + "model_name": args.model_name, + "checkpoint": args.checkpoint, + "num_batches": args.num_batches, + "batch_size": args.batch_size, + "thompson_candidate_pool_size": args.thompson_candidate_pool_size, + "weight_team": args.weight_team, + "weight_pokemon": args.weight_pokemon, + "weight_moves": args.weight_moves, + "epsilon_start": args.epsilon_start, + "epsilon_end": args.epsilon_end, + "seed": args.seed, + }, + ) + wandb.define_metric("batch") + wandb.define_metric("*", step_metric="batch") + print( + f"[wandb] enabled project={args.wandb_project} " + f"entity={args.wandb_entity or ''}" + ) + + if cache_dir is None: + raise ValueError("METAMON_CACHE_DIR must be set for learner team-set writes.") + + learner_team_dir = _init_custom_teamset_dir( + cache_dir=cache_dir, + set_name=args.learner_team_set, + battle_format=args.battle_format, + ) + team_name_cache: Dict[str, tuple[List[str], List[str]]] = {} + cumulative_wins = 0.0 + cumulative_games = 0 + + batch = 0 + while True: + batch += 1 + if args.num_batches is None: + epsilon = args.epsilon_start + else: + t = (batch - 1) / max(1, args.num_batches - 1) + epsilon = args.epsilon_start + t * (args.epsilon_end - args.epsilon_start) + epsilon = max(0.0, min(1.0, epsilon)) + + print(f"[batch {batch:03d}] epsilon={epsilon:.3f}") + batch_score = 0.0 + batch_games = 0 + batch_explore = 0 + batch_modes: List[str] = [] + + for match_idx in range(1, args.batch_size + 1): + selected_team, selection_mode = _select_candidate_team( + pool=team_pool, + state=state, + epsilon=epsilon, + rng=rng, + candidate_pool_size=args.thompson_candidate_pool_size, + weight_team=args.weight_team, + weight_pokemon=args.weight_pokemon, + weight_moves=args.weight_moves, + ) + batch_modes.append(selection_mode) + if selection_mode.startswith("explore"): + batch_explore += 1 + + team_file = selected_team["team_file"] + team_a_id = selected_team["team_id"] + team_a_names = selected_team["pokemon"] + team_a_moves = selected_team["moves"] + _set_custom_team( + team_dir=learner_team_dir, + battle_format=args.battle_format, + source_team_file=team_file, + ) + print( + f"[batch {batch:03d} match {match_idx:02d}] mode={selection_mode} " + f"team={team_file.name} team_id={team_a_id}" + ) + + result = None + for attempt in range(args.matchup_max_retries + 1): + try: + result = run_matchup( + battle_format=args.battle_format, + num_battles=1, + model_name=args.model_name, + team_set_a=args.learner_team_set, + team_set_b=args.opponent_team_set, + gpu_a=args.gpu_a, + gpu_b=args.gpu_b, + work_dir=matchup_work_dir, + checkpoint=args.checkpoint, + print_match_stats=args.print_match_stats, + ) + break + except RuntimeError as exc: + if attempt >= args.matchup_max_retries: + print( + f"[batch {batch:03d} match {match_idx:02d}] " + f"failed after retries: {exc}" + ) + else: + print( + f"[batch {batch:03d} match {match_idx:02d}] " + f"worker failure, retry {attempt + 1}/{args.matchup_max_retries}" + ) + time.sleep(args.matchup_retry_sleep_sec) + if result is None: + continue + + acceptor_matches = result["acceptor_matches"] + challenger_matches = result["challenger_matches"] + if not acceptor_matches or not challenger_matches: + continue + + a_match = acceptor_matches[0] + b_match = challenger_matches[0] + team_b_path = b_match.get("team_file_path", "") + team_b_names: List[str] = [] + team_b_moves: List[str] = [] + team_b_id = "" + if team_b_path: + team_b_file = Path(team_b_path) + team_b_names, team_b_moves = _team_features_from_file( + team_b_file, team_name_cache + ) + team_b_id = _team_id_for_path(team_b_file, source_team_dir) + + winner = _winner_from_result(a_match["result"]) + score = 1.0 if winner == "a" else 0.5 if winner == "draw" else 0.0 + batch_score += score + batch_games += 1 + cumulative_wins += score + cumulative_games += 1 + + if team_b_names and team_b_id: + update_from_feature_match( + state, + team_a_id=team_a_id, + team_a_pokemon=team_a_names, + team_a_moves=team_a_moves, + team_b_id=team_b_id, + team_b_pokemon=team_b_names, + team_b_moves=team_b_moves, + winner=winner, + ) + history_path.parent.mkdir(parents=True, exist_ok=True) + with history_path.open("a", encoding="utf-8") as f: + f.write( + json.dumps( + { + "batch": batch, + "match_idx": match_idx, + "winner": winner, + "team_a_id": team_a_id, + "team_a": team_a_names, + "team_a_move_count": len(team_a_moves), + "team_b_id": team_b_id, + "team_b": team_b_names, + "team_b_move_count": len(team_b_moves), + "battle_id": a_match.get("battle_id", ""), + "selection_mode": selection_mode, + } + ) + + "\n" + ) + + if wandb_run is not None: + wandb_run.log( + { + "batch": batch, + "match_idx_in_batch": match_idx, + "match_win_score": score, + "match_selection_explore": ( + 1 if selection_mode.startswith("explore") else 0 + ), + "match_team_a_move_count": len(team_a_moves), + } + ) + + save_state(state_path, state) + + batch_wr = batch_score / max(1, batch_games) + cumulative_wr = cumulative_wins / max(1, cumulative_games) + top_names = sorted( + state["pokemon"].keys(), + key=lambda name: feature_posterior_mean(state["pokemon"], name), + reverse=True, + )[:6] + top_moves = sorted( + state["moves"].keys(), + key=lambda move: feature_posterior_mean(state["moves"], move), + reverse=True, + )[:10] + print( + f"[batch {batch:03d}] batch_wr={batch_wr:.3f} cum_wr={cumulative_wr:.3f} " + f"games={batch_games}/{args.batch_size} " + f"explore_rate={batch_explore / max(1, args.batch_size):.3f} " + f"top_pokemon={top_names}" + ) + + with batch_history_path.open("a", encoding="utf-8") as f: + f.write( + json.dumps( + { + "batch": batch, + "batch_win_rate": batch_wr, + "cumulative_win_rate": cumulative_wr, + "epsilon": epsilon, + "batch_games": batch_games, + "target_games": args.batch_size, + "explore_count": batch_explore, + "explore_rate": batch_explore / max(1, args.batch_size), + "top_pokemon": top_names, + "top_moves": top_moves, + "modes": batch_modes, + } + ) + + "\n" + ) + + if wandb_run is not None: + wandb_run.log( + { + "batch": batch, + "batch_win_rate": batch_wr, + "cumulative_win_rate": cumulative_wr, + "epsilon": epsilon, + "batch_games": batch_games, + "explore_rate": batch_explore / max(1, args.batch_size), + "top_pokemon": ", ".join(top_names), + "top_moves": ", ".join(top_moves), + } + ) + + if args.num_batches is not None and batch >= args.num_batches: + break + + if wandb_run is not None: + wandb_run.finish() + print(f"[done] state={state_path} batch_history={batch_history_path}") + + +def _run_multi_mode(args: argparse.Namespace) -> None: + if args.team_source_dir is None: + raise ValueError("--team-source-dir is required when --execution-mode multi") + + num_batches = args.num_batches if args.num_batches is not None else 100 + acceptor_gpus = _parse_gpu_list(args.acceptor_gpus) + challenger_gpus = _parse_gpu_list(args.challenger_gpus) + if len(acceptor_gpus) != len(challenger_gpus): + raise ValueError("acceptor-gpus and challenger-gpus must have the same length") + gpu_pairs = list(zip(acceptor_gpus, challenger_gpus)) + + rng = random.Random(args.seed) + if args.reset_tmp and args.data_root.exists(): + shutil.rmtree(args.data_root) + args.data_root.mkdir(parents=True, exist_ok=True) + + source_team_dir = args.team_source_dir + + local_cache_dir = args.data_root / "metamon_cache" + os.environ["METAMON_CACHE_DIR"] = str(local_cache_dir) + _mirror_teamset_to_local_cache( + source_team_dir=source_team_dir, + local_cache_dir=local_cache_dir, + team_set_name=args.opponent_team_set, + battle_format=args.battle_format, + ) + + team_index_csv = args.data_root / f"{args.battle_format}_pokemon_team_index.csv" + moveset_index_csv = ( + args.data_root / f"{args.battle_format}_pokemon_moveset_index.csv" + ) + state_path = args.data_root / "name_bandit_state.json" + history_path = args.data_root / "match_history.jsonl" + batch_history_path = args.data_root / "batch_history.jsonl" + matchup_work_dir = args.data_root / "team_construction_battles" + + if args.reset_tmp or not (team_index_csv.exists() and moveset_index_csv.exists()): + pokemon_to_teams, pokemon_to_movesets = parse_teams( + source_team_dir, fallback_format=None + ) + write_team_index_csv(pokemon_to_teams, team_index_csv) + write_moveset_index_csv(pokemon_to_movesets, moveset_index_csv) + + pool, _ = load_pokemon_pool(team_index_csv) + team_ids_by_name = _load_team_index_mapping(team_index_csv) + team_file_by_id = _build_team_file_lookup(source_team_dir) + if not team_file_by_id: + raise ValueError(f"No team files found in {source_team_dir}") + + state = load_state(state_path) + if args.reset_tmp: + state = { + "version": 1, + "metric": "beta_bernoulli_name_bandit", + "matches": 0, + "pokemon": {}, + } + save_state(state_path, state) + + learner_team_dir = _init_custom_teamset_dir( + cache_dir=local_cache_dir, + set_name=args.learner_team_set, + battle_format=args.battle_format, + ) + team_name_cache: Dict[str, List[str]] = {} + cumulative_wins = 0.0 + cumulative_games = 0 + + wandb_run = None + if args.log_wandb: + try: + import wandb + except ImportError as exc: + raise ImportError( + "wandb is not installed. Install it or run without --log-wandb." + ) from exc + + wandb_run = wandb.init( + project=args.wandb_project, + entity=args.wandb_entity, + name=args.wandb_run_name, + config={ + "execution_mode": "multi", + "battle_format": args.battle_format, + "num_batches": num_batches, + "batch_size_per_gpu": args.batch_size_per_gpu, + "gpu_pairs": gpu_pairs, + "epsilon_start": args.epsilon_start, + "epsilon_end": args.epsilon_end, + }, + ) + wandb.define_metric("batch") + wandb.define_metric("*", step_metric="batch") + + for batch in range(1, num_batches + 1): + t = (batch - 1) / max(1, num_batches - 1) + epsilon = args.epsilon_start + t * (args.epsilon_end - args.epsilon_start) + epsilon = max(0.0, min(1.0, epsilon)) + + candidate_names, selection_mode = _select_candidate_names( + pool=pool, + state=state, + team_size=args.team_size, + epsilon=epsilon, + rng=rng, + ) + team_file, matched_norm_names, match_size = _retrieve_team_fast( + candidate_names=candidate_names, + team_ids_by_name=team_ids_by_name, + team_file_by_id=team_file_by_id, + ) + team_a_names = _team_names_from_file(team_file, team_name_cache) + _set_custom_team( + team_dir=learner_team_dir, + battle_format=args.battle_format, + source_team_file=team_file, + ) + + futures = [] + with concurrent.futures.ThreadPoolExecutor( + max_workers=len(gpu_pairs) + ) as executor: + for gpu_a, gpu_b in gpu_pairs: + futures.append( + executor.submit( + _run_matchup_with_retry, + battle_format=args.battle_format, + num_battles=args.batch_size_per_gpu, + model_name=args.model_name, + team_set_a=args.learner_team_set, + team_set_b=args.opponent_team_set, + gpu_a=gpu_a, + gpu_b=gpu_b, + work_dir=matchup_work_dir, + checkpoint=args.checkpoint, + print_match_stats=args.print_match_stats, + max_retries=args.matchup_max_retries, + retry_sleep_sec=args.matchup_retry_sleep_sec, + ) + ) + results = [future.result() for future in futures] + + batch_score = 0.0 + batch_games = 0 + for result in results: + a_matches = result["acceptor_matches"] + b_matches = result["challenger_matches"] + for a_match, b_match in zip(a_matches, b_matches): + team_b_path = b_match.get("team_file_path", "") + team_b_names = ( + _team_names_from_file(Path(team_b_path), team_name_cache) + if team_b_path + else [] + ) + winner = _winner_from_result(a_match["result"]) + if team_b_names: + update_from_match(state, team_a_names, team_b_names, winner) + history_path.parent.mkdir(parents=True, exist_ok=True) + with history_path.open("a", encoding="utf-8") as f: + f.write( + json.dumps( + { + "batch": batch, + "winner": winner, + "team_a": team_a_names, + "team_b": team_b_names, + "battle_id": a_match.get("battle_id", ""), + } + ) + + "\n" + ) + + score = 1.0 if winner == "a" else 0.5 if winner == "draw" else 0.0 + batch_score += score + batch_games += 1 + + save_state(state_path, state) + batch_wr = batch_score / max(1, batch_games) + cumulative_wins += batch_score + cumulative_games += batch_games + cumulative_wr = cumulative_wins / max(1, cumulative_games) + top_names = sorted( + pool, key=lambda name: posterior_mean(state, name), reverse=True + )[:6] + + with batch_history_path.open("a", encoding="utf-8") as f: + f.write( + json.dumps( + { + "batch": batch, + "epsilon": epsilon, + "selection_mode": selection_mode, + "candidate_names": candidate_names, + "matched_names": matched_norm_names, + "team_file": str(team_file), + "batch_games": batch_games, + "batch_win_rate": batch_wr, + "cumulative_win_rate": cumulative_wr, + "top_names": top_names, + } + ) + + "\n" + ) + + if wandb_run is not None: + wandb_run.log( + { + "batch": batch, + "batch_win_rate": batch_wr, + "cumulative_win_rate": cumulative_wr, + "epsilon": epsilon, + "match_size": match_size, + "selection_explore": ( + 1 if selection_mode.startswith("explore") else 0 + ), + "top_names": ", ".join(top_names), + "chosen_team_file": team_file.name, + "batch_games": batch_games, + } + ) + + print( + f"[batch {batch:03d}] wr={batch_wr:.3f} cum={cumulative_wr:.3f} " + f"games={batch_games} mode={selection_mode} epsilon={epsilon:.3f}" + ) + + if wandb_run is not None: + wandb_run.finish() + print(f"[done] state={state_path} batch_history={batch_history_path}") + + +def main() -> None: + if len(sys.argv) > 1 and sys.argv[1] in {"pipeline", "new"}: + from metamon.backend.team_construction.cli import main as pipeline_main + + pipeline_main(sys.argv[2:]) + return + + parser = argparse.ArgumentParser( + description=( + "Team-construction trainer. Use --execution-mode single (legacy single-GPU), " + "--execution-mode multi (legacy multi-GPU), or `pipeline` for the new model-based flow." + ) + ) + + parser.add_argument( + "--execution-mode", choices=["single", "multi"], default="single" + ) + + parser.add_argument("--battle-format", default="gen1ou") + parser.add_argument("--opponent-team-set", default="competitive") + parser.add_argument("--learner-team-set", default="team_construction_learner") + parser.add_argument("--model-name", default="Kakuna") + parser.add_argument("--checkpoint", type=int, default=None) + parser.add_argument( + "--num-batches", + type=int, + default=None, + help=( + "Number of batches. Omit for perpetual training in single mode; " + "defaults to 100 in multi mode." + ), + ) + parser.add_argument("--batch-size", type=int, default=16) + parser.add_argument("--batch-size-per-gpu", type=int, default=16) + parser.add_argument("--team-size", type=int, default=6) + + parser.add_argument("--gpu-a", type=int, default=0) + parser.add_argument("--gpu-b", type=int, default=1) + parser.add_argument("--acceptor-gpus", type=str, default="0,1,2,3") + parser.add_argument("--challenger-gpus", type=str, default="4,5,6,7") + + parser.add_argument("--seed", type=int, default=0) + parser.add_argument( + "--data-root", + type=Path, + default=Path("/tmp/team_construction"), + help="Working directory for indexes/state/history/logs.", + ) + parser.add_argument( + "--team-source-dir", + type=Path, + default=None, + help="Optional source team directory (required for multi mode).", + ) + parser.add_argument( + "--reset-tmp", + action=argparse.BooleanOptionalAction, + default=True, + help="Delete --data-root before training (use --no-reset-tmp to append).", + ) + parser.add_argument( + "--epsilon-start", + type=float, + default=0.35, + help="Initial exploration probability.", + ) + parser.add_argument( + "--epsilon-end", + type=float, + default=0.05, + help="Final exploration probability at last batch.", + ) + parser.add_argument( + "--thompson-candidate-pool-size", + type=int, + default=2048, + help=( + "Max teams to score per exploit step in single mode (sampled from full pool). " + "Use <=0 to score all teams." + ), + ) + parser.add_argument( + "--weight-team", + type=float, + default=0.35, + help="Weight for team-identity Thompson prior (single mode).", + ) + parser.add_argument( + "--weight-pokemon", + type=float, + default=0.40, + help="Weight for Pokemon-level Thompson prior (single mode).", + ) + parser.add_argument( + "--weight-moves", + type=float, + default=0.25, + help="Weight for move-level Thompson prior (single mode).", + ) + parser.add_argument( + "--print-match-stats", + action="store_true", + help="Print per-match stats from matchup.py.", + ) + + parser.add_argument("--log-wandb", action="store_true") + parser.add_argument("--wandb-project", default="team_construction") + parser.add_argument( + "--wandb-entity", + default=os.environ.get("METAMON_WANDB_ENTITY", None), + ) + parser.add_argument("--wandb-run-name", default=None) + parser.add_argument( + "--matchup-max-retries", + type=int, + default=3, + help="Retry count when matchup workers fail.", + ) + parser.add_argument( + "--matchup-retry-sleep-sec", + type=float, + default=5.0, + help="Sleep duration between matchup retries.", + ) + args = parser.parse_args() + + if args.execution_mode == "single": + _run_single_mode(args) + else: + _run_multi_mode(args) + + +if __name__ == "__main__": + main() diff --git a/metamon/backend/team_construction/update.py b/metamon/backend/team_construction/update.py new file mode 100644 index 0000000000..ef64809ad9 --- /dev/null +++ b/metamon/backend/team_construction/update.py @@ -0,0 +1,411 @@ +import argparse +import csv +import json +import random +from pathlib import Path +from typing import Dict, List, Tuple + + +def _norm(name: str) -> str: + return name.strip().lower() + + +def load_pokemon_pool(index_csv: Path) -> Tuple[List[str], Dict[str, str]]: + if not index_csv.exists(): + raise FileNotFoundError(f"Index CSV not found: {index_csv}") + pool: List[str] = [] + canonical: Dict[str, str] = {} + with index_csv.open("r", newline="", encoding="utf-8") as f: + reader = csv.DictReader(f) + if "pokemon_name" not in (reader.fieldnames or []): + raise ValueError(f"{index_csv} must contain a pokemon_name column") + for row in reader: + name = (row.get("pokemon_name") or "").strip() + if not name: + continue + key = _norm(name) + canonical[key] = name + pool.append(name) + if not pool: + raise ValueError(f"No pokemon names found in {index_csv}") + pool = sorted(set(pool)) + return pool, canonical + + +def canonicalize_team(team: List[str], canonical: Dict[str, str]) -> List[str]: + seen = set() + out: List[str] = [] + for p in team: + key = _norm(p) + name = canonical.get(key, p.strip()) + if name and name not in seen: + out.append(name) + seen.add(name) + return out + + +def default_state() -> dict: + return { + "version": 1, + "metric": "beta_bernoulli_name_bandit", + "matches": 0, + "pokemon": {}, + } + + +def default_feature_state() -> dict: + return { + "version": 2, + "metric": "beta_bernoulli_team_feature_bandit", + "matches": 0, + "pokemon": {}, + "moves": {}, + "teams": {}, + } + + +def load_state(path: Path) -> dict: + if not path.exists(): + return default_state() + with path.open("r", encoding="utf-8") as f: + return json.load(f) + + +def save_state(path: Path, state: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8") as f: + json.dump(state, f, indent=2, sort_keys=True) + + +def ensure_feature_bandit_state(state: dict | None) -> dict: + if not state or state.get("metric") != "beta_bernoulli_team_feature_bandit": + return default_feature_state() + state.setdefault("version", 2) + state.setdefault("matches", 0) + state.setdefault("pokemon", {}) + state.setdefault("moves", {}) + state.setdefault("teams", {}) + return state + + +def ensure_pokemon(state: dict, name: str) -> None: + if name not in state["pokemon"]: + state["pokemon"][name] = {"alpha": 1.0, "beta": 1.0, "wins": 0, "losses": 0} + + +def ensure_feature(state_bucket: dict, name: str) -> None: + if name not in state_bucket: + state_bucket[name] = {"alpha": 1.0, "beta": 1.0, "wins": 0, "losses": 0} + + +def posterior_mean(state: dict, name: str) -> float: + ensure_pokemon(state, name) + stats = state["pokemon"][name] + return stats["alpha"] / (stats["alpha"] + stats["beta"]) + + +def feature_posterior_mean(state_bucket: dict, name: str) -> float: + ensure_feature(state_bucket, name) + stats = state_bucket[name] + return stats["alpha"] / (stats["alpha"] + stats["beta"]) + + +def thompson_sample(state: dict, name: str, rng: random.Random) -> float: + ensure_pokemon(state, name) + stats = state["pokemon"][name] + return rng.betavariate(stats["alpha"], stats["beta"]) + + +def feature_thompson_sample(state_bucket: dict, name: str, rng: random.Random) -> float: + ensure_feature(state_bucket, name) + stats = state_bucket[name] + return rng.betavariate(stats["alpha"], stats["beta"]) + + +def thompson_score_team( + state: dict, + *, + team_id: str, + pokemon_names: List[str], + moves: List[str], + rng: random.Random, + weight_team: float = 0.35, + weight_pokemon: float = 0.40, + weight_moves: float = 0.25, +) -> float: + state = ensure_feature_bandit_state(state) + + pokemon_unique = sorted({p.strip() for p in pokemon_names if p.strip()}) + moves_unique = sorted({m.strip() for m in moves if m.strip()}) + + team_sample = feature_thompson_sample(state["teams"], team_id, rng) + if pokemon_unique: + p_samples = [ + feature_thompson_sample(state["pokemon"], p, rng) for p in pokemon_unique + ] + pokemon_sample = sum(p_samples) / len(p_samples) + else: + pokemon_sample = 0.5 + + if moves_unique: + m_samples = [ + feature_thompson_sample(state["moves"], m, rng) for m in moves_unique + ] + move_sample = sum(m_samples) / len(m_samples) + else: + move_sample = 0.5 + + total = weight_team + weight_pokemon + weight_moves + if total <= 0: + return team_sample + return ( + (weight_team * team_sample) + + (weight_pokemon * pokemon_sample) + + (weight_moves * move_sample) + ) / total + + +def _update_bucket_from_outcome( + bucket: dict, keys: List[str], *, won: bool, draw: bool +) -> None: + unique_keys = {k for k in keys if k} + for key in unique_keys: + ensure_feature(bucket, key) + stats = bucket[key] + if draw: + stats["alpha"] += 0.5 + stats["beta"] += 0.5 + elif won: + stats["alpha"] += 1.0 + stats["wins"] += 1 + else: + stats["beta"] += 1.0 + stats["losses"] += 1 + + +def update_from_feature_match( + state: dict, + *, + team_a_id: str, + team_a_pokemon: List[str], + team_a_moves: List[str], + team_b_id: str, + team_b_pokemon: List[str], + team_b_moves: List[str], + winner: str, +) -> None: + state = ensure_feature_bandit_state(state) + + if winner not in {"a", "b", "draw"}: + raise ValueError("winner must be one of: a, b, draw") + + draw = winner == "draw" + a_won = winner == "a" + b_won = winner == "b" + + _update_bucket_from_outcome(state["teams"], [team_a_id], won=a_won, draw=draw) + _update_bucket_from_outcome(state["teams"], [team_b_id], won=b_won, draw=draw) + + _update_bucket_from_outcome(state["pokemon"], team_a_pokemon, won=a_won, draw=draw) + _update_bucket_from_outcome(state["pokemon"], team_b_pokemon, won=b_won, draw=draw) + + _update_bucket_from_outcome(state["moves"], team_a_moves, won=a_won, draw=draw) + _update_bucket_from_outcome(state["moves"], team_b_moves, won=b_won, draw=draw) + + state["matches"] += 1 + + +def update_from_match( + state: dict, team_a: List[str], team_b: List[str], winner: str +) -> None: + a_set = set(team_a) + b_set = set(team_b) + all_names = a_set.union(b_set) + for p in all_names: + ensure_pokemon(state, p) + + if winner == "a": + winners, losers = a_set, b_set + draw = False + elif winner == "b": + winners, losers = b_set, a_set + draw = False + elif winner == "draw": + draw = True + winners, losers = set(), set() + else: + raise ValueError("winner must be one of: a, b, draw") + + if draw: + for p in all_names: + state["pokemon"][p]["alpha"] += 0.5 + state["pokemon"][p]["beta"] += 0.5 + else: + for p in winners: + state["pokemon"][p]["alpha"] += 1.0 + state["pokemon"][p]["wins"] += 1 + for p in losers: + state["pokemon"][p]["beta"] += 1.0 + state["pokemon"][p]["losses"] += 1 + + state["matches"] += 1 + + +def propose_team( + current_team: List[str], + pool: List[str], + state: dict, + replacements: int, + rng: random.Random, +) -> List[str]: + if replacements <= 0: + return list(current_team) + + replacements = min(replacements, len(current_team)) + team_samples = {p: thompson_sample(state, p, rng) for p in current_team} + to_drop = sorted(current_team, key=lambda p: team_samples[p])[:replacements] + remaining = [p for p in current_team if p not in set(to_drop)] + + candidates = [p for p in pool if p not in set(remaining)] + candidate_scores = [(p, thompson_sample(state, p, rng)) for p in candidates] + candidate_scores.sort(key=lambda x: x[1], reverse=True) + additions = [p for p, _ in candidate_scores[:replacements]] + + updated = remaining + additions + return updated[: len(current_team)] + + +def main() -> None: + parser = argparse.ArgumentParser( + description=( + "Update standalone name-bandit state from a match result and propose " + "new team names." + ) + ) + parser.add_argument( + "--team-a", + nargs="+", + required=True, + help="Team A pokemon names (typically 6).", + ) + parser.add_argument( + "--team-b", + nargs="+", + required=True, + help="Team B pokemon names (typically 6).", + ) + parser.add_argument( + "--winner", + required=True, + choices=["a", "b", "draw"], + help="Winner of the match.", + ) + parser.add_argument( + "--index-csv", + type=Path, + default=Path("/tmp/team_construction/gen1ou_pokemon_team_index.csv"), + help="Pokemon pool CSV.", + ) + parser.add_argument( + "--state-path", + type=Path, + default=Path("/tmp/team_construction/name_bandit_state.json"), + help="Path to persistent bandit state JSON.", + ) + parser.add_argument( + "--history-path", + type=Path, + default=Path("/tmp/team_construction/match_history.jsonl"), + help="Path to append-only history log.", + ) + parser.add_argument( + "--reset", + action="store_true", + help="Delete existing state/history files before applying this update.", + ) + parser.add_argument( + "--replacements-loser", + type=int, + default=1, + help="How many pokemon to replace on the losing team.", + ) + parser.add_argument( + "--replacements-winner", + type=int, + default=0, + help="How many pokemon to replace on the winning team.", + ) + parser.add_argument("--seed", type=int, default=0) + args = parser.parse_args() + + pool, canonical = load_pokemon_pool(args.index_csv) + team_a = canonicalize_team(args.team_a, canonical) + team_b = canonicalize_team(args.team_b, canonical) + if not team_a or not team_b: + raise ValueError("Both teams must contain at least one pokemon name.") + + if args.reset: + if args.state_path.exists(): + args.state_path.unlink() + if args.history_path.exists(): + args.history_path.unlink() + + state = load_state(args.state_path) + update_from_match(state, team_a=team_a, team_b=team_b, winner=args.winner) + + rng = random.Random(args.seed + state["matches"]) + if args.winner == "a": + team_a_next = propose_team( + team_a, pool, state, replacements=args.replacements_winner, rng=rng + ) + team_b_next = propose_team( + team_b, pool, state, replacements=args.replacements_loser, rng=rng + ) + elif args.winner == "b": + team_a_next = propose_team( + team_a, pool, state, replacements=args.replacements_loser, rng=rng + ) + team_b_next = propose_team( + team_b, pool, state, replacements=args.replacements_winner, rng=rng + ) + else: + team_a_next = propose_team( + team_a, pool, state, replacements=args.replacements_loser, rng=rng + ) + team_b_next = propose_team( + team_b, pool, state, replacements=args.replacements_loser, rng=rng + ) + + save_state(args.state_path, state) + args.history_path.parent.mkdir(parents=True, exist_ok=True) + with args.history_path.open("a", encoding="utf-8") as f: + f.write( + json.dumps( + { + "match_num": state["matches"], + "winner": args.winner, + "team_a": team_a, + "team_b": team_b, + "team_a_next": team_a_next, + "team_b_next": team_b_next, + } + ) + + "\n" + ) + + print(f"Updated state: {args.state_path} (matches={state['matches']})") + print(f"Team A current: {team_a}") + print(f"Team B current: {team_b}") + print(f"Winner: {args.winner}") + print(f"Team A next: {team_a_next}") + print(f"Team B next: {team_b_next}") + print("Posterior means for Team A next:") + for p in team_a_next: + print(f" {p}: {posterior_mean(state, p):.3f}") + print("Posterior means for Team B next:") + for p in team_b_next: + print(f" {p}: {posterior_mean(state, p):.3f}") + + +if __name__ == "__main__": + main() diff --git a/metamon/backend/team_prediction/.gitignore b/metamon/backend/team_prediction/.gitignore new file mode 100644 index 0000000000..c47d1dede5 --- /dev/null +++ b/metamon/backend/team_prediction/.gitignore @@ -0,0 +1,8 @@ +# Test files created during development +test_*.py + +# Local test checkpoints +test_ckpts/ + +# WandB local files +wandb/ diff --git a/metamon/backend/team_prediction/build_replay_team_sets.py b/metamon/backend/team_prediction/build_replay_team_sets.py new file mode 100644 index 0000000000..a7e608bdd2 --- /dev/null +++ b/metamon/backend/team_prediction/build_replay_team_sets.py @@ -0,0 +1,378 @@ +""" +Build replay-derived team sets from cached revealed_teams. + +Set definitions live in YAML (see team_sets_gl_hl_05_26.yaml, team_sets_may26_gen9ou.yaml). +Each set has set_type: gl (general ladder) or hl (high ladder ∪ smogtours). + +Pipeline per format: + 1. Select filenames (FilteredTeamsFromReplaysDataset or filter_elite.select_elite_filenames) + 2. Fill with NaiveUsagePredictor (+ optional post-fill revealed_score filter for hl) + 3. Write index.csv (+ predictions_meta.json) + +Example (year-window gl/hl): + python -m metamon.backend.team_prediction.build_replay_team_sets --set all + +Example (May 2026 gen9ou supplement): + python -m metamon.backend.team_prediction.build_replay_team_sets \\ + --config metamon/backend/team_prediction/team_sets_may26_gen9ou.yaml \\ + --set all --formats gen9ou --validate +""" + +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +from multiprocessing import Pool, cpu_count +from pathlib import Path +from typing import Any, Optional + +import tqdm +import yaml + +from metamon.config import METAMON_CACHE_DIR +from metamon.backend.team_prediction.dataset import ( + FilteredTeamsFromReplaysDataset, + default_revealed_teams_dir, + parse_replay_team_filename, +) +from metamon.backend.team_prediction.filter_elite import ( + select_elite_filenames, + write_team_index_csv, +) +from metamon.backend.team_prediction.predictor import NaiveUsagePredictor +from metamon.backend.team_prediction.team import TeamSet + +DEFAULT_CONFIG = Path(__file__).resolve().parent / "team_sets_gl_hl_05_26.yaml" +FORMATS = ["gen1ou", "gen2ou", "gen3ou", "gen4ou", "gen9ou"] +_FILL_PREDICTOR: NaiveUsagePredictor | None = None + + +def _default_workers() -> int: + return max(1, min(64, cpu_count() - 4)) + + +def _init_fill_worker() -> None: + global _FILL_PREDICTOR + _FILL_PREDICTOR = NaiveUsagePredictor() + + +def load_config(path: Path) -> dict[str, Any]: + with open(path) as f: + return yaml.safe_load(f) + + +def config_set_names(cfg: dict[str, Any]) -> list[str]: + return [key for key in cfg if key != "window"] + + +def resolve_set_type(set_name: str, set_cfg: dict[str, Any]) -> str: + set_type = set_cfg.get("set_type") + if set_type is not None: + return set_type + if set_name.startswith("gl"): + return "gl" + if set_name.startswith("hl"): + return "hl" + raise ValueError( + f"Set {set_name!r} needs set_type: gl|hl in config (or a gl_/hl_ prefix)" + ) + + +def resolve_min_rating(selection: dict, format_name: str) -> Optional[int]: + per_fmt = selection.get("per_format") or {} + if format_name in per_fmt and "min_rating" in per_fmt[format_name]: + return per_fmt[format_name]["min_rating"] + default = selection.get("default") or {} + return default.get("min_rating") + + +def resolve_min_revealed_score(hl_cfg: dict, format_name: str) -> Optional[float]: + scores = hl_cfg.get("min_revealed_score") or {} + if format_name in scores: + return scores[format_name] + return scores.get("default") + + +def select_gl_filenames( + revealed_dir: str, + format_name: str, + window: dict, + selection: dict, +) -> list[str]: + dataset = FilteredTeamsFromReplaysDataset( + replay_teamfile_dir=revealed_dir, + format=format_name, + min_date=window.get("min_date"), + max_date=window.get("max_date"), + min_rating=resolve_min_rating(selection, format_name), + sort_by_date=bool(selection.get("sort_by_date", False)), + max_teams=selection.get("max_teams"), + ) + return list(dataset.filenames) + + +def select_hl_filenames( + revealed_dir: str, + format_name: str, + window: dict, + selection: dict, +) -> list[str]: + min_rating = resolve_min_rating(selection, format_name) + if min_rating is None: + raise ValueError(f"hl_05_26 requires min_rating for {format_name}") + return select_elite_filenames( + replay_teamfile_dir=revealed_dir, + format_name=format_name, + min_rating=min_rating, + min_date=window.get("min_date"), + ) + + +def _fill_one(args: tuple) -> Optional[dict]: + global _FILL_PREDICTOR + if _FILL_PREDICTOR is None: + _FILL_PREDICTOR = NaiveUsagePredictor() + idx, rel_path, src_root, format_name, min_revealed_score, output_dir = args + src_path = os.path.join(src_root, rel_path) + basename = os.path.basename(rel_path) + meta = parse_replay_team_filename(basename, format_name) + if meta is None: + return None + try: + team = TeamSet.from_showdown_file(src_path, format=format_name) + predicted = _FILL_PREDICTOR.predict( + team, + date=meta.date.date(), + rating=meta.rating_raw, + gameid=meta.battle_id, + ) + score = predicted.revealed_score(include_stats=False) + if min_revealed_score is not None and score < min_revealed_score: + return None + out_name = f"team_{idx:06d}.{format_name}_team" + predicted.write_to_file(os.path.join(output_dir, out_name)) + return { + "output_file": out_name, + "revealed_score": float(score), + "source_file": rel_path, + } + except Exception: + return None + + +def fill_and_write( + revealed_dir: str, + format_name: str, + rel_paths: list[str], + output_dir: Path, + min_revealed_score: Optional[float], + workers: int, +) -> dict: + output_dir.mkdir(parents=True, exist_ok=True) + src_root = os.path.join(revealed_dir, format_name) + out_dir_str = str(output_dir) + work = [ + (i, rel_path, src_root, format_name, min_revealed_score, out_dir_str) + for i, rel_path in enumerate(rel_paths) + ] + chunksize = max(1, len(work) // (max(1, workers) * 8)) + kept = [] + with Pool(max(1, workers), initializer=_init_fill_worker) as pool: + for result in tqdm.tqdm( + pool.imap_unordered(_fill_one, work, chunksize=chunksize), + total=len(work), + desc=f"Fill {format_name}", + ): + if result is not None: + kept.append(result) + + kept.sort(key=lambda row: row["output_file"]) + out_names = [row["output_file"] for row in kept] + meta_rows = [ + { + "output_file": row["output_file"], + "revealed_score": row["revealed_score"], + "source_file": row["source_file"], + } + for row in kept + ] + + write_team_index_csv(output_dir, out_names) + with open(output_dir / "predictions_meta.json", "w") as f: + json.dump(meta_rows, f, indent=2) + + return { + "format": format_name, + "input_count": len(rel_paths), + "output_count": len(out_names), + "min_revealed_score": min_revealed_score, + "output_dir": str(output_dir), + } + + +def run_validate( + format_name: str, + input_root: Path, + output_root: Path, +) -> None: + cmd = [ + sys.executable, + "-m", + "metamon.backend.team_prediction.validate", + format_name, + "--input-path", + str(input_root), + "--output-path", + str(output_root), + ] + subprocess.run(cmd, check=True) + + +def build_set( + set_name: str, + cfg: dict, + revealed_dir: str, + cache_dir: str, + formats: list[str], + workers: int, + validate: bool, +) -> None: + window = cfg["window"] + set_cfg = cfg[set_name] + selection = set_cfg["selection"] + out_unfiltered = Path(cache_dir) / set_cfg["output"]["unfiltered"] + out_verified = Path(cache_dir) / set_cfg["output"]["verified"] + + set_type = resolve_set_type(set_name, set_cfg) + print(f"\n=== {set_name} ({set_type}) ===") + summary = [] + for format_name in formats: + if set_type == "gl": + rel_paths = select_gl_filenames( + revealed_dir, format_name, window, selection + ) + min_score = None + elif set_type == "hl": + rel_paths = select_hl_filenames( + revealed_dir, format_name, window, selection + ) + min_score = resolve_min_revealed_score(set_cfg, format_name) + else: + raise ValueError(f"Unknown set_type {set_type!r} for {set_name}") + + print(f"{format_name}: selected {len(rel_paths):,} teams") + if not rel_paths: + continue + + meta = fill_and_write( + revealed_dir=revealed_dir, + format_name=format_name, + rel_paths=rel_paths, + output_dir=out_unfiltered / format_name, + min_revealed_score=min_score, + workers=workers, + ) + summary.append(meta) + print(json.dumps(meta, indent=2)) + + if validate: + print(f"Validating {format_name} -> {out_verified / format_name}") + run_validate(format_name, out_unfiltered, out_verified) + + summary_path = out_unfiltered / "build_summary.json" + with open(summary_path, "w") as f: + json.dump(summary, f, indent=2) + print(f"Wrote {summary_path}") + + +def main(): + parser = argparse.ArgumentParser(description="Build replay-derived team sets") + parser.add_argument("--config", type=Path, default=DEFAULT_CONFIG) + parser.add_argument( + "--set", + nargs="+", + default=["all"], + metavar="SET", + help="Set name(s) from config, or 'all' (default: all sets in config)", + ) + parser.add_argument("--formats", nargs="+", default=FORMATS) + parser.add_argument("--revealed-teams-dir", default=None) + parser.add_argument("--cache-dir", default=METAMON_CACHE_DIR) + parser.add_argument("--workers", type=int, default=_default_workers()) + parser.add_argument( + "--min-date", + type=str, + default=None, + help='Override window min_date (MM-DD-YYYY), e.g. "05-01-2026"', + ) + parser.add_argument( + "--max-date", + type=str, + default=None, + help="Override window max_date (MM-DD-YYYY)", + ) + parser.add_argument( + "--max-teams", + type=int, + default=None, + help="Override gl set max_teams cap from config", + ) + parser.add_argument( + "--validate", + action="store_true", + help="Run validate.py after fill (Showdown legality check)", + ) + args = parser.parse_args() + + if args.cache_dir is None: + raise ValueError("METAMON_CACHE_DIR must be set (or pass --cache-dir)") + + revealed_dir = args.revealed_teams_dir or default_revealed_teams_dir() + cfg = load_config(args.config) + available = config_set_names(cfg) + if "all" in args.set: + sets = available + else: + unknown = set(args.set) - set(available) + if unknown: + raise ValueError( + f"Unknown set(s) {sorted(unknown)}; available: {available}" + ) + sets = args.set + + window = dict(cfg["window"]) + if args.min_date is not None: + window["min_date"] = args.min_date + if args.max_date is not None: + window["max_date"] = args.max_date + cfg = {**cfg, "window": window} + + print(f"Config: {args.config}") + print(f"Sets: {sets}") + print(f"Window: {window.get('min_date')} .. {window.get('max_date') or 'now'}") + print(f"Revealed teams: {revealed_dir}") + print(f"Cache output: {args.cache_dir}/teams/") + + for set_name in sets: + set_cfg = dict(cfg[set_name]) + if args.max_teams is not None and resolve_set_type(set_name, set_cfg) == "gl": + set_cfg = { + **set_cfg, + "selection": {**set_cfg["selection"], "max_teams": args.max_teams}, + } + build_set( + set_name=set_name, + cfg={**cfg, set_name: set_cfg}, + revealed_dir=revealed_dir, + cache_dir=args.cache_dir, + formats=args.formats, + workers=args.workers, + validate=args.validate, + ) + + +if __name__ == "__main__": + main() diff --git a/metamon/backend/team_prediction/compute_revealed_scores.py b/metamon/backend/team_prediction/compute_revealed_scores.py new file mode 100644 index 0000000000..1a37a8abcc --- /dev/null +++ b/metamon/backend/team_prediction/compute_revealed_scores.py @@ -0,0 +1,236 @@ +""" +Compute revealed scores for all teams in the dataset. + +Output: + - index_scored.csv: filename, gen, revealed_score (sorted by gen, then score desc) + - index_scored_meta.json: per-generation statistics +""" + +import argparse +import csv +import json +import pathlib +from collections import defaultdict +from multiprocessing import Pool, cpu_count + +import numpy as np +from tqdm import tqdm + +import metamon.data.download +from metamon.backend.team_prediction.team import TeamSet, PokemonSet + +_include_stats = False + + +def _init_worker(include_stats: bool): + global _include_stats + _include_stats = include_stats + + +def _process_single_file(args): + full_path, rel_path = args + try: + path = pathlib.Path(full_path) + format_str = path.suffix.replace("_team", "").lstrip(".") + if not format_str: + format_str = path.parent.name + + team = TeamSet.from_showdown_file(full_path, format_str) + return (rel_path, team.gen, team.revealed_score(_include_stats)) + except Exception: + return None + + +def compute_gen_statistics(scores): + if not scores: + return {} + arr = np.array(scores) + return { + "count": len(scores), + "mean": float(np.mean(arr)), + "std": float(np.std(arr)), + "min": float(np.min(arr)), + "q25": float(np.percentile(arr, 25)), + "median": float(np.median(arr)), + "q75": float(np.percentile(arr, 75)), + "max": float(np.max(arr)), + } + + +def process_directory( + data_dir: str, + output_filename: str = "index_scored.csv", + include_stats: bool = False, + verbose: bool = True, + num_workers: int = None, +): + """Process all team files and compute revealed scores.""" + d_path = pathlib.Path(data_dir) + num_workers = num_workers or max(1, cpu_count() - 1) + + index_path = d_path / "index.csv" + if not index_path.exists(): + raise FileNotFoundError(f"index.csv not found at {index_path}") + + if verbose: + print(f"Reading file list from {index_path}...") + + work_items = [] + with open(index_path, "r") as f: + for rel_path in f.read().splitlines()[1:]: + if rel_path: + work_items.append((str(d_path / rel_path), rel_path)) + + if verbose: + print(f"Processing {len(work_items)} files with {num_workers} workers...") + + results = [] + scores_by_gen = defaultdict(list) + num_errors = 0 + + with Pool(num_workers, initializer=_init_worker, initargs=(include_stats,)) as pool: + iterator = pool.imap_unordered(_process_single_file, work_items, chunksize=100) + if verbose: + iterator = tqdm(iterator, total=len(work_items), desc="Computing scores") + + for result in iterator: + if result is None: + num_errors += 1 + else: + rel_path, gen, score = result + results.append((rel_path, gen, score)) + scores_by_gen[gen].append(score) + + # Build metadata + metadata = { + "total_count": len(results), + "total_errors": num_errors, + "include_stats": include_stats, + "per_generation": {}, + } + for gen in sorted(scores_by_gen.keys()): + stats = compute_gen_statistics(scores_by_gen[gen]) + stats["max_attrs_per_pokemon"] = PokemonSet.max_relevant_attrs( + gen, include_stats + ) + metadata["per_generation"][f"gen{gen}"] = stats + + # Sort by gen, then score descending + results.sort(key=lambda x: (x[1], -x[2])) + + # Write outputs + output_path = d_path / output_filename + with open(output_path, "w", newline="") as f: + writer = csv.writer(f) + writer.writerow(["filename", "gen", "revealed_score"]) + for filename, gen, score in results: + writer.writerow([filename, gen, f"{score:.4f}"]) + + meta_path = d_path / output_filename.replace(".csv", "_meta.json") + with open(meta_path, "w") as f: + json.dump(metadata, f, indent=2) + + if verbose: + print(f"\nWrote {len(results)} entries to {output_path}") + print(f"Errors: {num_errors}") + _print_statistics(scores_by_gen, metadata, results, d_path) + + return len(results), num_errors, results, metadata + + +def _print_statistics(scores_by_gen, metadata, results, d_path): + """Print per-generation stats and example teams.""" + print(f"\n{'Gen':<6} {'Count':>8} {'Mean':>8} {'Median':>8} {'Q25':>8} {'Q75':>8}") + print("-" * 50) + for gen in sorted(scores_by_gen.keys()): + s = metadata["per_generation"][f"gen{gen}"] + print( + f"Gen {gen:<2} {s['count']:>8} {s['mean']:>7.1%} {s['median']:>7.1%} {s['q25']:>7.1%} {s['q75']:>7.1%}" + ) + + all_scores = [s for _, _, s in results] + if all_scores: + print( + f"\nOverall: min={min(all_scores):.1%}, max={max(all_scores):.1%}, mean={np.mean(all_scores):.1%}" + ) + + buckets = [0] * 10 + for s in all_scores: + buckets[min(int(s * 10), 9)] += 1 + print(f"\nHistogram:") + for i, count in enumerate(buckets): + pct = count / len(all_scores) * 100 + print( + f" {i*10:2d}-{(i+1)*10:2d}%: {count:6d} ({pct:4.1f}%) {'█' * int(pct / 2)}" + ) + + # Show most/least revealed per gen + print(f"\n{'='*50}\nMost/least revealed per generation:\n{'='*50}") + results_by_gen = defaultdict(list) + for r in results: + results_by_gen[r[1]].append(r) + + for gen in sorted(results_by_gen.keys()): + gen_results = results_by_gen[gen] + if not gen_results: + continue + + most = max(gen_results, key=lambda x: x[2]) + least = min(gen_results, key=lambda x: x[2]) + + print(f"\n--- Gen {gen} ---") + for label, item in [("MOST", most), ("LEAST", least)]: + print(f"\n{label} ({item[2]:.1%}): {item[0]}") + try: + path = d_path / item[0] + fmt = path.suffix.replace("_team", "").lstrip(".") or path.parent.name + print(TeamSet.from_showdown_file(str(path), fmt).to_str()) + except Exception as e: + print(f" (Could not load: {e})") + + +def main(): + parser = argparse.ArgumentParser( + description="Compute revealed scores for team files" + ) + parser.add_argument("--data-dir", type=str, default=None) + parser.add_argument("--output", type=str, default="index_scored.csv") + parser.add_argument( + "--include-stats", action="store_true", help="Include nature/EVs/IVs" + ) + parser.add_argument("--quiet", action="store_true") + parser.add_argument("--workers", type=int, default=None) + args = parser.parse_args() + + if args.data_dir is None: + args.data_dir = metamon.data.download.download_revealed_teams() + + data_path = pathlib.Path(args.data_dir) + subdirs = [d for d in data_path.iterdir() if d.is_dir()] + has_team_files = any( + str(f).endswith("team") for f in data_path.rglob("*") if f.is_file() + ) + + if subdirs and not has_team_files: + for subdir in sorted(subdirs): + if not args.quiet: + print(f"\n{'='*50}\nProcessing {subdir.name}\n{'='*50}") + process_directory( + str(subdir), + output_filename=args.output, + include_stats=args.include_stats, + verbose=not args.quiet, + num_workers=args.workers, + ) + else: + process_directory( + args.data_dir, + output_filename=args.output, + include_stats=args.include_stats, + verbose=not args.quiet, + num_workers=args.workers, + ) + + +if __name__ == "__main__": + main() diff --git a/metamon/backend/team_prediction/dataset.py b/metamon/backend/team_prediction/dataset.py index 3f8b799c19..fb1a15eba7 100644 --- a/metamon/backend/team_prediction/dataset.py +++ b/metamon/backend/team_prediction/dataset.py @@ -1,19 +1,68 @@ +import csv import os import random import pathlib +from dataclasses import dataclass from typing import List, Tuple, Optional, Union, Iterable, Literal, Dict, Set from datetime import datetime import torch +import torch.multiprocessing as mp from torch.utils.data import Dataset from poke_env.data import to_id_str import metamon from metamon.backend.team_prediction.team import TeamSet, Roster, PokemonSet -from metamon.backend.team_prediction.vocabulary import Vocabulary +from metamon.backend.team_prediction.team import Team2Seq +from metamon.backend.team_prediction.masking import TeamMasker from metamon.config import METAMON_CACHE_DIR +@dataclass(frozen=True) +class ReplayTeamFilenameMeta: + battle_id: str + rating_raw: str + rating_int: int + date: datetime + is_smogtours: bool + + +def parse_replay_team_filename( + filename: str, format: str +) -> Optional[ReplayTeamFilenameMeta]: + suffix = f".{format}_team" + if not filename.endswith(suffix): + return None + stem = filename[: -len(suffix)] + parts = stem.split("_") + if len(parts) < 7: + return None + battle_id, rating_raw, _, _, _, mm_dd_yyyy, result = parts[:7] + if result not in ("WIN", "LOSS"): + return None + try: + date = datetime.strptime(mm_dd_yyyy, "%m-%d-%Y") + except ValueError: + return None + try: + rating_int = int(rating_raw) + except ValueError: + rating_int = 1000 + return ReplayTeamFilenameMeta( + battle_id=battle_id, + rating_raw=rating_raw, + rating_int=rating_int, + date=date, + is_smogtours="smogtours" in battle_id.lower(), + ) + + +def default_revealed_teams_dir() -> str: + if METAMON_CACHE_DIR is None: + raise ValueError("METAMON_CACHE_DIR is not set") + return os.path.join(METAMON_CACHE_DIR, "parsed-replays", "revealed_teams") + + class TeamDataset(Dataset): def __init__( self, @@ -79,221 +128,176 @@ def __init__( max_date: Optional[str] = None, min_rating: Optional[int] = None, max_rating: Optional[int] = None, + sort_by_date: bool = False, ): self.min_date = min_date self.max_date = max_date self.min_rating = min_rating self.max_rating = max_rating + self.sort_by_date = sort_by_date super().__init__( team_file_dir=replay_teamfile_dir, format=format, max_teams=max_teams ) def load_filenames(self, max_teams: Optional[int] = None): self.filenames = [] - - def _rating_to_int(rating: str) -> int: - # mainly maps "Unrated" to 1000 - try: - return int(rating) - except ValueError: - return 1000 + entries: list[tuple[str, datetime]] = [] for root, _, files in os.walk(self.team_path): for filename in files: if not filename.endswith(f".{self.format}_team"): continue - try: - ( - battle_id, - rating, - username, - _, - opponent_username, - mm_dd_yyyy, - result, - ) = filename[: -len(f".{self.format}_team")].split("_") - except ValueError: + meta = parse_replay_team_filename(filename, self.format) + if meta is None: continue - - rating = _rating_to_int(rating) - date = datetime.strptime(mm_dd_yyyy, "%m-%d-%Y") - if ( - (self.min_rating is not None and rating < self.min_rating) - or (self.max_rating is not None and rating > self.max_rating) - or ( - self.min_date is not None - and date < datetime.strptime(self.min_date, "%m-%d-%Y") - ) - or ( - self.max_date is not None - and date > datetime.strptime(self.max_date, "%m-%d-%Y") - ) + if self.min_rating is not None and meta.rating_int < self.min_rating: + continue + if self.max_rating is not None and meta.rating_int > self.max_rating: + continue + if self.min_date is not None and meta.date < datetime.strptime( + self.min_date, "%m-%d-%Y" + ): + continue + if self.max_date is not None and meta.date > datetime.strptime( + self.max_date, "%m-%d-%Y" ): continue full_path = os.path.join(root, filename) rel_path = os.path.relpath(full_path, self.team_path) - self.filenames.append(rel_path) + entries.append((rel_path, meta.date)) - random.shuffle(self.filenames) + if self.sort_by_date: + entries.sort(key=lambda item: item[1], reverse=True) + else: + random.shuffle(entries) + self.filenames = [rel_path for rel_path, _ in entries] if max_teams is not None: self.filenames = self.filenames[:max_teams] class TeamPredictionDataset(Dataset): + """ + Dataset for team prediction using index_scored.csv. + + Supports generation-weighted sampling. Loads files grouped by generation + with their revealed scores for efficient sampling. + """ + def __init__( self, - data_dir: Union[str, Iterable[str]], - mask_pokemon_prob_range: Tuple[float, float], - mask_attrs_prob_range: Tuple[float, float], + data_dir: str, + masker: TeamMasker, + gen_weights: Optional[Dict[int, float]] = None, split: Literal["train", "val"] = "train", validation_ratio: float = 0.1, seed: Optional[int] = None, - use_cached_filenames: bool = False, verbose: bool = False, + include_stats: bool = False, ): - """ - Args: - data_dir: Directory or iterable of directories containing .team files (will be searched recursively) - split: Whether this is the training or validation split - validation_ratio: Fraction of data to use for validation - mask_pokemon_prob_range: Range of probabilities to use for masking an entire Pokemon - mask_attrs_prob_range: Range of probabilities to use for masking an indivudal attribute - seed: Random seed for reproducibility - use_cached_filenames: If True, use cached index files instead of scanning directories - verbose: If True, print progress information - """ - ( - self.mask_pokemon_prob_low, - self.mask_pokemon_prob_high, - ) = mask_pokemon_prob_range - self.mask_attrs_prob_low, self.mask_attrs_prob_high = mask_attrs_prob_range - assert self.mask_pokemon_prob_low <= self.mask_pokemon_prob_high - assert self.mask_attrs_prob_low <= self.mask_attrs_prob_high - assert 0 <= validation_ratio <= 1, "validation_ratio must be in [0, 1)" - - self.vocab = Vocabulary() - self.use_cached_filenames = use_cached_filenames + self.masker = masker + self.t2s = Team2Seq(include_stats=include_stats) self.verbose = verbose - if seed is not None: - random.seed(seed) - torch.manual_seed(seed) - - # Accept a string or an iterable of strings for data_dir - if isinstance(data_dir, str): - data_dirs = [data_dir] - else: - data_dirs = list(data_dir) - - # Collect all team files - team_files_set = set() - for d in data_dirs: - if self.verbose: - print(f"Processing directory: {d}") - d_path = pathlib.Path(d) - index_path = d_path / "index.csv" - - if self.use_cached_filenames and index_path.exists(): - with open(index_path, "r") as f: - lines = f.read().splitlines()[1:] # skip header - team_files_set.update(str(d_path / line) for line in lines if line) - if self.verbose: - print(f"Loaded {len(lines)} files from {index_path}") - else: - # Scan directory for team files - rel_paths = [] - for f in d_path.rglob("*"): - if f.is_file() and f.suffix.endswith("team"): - team_files_set.add(str(f)) - rel_paths.append(str(f.relative_to(d_path))) - if self.verbose: - print(f"Indexed {len(rel_paths)} files from {d}/") - # Write to index.csv cache - if rel_paths: - with open(index_path, "w") as f: - f.write("filename\n") - for rel_path in rel_paths: - f.write(f"{rel_path}\n") - all_team_files = sorted(team_files_set) - - # Create deterministic train/val split - n_total = len(all_team_files) - n_val = int(n_total * validation_ratio) - - # Use a separate random state for splitting to ensure same split regardless of other randomness + self.data_dir = pathlib.Path(data_dir) + self._rng = random.Random(seed) + + # load index_scored.csv + scored_index_path = self.data_dir / "index_scored.csv" + if not scored_index_path.exists(): + raise FileNotFoundError( + f"index_scored.csv not found at {scored_index_path}. " + "Run compute_revealed_scores.py first." + ) + + # parse csv: group by gen, sorted by score descending + files_by_gen: Dict[int, List[Tuple[str, float]]] = {} + with open(scored_index_path, "r") as f: + reader = csv.DictReader(f) + for row in reader: + filepath = str(self.data_dir / row["filename"]) + gen = int(row["gen"]) + score = float(row["revealed_score"]) + if gen not in files_by_gen: + files_by_gen[gen] = [] + files_by_gen[gen].append((filepath, score)) + + # Verify sorted by score descending within each gen + for gen, files in files_by_gen.items(): + for i in range(len(files) - 1): + assert ( + files[i][1] >= files[i + 1][1] + ), f"Files not sorted by score descending for gen {gen}" + + # train/val split split_rng = random.Random(seed) - indices = list(range(n_total)) - split_rng.shuffle(indices) - - val_indices = set(indices[:n_val]) - - # Assign files based on split - if split == "train": - self.team_files = [ - f for i, f in enumerate(all_team_files) if i not in val_indices - ] - else: # val - self.team_files = [ - f for i, f in enumerate(all_team_files) if i in val_indices - ] - - print(f"Created {split} split with {len(self.team_files)} team files") - - def __len__(self) -> int: - return len(self.team_files) - - def _mask(self, seq: list[str]) -> list[str]: - mask_out = [] - for token in seq: - if random.random() < self.mask_pokemon_prob: - mask_out.append(self.vocab.special_tokens["Mon"]) + self.files_by_gen: Dict[int, List[Tuple[str, float]]] = {} + for gen, files in files_by_gen.items(): + n_val = int(len(files) * validation_ratio) + indices = list(range(len(files))) + split_rng.shuffle(indices) + val_indices = set(indices[:n_val]) + + if split == "train": + selected = [files[i] for i in range(len(files)) if i not in val_indices] else: - mask_out.append(token) - return mask_out + selected = [files[i] for i in range(len(files)) if i in val_indices] + selected.sort(key=lambda x: -x[1]) + self.files_by_gen[gen] = selected + + # extract just scores for subclass use (binary search) + self.scores_by_gen: Dict[int, List[float]] = { + gen: [s for _, s in files] for gen, files in self.files_by_gen.items() + } + + # generation weights + self.gens = sorted(self.files_by_gen.keys()) + if gen_weights is None: + # natural distribution: probability proportional to file count + self.gen_weights = {g: len(self.files_by_gen[g]) for g in self.gens} + else: + self.gen_weights = {g: gen_weights.get(g, 0.0) for g in self.gens} + total_weight = sum(self.gen_weights.values()) + if total_weight <= 0: + raise ValueError( + "Invalid generation weights: sum(gen_weights) must be > 0. " + f"Got {self.gen_weights}" + ) + self.gen_probs = [self.gen_weights[g] / total_weight for g in self.gens] + + self._total_files = sum(len(f) for f in self.files_by_gen.values()) + if verbose: + print( + f"Dataset ({split}): {self._total_files:,} files, gens={list(self.gens)}" + ) + + def _get_sample_range(self, gen: int) -> int: + """Get the max index to sample from for a generation. Base class uses all files.""" + return len(self.files_by_gen[gen]) - 1 - def __getitem__(self, idx: int) -> Tuple[TeamSet, TeamSet]: - """ - Returns: - x: Masked team - x_type_ids: Type indicating ints (pokemon, ability, item, etc.) - y: Complete team (ground truth) - pred_mask: Mask indicating which values are eligible for loss function - """ + def __len__(self) -> int: + return self._total_files + + def _load_and_process_team(self, filepath: str): + """Load a team file and process it through masker and Team2Seq.""" + format_str = to_id_str(os.path.splitext(filepath)[1].split("_")[0]) + assert format_str.startswith("gen"), f"Invalid format: {format_str}" + team = TeamSet.from_showdown_file(filepath, format=format_str) + x, y = self.masker.mask(team) + x_tokens, type_ids, y_tokens, pred_mask = self.t2s.encode_pair(x, y) + assert len(x_tokens) == (8 * 6) + 1 + return x_tokens, type_ids, y_tokens, pred_mask + + def __getitem__(self, idx: int): max_retries = 50 for attempt in range(max_retries): try: - current_idx = idx if attempt == 0 else random.randint(0, len(self) - 1) - path = self.team_files[current_idx] - # Extract format from file extension (e.g. .gen4ou_team -> gen4ou) - format = to_id_str(os.path.splitext(path)[1].split("_")[0]) - assert format.startswith("gen"), f"Invalid format: {format}" - team = TeamSet.from_showdown_file(path, format=format) - mask_pokemon_prob = random.uniform( - self.mask_pokemon_prob_low, self.mask_pokemon_prob_high - ) - mask_attrs_prob = random.uniform( - self.mask_attrs_prob_low, self.mask_attrs_prob_high - ) - x, y = team.to_prediction_pair( - mask_pokemon_prob=mask_pokemon_prob, - mask_attrs_prob=mask_attrs_prob, - ) - x_seq, x_needs_pred = x.to_seq(include_stats=False) - y_seq, y_needs_pred = y.to_seq(include_stats=False) - # we will only train on values that are missing from x but provided by y - pred_mask = torch.logical_and( - torch.tensor(x_needs_pred), ~torch.tensor(y_needs_pred) - ) - x_tokens, x_type_ids = self.vocab.pokeset_seq_to_ints(x_seq) - y_tokens, y_type_ids = self.vocab.pokeset_seq_to_ints(y_seq) - assert len(x_tokens) == len(x_type_ids) - assert len(y_tokens) == len(y_type_ids) - assert len(x_tokens) == (8 * 6) + 1 - assert (x_type_ids == y_type_ids).all() - x_tokens = torch.from_numpy(x_tokens).long() - x_type_ids = torch.from_numpy(x_type_ids).long() - y_tokens = torch.from_numpy(y_tokens).long() - return x_tokens, x_type_ids, y_tokens, pred_mask + gen = self._rng.choices(self.gens, weights=self.gen_probs, k=1)[0] + max_idx = self._get_sample_range(gen) + if max_idx < 0: + continue + file_idx = self._rng.randint(0, max_idx) + filepath, score = self.files_by_gen[gen][file_idx] + return self._load_and_process_team(filepath) except Exception as e: if attempt == max_retries - 1: raise RuntimeError( @@ -302,63 +306,77 @@ def __getitem__(self, idx: int) -> Tuple[TeamSet, TeamSet]: continue -class CompetitiveTeamPredictionDataset(TeamPredictionDataset): +class ScoredTeamPredictionDataset(TeamPredictionDataset): + """ + Dataset with percentile-based filtering and curriculum support. + + Inherits gen-weighted sampling from TeamPredictionDataset, adds: + - Percentile-based filtering (e.g., top 10% most complete teams) + - Curriculum learning with dynamically changing percentile threshold + + Files are sorted by revealed_score descending, so percentile=10 means + only sample from the top 10% most complete teams per generation. + """ + def __init__( self, - mask_pokemon_prob_range: Tuple[float, float] = (0.1, 0.1), - mask_attrs_prob_range: Tuple[float, float] = (0.1, 0.1), + data_dir: str, + masker: TeamMasker, + gen_weights: Optional[Dict[int, float]] = None, + percentile: float = 100.0, + split: Literal["train", "val"] = "train", + validation_ratio: float = 0.1, + seed: Optional[int] = None, verbose: bool = False, + include_stats: bool = False, ): - team_dirs = [] - for gen in [1, 2, 3, 4, 5, 9]: - # TODO: add other tiers? - for tier in ["ou"]: - team_dirs.append( - os.path.join( - METAMON_CACHE_DIR, - "teams", - "competitive", - f"gen{gen}{tier}", - ) - ) super().__init__( - data_dir=team_dirs, - split="val", - validation_ratio=1.0, - mask_pokemon_prob_range=mask_pokemon_prob_range, - mask_attrs_prob_range=mask_attrs_prob_range, - use_cached_filenames=False, + data_dir=data_dir, + masker=masker, + gen_weights=gen_weights, + split=split, + validation_ratio=validation_ratio, + seed=seed, verbose=verbose, + include_stats=include_stats, ) + # Static percentile (can be overridden by curriculum) + self._static_percentile = percentile -if __name__ == "__main__": - # Test dataset loading - # dataset = FilteredTeamsFromReplaysDataset( - # os.path.join(METAMON_CACHE_DIR, "parsed-replays", "revealed_teams"), - # format="gen9ou", - # ) + # Shared value for curriculum (if used) + self._shared_percentile: Optional[mp.Value] = None - dataset = TeamPredictionDataset( - data_dir=os.path.join(METAMON_CACHE_DIR, "parsed-replays", "revealed_teams"), - split="train", - validation_ratio=0.1, - mask_pokemon_prob_range=(0.1, 0.1), - mask_attrs_prob_range=(0.1, 0.1), - ) + def enable_curriculum(self, initial_percentile: float = 10.0) -> None: + """Enable curriculum learning with a shared percentile that can be updated.""" + self._shared_percentile = mp.Value("d", initial_percentile) + + def set_curriculum_percentile(self, percentile: float) -> None: + """Update the curriculum percentile (call from main process).""" + if self._shared_percentile is not None: + self._shared_percentile.value = percentile + + @property + def percentile(self) -> float: + """Get current percentile threshold.""" + if self._shared_percentile is not None: + return self._shared_percentile.value + return self._static_percentile - for item in dataset: - print(item) - input() - - print(f"Dataset size: {len(dataset)}") - - # # Test loading a single item - # x, type_ids, y = dataset[0] - # print("\nMasked team (x):") - # print(x) - # print("\nType IDs:") - # print(type_ids) - # print("\nComplete team (y):") - # print(y) - # input() + def _get_sample_range(self, gen: int) -> int: + """ + Override to restrict sampling to top percentile of files by score. + + Returns the last valid index for sampling. + Returns -1 if no files available. + """ + n_files = len(self.files_by_gen[gen]) + if n_files == 0: + return -1 + + # percentile=10 means top 10%, so cutoff = n_files * 10 / 100 + cutoff = int(n_files * self.percentile / 100.0) + # Ensure at least 1 file if percentile > 0 + if self.percentile > 0: + cutoff = max(1, cutoff) + return cutoff - 1 # Convert count to max index diff --git a/metamon/backend/team_prediction/filter_elite.py b/metamon/backend/team_prediction/filter_elite.py new file mode 100644 index 0000000000..eed251958c --- /dev/null +++ b/metamon/backend/team_prediction/filter_elite.py @@ -0,0 +1,141 @@ +import json +import os +import shutil +import datetime +from pathlib import Path + +import tqdm + +from metamon.backend.team_prediction.dataset import ( + FilteredTeamsFromReplaysDataset, + parse_replay_team_filename, +) +from metamon.backend.team_prediction.team_index import write_team_index + + +def _find_smogtours_files(team_path: Path, format_name: str) -> set[str]: + suffix = f".{format_name}_team" + selected: set[str] = set() + for root, _, files in os.walk(team_path): + for filename in files: + if not filename.endswith(suffix): + continue + if "smogtours" not in filename.lower(): + continue + full_path = Path(root) / filename + rel_path = full_path.relative_to(team_path).as_posix() + selected.add(rel_path) + return selected + + +def _write_index_csv(output_dir: Path, rel_paths: list[str]) -> None: + write_team_index(output_dir, rel_paths) + + +def _filter_files_by_min_date( + files: set[str], format_name: str, min_date: str | None +) -> set[str]: + if min_date is None: + return files + cutoff = datetime.datetime.strptime(min_date, "%m-%d-%Y") + filtered = set() + for rel_path in files: + meta = parse_replay_team_filename(Path(rel_path).name, format_name) + if meta is None: + continue + if meta.date >= cutoff: + filtered.add(rel_path) + return filtered + + +def select_elite_filenames( + replay_teamfile_dir: str, + format_name: str, + min_rating: int = 1400, + min_date: str | None = None, +) -> list[str]: + """Return high-ELO ∪ smogtours filenames (no copy).""" + dataset = FilteredTeamsFromReplaysDataset( + replay_teamfile_dir=replay_teamfile_dir, + format=format_name, + min_rating=min_rating, + min_date=min_date, + ) + source_team_path = Path(dataset.team_path) + high_elo_files = _filter_files_by_min_date( + set(dataset.filenames), format_name, min_date + ) + smogtours_files = _find_smogtours_files(source_team_path, format_name) + smogtours_files = _filter_files_by_min_date(smogtours_files, format_name, min_date) + return sorted(high_elo_files | smogtours_files) + + +def write_team_index_csv(output_dir: Path, rel_paths: list[str]) -> None: + write_team_index(output_dir, rel_paths) + + +def filter_elite_sets( + replay_teamfile_dir: str, + base_output_dir: str, + format_name: str = "gen1ou", + min_rating: int = 1400, + min_date: str | None = None, + max_teams: int | None = None, + overwrite: bool = False, +) -> dict: + selected_files = select_elite_filenames( + replay_teamfile_dir=replay_teamfile_dir, + format_name=format_name, + min_rating=min_rating, + min_date=min_date, + ) + if max_teams is not None: + selected_files = selected_files[:max_teams] + + source_team_path = Path(replay_teamfile_dir) / format_name + high_elo_files = set() + for f in selected_files: + meta = parse_replay_team_filename(Path(f).name, format_name) + if meta and meta.rating_int >= min_rating: + high_elo_files.add(f) + smogtours_files = {f for f in selected_files if "smogtours" in Path(f).name.lower()} + intersection_files = high_elo_files & smogtours_files + + output_dir = Path(base_output_dir) / format_name + if overwrite and output_dir.exists(): + shutil.rmtree(output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + + copied = 0 + for rel_path in tqdm.tqdm( + selected_files, + desc=f"Copying {format_name} elite sets", + total=len(selected_files), + ): + src = source_team_path / rel_path + if not src.exists(): + continue + dst = output_dir / rel_path + dst.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(src, dst) + copied += 1 + + _write_index_csv(output_dir, selected_files) + + metadata = { + "format": format_name, + "source_team_path": str(source_team_path), + "min_rating": min_rating, + "min_date": min_date, + "total_selected": len(selected_files), + "copied_files": copied, + "high_elo_count": len(high_elo_files), + "smogtours_count": len(smogtours_files), + "intersection_count": len(intersection_files), + "union_count": len(selected_files), + "overwrite": overwrite, + } + with open(output_dir / "elite_filter_meta.json", "w", encoding="utf-8") as f: + json.dump(metadata, f, indent=2) + + return metadata diff --git a/metamon/backend/team_prediction/generate_replay_stats.py b/metamon/backend/team_prediction/generate_replay_stats.py index b47b35273d..7faedb5630 100644 --- a/metamon/backend/team_prediction/generate_replay_stats.py +++ b/metamon/backend/team_prediction/generate_replay_stats.py @@ -23,7 +23,11 @@ def load_replay_teams( pokemon_sets = defaultdict(list) team_rosters = [] - dataset = TeamDataset(revealed_team_dir, format, max_teams=max_teams) + dataset = TeamDataset( + format=format, + max_teams=max_teams, + team_file_dir=revealed_team_dir, + ) dataloader = DataLoader( dataset, batch_size=16, diff --git a/metamon/backend/team_prediction/generate_teamsets_from_replays.py b/metamon/backend/team_prediction/generate_teamsets_from_replays.py deleted file mode 100644 index c6df01ed44..0000000000 --- a/metamon/backend/team_prediction/generate_teamsets_from_replays.py +++ /dev/null @@ -1,127 +0,0 @@ -import os -import tqdm -import datetime -from multiprocessing import Pool, cpu_count -from itertools import islice - -from metamon.backend.team_prediction.dataset import FilteredTeamsFromReplaysDataset -from metamon.backend.team_prediction.predictor import ReplayPredictor - - -def chunk_dataset(dataset, chunk_size): - iterator = iter(dataset) - while chunk := list(islice(iterator, chunk_size)): - yield chunk - - -def process_chunk(args): - chunk, format_name, output_dir, offset = args - predictor = ReplayPredictor() - success_count = 0 - - for i, result in tqdm.tqdm(enumerate(chunk), total=len(chunk)): - try: - team, *_ = result - predicted_team = predictor.predict( - team, date=datetime.datetime.now().date() - ) - output = os.path.join(output_dir, f"team_{i + offset}.{format_name}_team") - predicted_team.write_to_file(output) - success_count += 1 - except Exception as e: - print(f"Error generating teamset: {e}") - continue - - return success_count - - -def main(args): - for format in args.formats: - os.makedirs(os.path.join(args.base_output_dir, format), exist_ok=True) - team_dataset = FilteredTeamsFromReplaysDataset( - replay_teamfile_dir=args.replay_teamfile_dir, - min_date=args.min_date, - max_date=args.max_date, - min_rating=args.min_rating, - format=format, - ) - total_teams = len(team_dataset) - print(f"Found {total_teams} teams for format {format}") - - num_processes = min(args.num_processes, total_teams) - chunk_size = max(1, total_teams // (num_processes * 4)) - chunks = list(chunk_dataset(team_dataset, chunk_size)) - chunk_args = [ - (chunk, format, os.path.join(args.base_output_dir, format), i * chunk_size) - for i, chunk in enumerate(chunks) - ] - with Pool(processes=num_processes) as pool: - results = list( - tqdm.tqdm( - pool.imap(process_chunk, chunk_args), - total=len(chunks), - desc=f"Generating teamsets for {format}", - ) - ) - - total_success = sum(results) - print( - f"Successfully generated {total_success}/{total_teams} teamsets for {format}" - ) - - -if __name__ == "__main__": - import argparse - - parser = argparse.ArgumentParser(description="Generate teamsets from replay data") - parser.add_argument( - "--replay_teamfile_dir", - type=str, - required=True, - help="Path to the directory containing the replay teamfiles", - ) - parser.add_argument( - "--min_date", - type=str, - required=False, - default=None, - help="Minimum date of replays to include (MM-DD-YYYY)", - ) - parser.add_argument( - "--max_date", - type=str, - required=False, - default=None, - help="Maximum date of replays to include (MM-DD-YYYY)", - ) - parser.add_argument( - "--min_rating", - type=int, - required=False, - default=None, - help="Minimum rating threshold for included replays", - ) - parser.add_argument( - "--formats", - type=str, - nargs="+", - default=[ - f"gen{i}{tier}" for i in range(1, 5) for tier in ["ou", "ubers", "nu", "uu"] - ], - help="List of formats to include", - ) - parser.add_argument( - "--base_output_dir", - type=str, - required=True, - help="Path to the directory to save the generated teamsets", - ) - parser.add_argument( - "--num_processes", - type=int, - required=False, - default=cpu_count(), - help="Number of processes to use for parallel processing", - ) - args = parser.parse_args() - main(args) diff --git a/metamon/backend/team_prediction/iterative_decoder.py b/metamon/backend/team_prediction/iterative_decoder.py new file mode 100644 index 0000000000..18607baa67 --- /dev/null +++ b/metamon/backend/team_prediction/iterative_decoder.py @@ -0,0 +1,568 @@ +import torch +import torch.nn.functional as F +from abc import ABC, abstractmethod +from collections import defaultdict +from typing import Literal, Optional, List +from dataclasses import dataclass, field +import math + +from metamon.backend.team_prediction.team import TeamSet, Team2Seq, PokemonSet + + +class Decoder(ABC): + """Abstract base class for team prediction decoders.""" + + @property + @abstractmethod + def num_iterations(self) -> int: + """Number of decoding iterations.""" + pass + + @property + @abstractmethod + def vocab(self): + """Vocabulary for token filtering.""" + pass + + +@dataclass +class IterativeDecodingStats: + mask_ratios: List[float] = field(default_factory=list) + remaining_counts: List[int] = field(default_factory=list) + committed_counts: List[int] = field(default_factory=list) + names_committed_counts: List[int] = field(default_factory=list) + moves_committed_counts: List[int] = field(default_factory=list) + confidences_per_iter: List[torch.Tensor] = field(default_factory=list) + name_confidences_per_iter: List[torch.Tensor] = field(default_factory=list) + move_confidences_per_iter: List[torch.Tensor] = field(default_factory=list) + tokens_per_iter: List[torch.Tensor] = field(default_factory=list) + # Uniqueness constraint diagnostics + names_reset_counts: List[int] = field(default_factory=list) + moves_reset_counts: List[int] = field(default_factory=list) + num_iterations_used: int = 0 + total_masked: int = 0 + + def add_iteration( + self, + iteration: int, + mask_ratio: float, + remaining: int, + committed: int, + names_committed: int, + moves_committed: int, + masked_confidences: torch.Tensor, + name_confidences: torch.Tensor, + move_confidences: torch.Tensor, + names_reset: int, + moves_reset: int, + current_tokens: Optional[torch.Tensor] = None, + ): + self.mask_ratios.append(mask_ratio) + self.remaining_counts.append(remaining) + self.committed_counts.append(committed) + self.names_committed_counts.append(names_committed) + self.moves_committed_counts.append(moves_committed) + self.confidences_per_iter.append(masked_confidences) + self.name_confidences_per_iter.append(name_confidences) + self.move_confidences_per_iter.append(move_confidences) + self.names_reset_counts.append(names_reset) + self.moves_reset_counts.append(moves_reset) + if current_tokens is not None: + self.tokens_per_iter.append(current_tokens.cpu().clone()) + self.num_iterations_used = iteration + 1 + + +class IterativeStatsAccumulator: + def __init__(self, num_iterations: int): + self.num_iterations = num_iterations + self.total_masked = 0 + self.mask_ratios: Optional[List[float]] = None + self.remaining_counts: List[List[int]] = [[] for _ in range(num_iterations)] + self.committed_counts: List[List[int]] = [[] for _ in range(num_iterations)] + self.names_committed_counts: List[List[int]] = [ + [] for _ in range(num_iterations) + ] + self.moves_committed_counts: List[List[int]] = [ + [] for _ in range(num_iterations) + ] + self.confidences: List[List[torch.Tensor]] = [[] for _ in range(num_iterations)] + self.name_confidences: List[List[torch.Tensor]] = [ + [] for _ in range(num_iterations) + ] + self.move_confidences: List[List[torch.Tensor]] = [ + [] for _ in range(num_iterations) + ] + # Uniqueness constraint diagnostics + self.names_reset_counts: List[List[int]] = [[] for _ in range(num_iterations)] + self.moves_reset_counts: List[List[int]] = [[] for _ in range(num_iterations)] + + def add_batch(self, stats: IterativeDecodingStats): + self.total_masked += stats.total_masked + if self.mask_ratios is None: + # same for all batches + self.mask_ratios = stats.mask_ratios + for i, ( + remaining, + committed, + names_committed, + moves_committed, + conf, + name_conf, + move_conf, + names_reset, + moves_reset, + ) in enumerate( + zip( + stats.remaining_counts, + stats.committed_counts, + stats.names_committed_counts, + stats.moves_committed_counts, + stats.confidences_per_iter, + stats.name_confidences_per_iter, + stats.move_confidences_per_iter, + stats.names_reset_counts, + stats.moves_reset_counts, + ) + ): + self.remaining_counts[i].append(remaining) + self.committed_counts[i].append(committed) + self.names_committed_counts[i].append(names_committed) + self.moves_committed_counts[i].append(moves_committed) + if len(conf) > 0: + self.confidences[i].append(conf) + if len(name_conf) > 0: + self.name_confidences[i].append(name_conf) + if len(move_conf) > 0: + self.move_confidences[i].append(move_conf) + self.names_reset_counts[i].append(names_reset) + self.moves_reset_counts[i].append(moves_reset) + + def compute_results(self) -> dict: + """Aggregate batch stats into a summary dict (mask ratios, commits, confidences).""" + remaining_frac = [] + committed_per_iter = [] + names_committed_per_iter = [] + moves_committed_per_iter = [] + names_reset_per_iter = [] + moves_reset_per_iter = [] + if self.total_masked > 0: + for i in range(self.num_iterations): + if self.remaining_counts[i]: + remaining_frac.append( + sum(self.remaining_counts[i]) / self.total_masked + ) + committed_per_iter.append(sum(self.committed_counts[i])) + names_committed_per_iter.append(sum(self.names_committed_counts[i])) + moves_committed_per_iter.append(sum(self.moves_committed_counts[i])) + names_reset_per_iter.append(sum(self.names_reset_counts[i])) + moves_reset_per_iter.append(sum(self.moves_reset_counts[i])) + else: + break + + def _concat_tensors(tensor_lists): + result = [] + for i in range(self.num_iterations): + if tensor_lists[i]: + result.append(torch.cat(tensor_lists[i], dim=0)) + else: + result.append(torch.tensor([])) + return result + + return { + "mask_ratios": self.mask_ratios or [], + "remaining_frac": remaining_frac, + "committed_per_iter": committed_per_iter, + "names_committed_per_iter": names_committed_per_iter, + "moves_committed_per_iter": moves_committed_per_iter, + "names_reset_per_iter": names_reset_per_iter, + "moves_reset_per_iter": moves_reset_per_iter, + "confidences": _concat_tensors(self.confidences), + "name_confidences": _concat_tensors(self.name_confidences), + "move_confidences": _concat_tensors(self.move_confidences), + } + + +class OneShotDecoder(Decoder): + """ + Single-pass decoding with temperature and nucleus sampling. + + Used for one-shot evaluation with the same sampling options as iterative decoding. + """ + + def __init__( + self, + model, + temperature: float = 1.0, + top_p: float = 0.9, + deterministic: bool = False, + include_stats: bool = False, + ): + self.model = model + self.temperature = temperature + self.top_p = top_p + self.deterministic = deterministic + self.t2s = Team2Seq(include_stats=include_stats) + + @property + def num_iterations(self) -> int: + return 1 + + @property + def vocab(self): + return self.t2s.vocab + + def _nucleus_filter(self, probs: torch.Tensor) -> torch.Tensor: + """Apply nucleus (top-p) filtering and renormalize.""" + if self.top_p >= 1.0: + return probs + sorted_probs, sorted_indices = torch.sort(probs, descending=True, dim=-1) + cumulative_probs = torch.cumsum(sorted_probs, dim=-1) + sorted_mask = cumulative_probs - sorted_probs > self.top_p + sorted_probs[sorted_mask] = 0.0 + filtered_probs = torch.zeros_like(probs) + filtered_probs.scatter_(-1, sorted_indices, sorted_probs) + filtered_probs = filtered_probs / filtered_probs.sum(dim=-1, keepdim=True) + return filtered_probs + + @torch.no_grad() + def decode( + self, + x_tokens: torch.Tensor, + type_ids: torch.Tensor, + pred_mask: torch.Tensor, + ) -> torch.Tensor: + self.model.eval() + device = x_tokens.device + + logits = self.model(x_tokens, type_ids) + + # Apply temperature + if self.temperature != 1.0: + logits = logits / self.temperature + + probs = torch.softmax(logits, dim=-1) + + # Filter by valid token types + probs = self.vocab.filter_probs(probs, type_ids) + + # Apply nucleus sampling filter + probs = self._nucleus_filter(probs) + + # Sample or argmax + if self.deterministic: + sampled = probs.argmax(dim=-1) + else: + flat_probs = probs.view(-1, probs.shape[-1]) + sampled = torch.multinomial(flat_probs, num_samples=1).squeeze(-1) + sampled = sampled.view(probs.shape[:-1]) + + # Only replace masked positions + pred_tokens = x_tokens.clone() + pred_tokens[pred_mask] = sampled[pred_mask] + + return pred_tokens + + +class IterativeTeamDecoder(Decoder): + """ + MaskGIT-style iterative decoding with re-sorting after each fill. + + After each iteration, the filled-in tokens are converted back to a TeamSet, + re-sorted using Team2Seq to maintain the canonical ordering invariant + (visible items first alphabetically, then masked items). + """ + + def __init__( + self, + model, + num_iterations: int = 8, + mask_schedule: Literal["linear", "cosine"] = "cosine", + temperature: float = 1.0, + top_p: float = 0.9, + deterministic: bool = False, + include_stats: bool = False, + ): + self.model = model + self._num_iterations = num_iterations + self.mask_schedule = mask_schedule + self.temperature = temperature + self.top_p = top_p + self.deterministic = deterministic + self.t2s = Team2Seq(include_stats=include_stats) + # cache special token IDs for uniqueness constraints + self._missing_name_id = int( + self.vocab.tokenizer.tokenize([f"Mon: {PokemonSet.MISSING_NAME}"])[0] + ) + self._missing_move_id = int( + self.vocab.tokenizer.tokenize([f"Move: {PokemonSet.MISSING_MOVE}"])[0] + ) + self._no_move_id = int( + self.vocab.tokenizer.tokenize([f"Move: {PokemonSet.NO_MOVE}"])[0] + ) + + @property + def num_iterations(self) -> int: + return self._num_iterations + + @property + def vocab(self): + return self.t2s.vocab + + def _nucleus_filter(self, probs: torch.Tensor) -> torch.Tensor: + """Apply nucleus (top-p) filtering and renormalize.""" + sorted_probs, sorted_indices = torch.sort(probs, descending=True, dim=-1) + cumulative_probs = torch.cumsum(sorted_probs, dim=-1) + sorted_mask = cumulative_probs - sorted_probs > self.top_p + sorted_probs[sorted_mask] = 0.0 + filtered_probs = torch.zeros_like(probs) + filtered_probs.scatter_(-1, sorted_indices, sorted_probs) + filtered_probs = filtered_probs / filtered_probs.sum(dim=-1, keepdim=True) + return filtered_probs + + def _gamma(self, ratio: float) -> float: + """ + Mask ratio schedule: gamma(r) gives fraction of tokens still masked at progress r. + """ + if self.mask_schedule == "linear": + return 1.0 - ratio + elif self.mask_schedule == "cosine": + return math.cos(ratio * math.pi / 2) + raise ValueError(f"Unknown schedule: {self.mask_schedule}") + + def _compute_resort_permutation(self, tokens: torch.Tensor) -> List[int]: + """Compute permutation to put tokens in canonical order.""" + team = self.t2s.decode(tokens) + return self.t2s.compute_permutation(team) + + def _apply_permutation(self, tensor: torch.Tensor, perm: List[int]) -> torch.Tensor: + return tensor[perm] + + def _reset_duplicates( + self, + positions: List[int], + tokens: torch.Tensor, + mask: torch.Tensor, + probs: torch.Tensor, + missing_id: int, + skip_ids: set, + ) -> int: + """Reset duplicate tokens at given positions, keeping highest confidence.""" + # Get visible (non-masked) tokens at these positions + visible = [(p, tokens[p].item()) for p in positions if not mask[p]] + if len(visible) < 2: + return 0 + + # Group by token value + by_token = defaultdict(list) + for pos, tok in visible: + by_token[tok].append(pos) + + tokens_reset = 0 + for tok, pos_list in by_token.items(): + if len(pos_list) > 1 and tok not in skip_ids: + # Keep highest confidence, reset others + confs = [probs[p, tok].item() for p in pos_list] + best_idx = confs.index(max(confs)) + for i, p in enumerate(pos_list): + if i != best_idx: + tokens[p] = missing_id + mask[p] = True + tokens_reset += 1 + return tokens_reset + + def _enforce_uniqueness_constraints( + self, + b: int, + current_tokens: torch.Tensor, + current_mask: torch.Tensor, + filtered_probs: torch.Tensor, + ) -> tuple[int, int]: + """ + Enforce unique pokemon names and moves per pokemon (reset duplicates to $missing_*$). + Returns (names_reset, moves_reset). + """ + # 1. Pokemon names (6 positions, no duplicates allowed) + names_reset = self._reset_duplicates( + self.t2s.get_all_name_positions(), + current_tokens[b], + current_mask[b], + filtered_probs[b], + self._missing_name_id, + skip_ids={self._missing_name_id}, + ) + + # 2. Moves per Pokemon (4 positions each, can repeat) + moves_reset = 0 + for pokemon_idx in range(6): + moves_reset += self._reset_duplicates( + self.t2s.get_move_positions_for_pokemon(pokemon_idx), + current_tokens[b], + current_mask[b], + filtered_probs[b], + self._missing_move_id, + skip_ids={self._missing_move_id, self._no_move_id}, + ) + + return names_reset, moves_reset + + @torch.no_grad() + def decode( + self, + x_tokens: torch.Tensor, + type_ids: torch.Tensor, + pred_mask: torch.Tensor, + track_tokens: bool = False, + ) -> tuple[torch.Tensor, IterativeDecodingStats]: + """ + MaskGIT-style iterative decoding with re-sorting to deal with structured + semi-ordered sequence format of our Pokemon teams. + """ + self.model.eval() + batch_size, seq_len = x_tokens.shape + device = x_tokens.device + + current_tokens = x_tokens.clone() + current_type_ids = type_ids.clone() + current_mask = pred_mask.clone() + + initial_n_masked = pred_mask.sum(dim=1) # [batch_size] + + stats = IterativeDecodingStats() + stats.total_masked = pred_mask.sum().item() + NAME_TYPE_ID = self.vocab.type_ids["Mon"] + MOVE_TYPE_ID = self.vocab.type_ids["Move"] + + # initial state for visualization + if track_tokens: + stats.tokens_per_iter.append(current_tokens.cpu().clone()) + + for t in range(self.num_iterations): + n_masked = current_mask.sum(dim=1) + # early exit if all tokens committed + if not current_mask.any(): + break + + # forward pass + logits = self.model(current_tokens, current_type_ids) + probs = F.softmax(logits, dim=-1) + filtered_probs = self.vocab.filter_probs(probs, current_type_ids) + + if self.deterministic: + # argmax (matches one-shot behavior) + confidences, predictions = filtered_probs.max(dim=-1) + else: + # stochastic sampling with temperature and nucleus filtering + scaled_logits = logits / self.temperature + scaled_probs = F.softmax(scaled_logits, dim=-1) + scaled_filtered = self.vocab.filter_probs( + scaled_probs, current_type_ids + ) + flat_probs = scaled_filtered.view(-1, scaled_filtered.shape[-1]) + flat_probs = self._nucleus_filter(flat_probs) + sampled_flat = torch.multinomial(flat_probs, num_samples=1).squeeze(-1) + predictions = sampled_flat.view(batch_size, seq_len) + # confidence = probability of the sampled token (from unscaled probs) + confidences = filtered_probs.gather( + -1, predictions.unsqueeze(-1) + ).squeeze(-1) + confidences = torch.where( + current_mask, confidences, torch.tensor(float("inf"), device=device) + ) + + is_last_iter = t == self.num_iterations - 1 + progress = (t + 1) / self.num_iterations + target_mask_ratio = 0.0 if is_last_iter else self._gamma(progress) + + # Collect confidences by type for diagnostics + iter_confidences = ( + confidences[current_mask].cpu() + if current_mask.any() + else torch.tensor([]) + ) + # Name confidences + name_mask = current_mask & (current_type_ids == NAME_TYPE_ID) + iter_name_confidences = ( + confidences[name_mask].cpu() if name_mask.any() else torch.tensor([]) + ) + # Move confidences + move_mask = current_mask & (current_type_ids == MOVE_TYPE_ID) + iter_move_confidences = ( + confidences[move_mask].cpu() if move_mask.any() else torch.tensor([]) + ) + + total_committed_this_iter = 0 + total_names_committed_this_iter = 0 + total_moves_committed_this_iter = 0 + total_names_reset_this_iter = 0 + total_moves_reset_this_iter = 0 + for b in range(batch_size): + if n_masked[b] == 0: + continue + + # how many tokens to commit + n_remain = max( + 0, math.ceil(target_mask_ratio * initial_n_masked[b].item()) + ) + n_remain = min(n_remain, n_masked[b].item()) + n_to_commit = n_masked[b].item() - n_remain + n_to_commit = max(1, n_to_commit) + n_to_commit = min(n_to_commit, n_masked[b].item()) + + masked_positions = current_mask[b].nonzero(as_tuple=True)[0] + masked_confs = confidences[b][masked_positions] + + # select top-k most confident tokens to commit + _, topk_idx = masked_confs.topk(min(n_to_commit, masked_confs.numel())) + commit_positions = masked_positions[topk_idx] + + if commit_positions.numel() == 0: + continue + + # count names and moves being committed + commit_types = current_type_ids[b][commit_positions] + names_in_commit = (commit_types == NAME_TYPE_ID).sum().item() + moves_in_commit = (commit_types == MOVE_TYPE_ID).sum().item() + + # commit selected tokens + current_tokens[b, commit_positions] = predictions[b, commit_positions] + current_mask[b, commit_positions] = False + total_committed_this_iter += len(commit_positions) + total_names_committed_this_iter += names_in_commit + total_moves_committed_this_iter += moves_in_commit + + # Enforce uniqueness: no duplicate pokemon names, no duplicate moves per pokemon + # Only do this if we have more iterations to fix it + if not is_last_iter: + names_reset, moves_reset = self._enforce_uniqueness_constraints( + b, current_tokens, current_mask, filtered_probs + ) + total_committed_this_iter -= names_reset + moves_reset + total_names_committed_this_iter -= names_reset + total_moves_committed_this_iter -= moves_reset + total_names_reset_this_iter += names_reset + total_moves_reset_this_iter += moves_reset + + # re-sort to maintain canonical ordering + perm = self._compute_resort_permutation(current_tokens[b]) + current_tokens[b] = self._apply_permutation(current_tokens[b], perm) + current_type_ids[b] = self._apply_permutation(current_type_ids[b], perm) + current_mask[b] = self._apply_permutation(current_mask[b], perm) + + tokens_for_viz = current_tokens.clone() if track_tokens else None + + stats.add_iteration( + iteration=t, + mask_ratio=target_mask_ratio, + remaining=current_mask.sum().item(), + committed=total_committed_this_iter, + names_committed=total_names_committed_this_iter, + moves_committed=total_moves_committed_this_iter, + masked_confidences=iter_confidences, + name_confidences=iter_name_confidences, + move_confidences=iter_move_confidences, + names_reset=total_names_reset_this_iter, + moves_reset=total_moves_reset_this_iter, + current_tokens=tokens_for_viz, + ) + + return current_tokens, stats diff --git a/metamon/backend/team_prediction/masking.py b/metamon/backend/team_prediction/masking.py new file mode 100644 index 0000000000..f7ea4539b4 --- /dev/null +++ b/metamon/backend/team_prediction/masking.py @@ -0,0 +1,146 @@ +"""Masker classes for creating (x, y) training pairs by masking team attributes.""" + +import copy +import ctypes +import random +from typing import Tuple + +import torch.multiprocessing as mp + +from metamon.backend.team_prediction.team import TeamSet, PokemonSet + + +class TeamMasker: + """ + Base masker: creates (x, y) pairs by randomly masking team attributes. + + - attrs_prob_range: probability range for masking individual attributes + (any attribute including name can be masked; high values may mask entire Pokemon) + """ + + def __init__( + self, + attrs_prob_range: Tuple[float, float] = (0.1, 1.0), + include_stats: bool = False, + ): + self.attrs_prob_range = attrs_prob_range + self.include_stats = include_stats + + def set_step(self, step: int) -> None: + """Update training step (for curriculum subclasses).""" + pass + + def _mask_pokemon(self, pokemon: PokemonSet) -> PokemonSet: + """Mask a random subset of attributes (possibly all of them).""" + data = pokemon.to_dict() + maskable = pokemon.get_maskable_attrs(include_stats=self.include_stats) + + if not maskable: + return PokemonSet.from_dict(data) + + # Discrete sampling: uniformly choose count from [min_count, max_count] + min_frac, max_frac = self.attrs_prob_range + min_count = max(1, round(min_frac * len(maskable))) + max_count = max(min_count, round(max_frac * len(maskable))) + num_to_mask = random.randint(min_count, max_count) + + for key, subkey in random.sample(maskable, num_to_mask): + if subkey is None: + if key == "name": + data["name"] = PokemonSet.MISSING_NAME + elif key == "ability": + data["ability"] = PokemonSet.MISSING_ABILITY + elif key == "item": + data["item"] = PokemonSet.MISSING_ITEM + elif key == "tera_type": + data["tera_type"] = PokemonSet.MISSING_TERA_TYPE + else: + if key == "moves": + data["moves"][subkey] = PokemonSet.MISSING_MOVE + elif key == "evs": + data["evs"][subkey] = PokemonSet.MISSING_EV + elif key == "ivs": + data["ivs"][subkey] = PokemonSet.MISSING_IV + + return PokemonSet.from_dict(data) + + def mask(self, team: TeamSet) -> Tuple[TeamSet, TeamSet]: + """Mask a team. Returns (masked_x, ground_truth_y).""" + y = copy.deepcopy(team) + x = copy.deepcopy(team) + x.lead = self._mask_pokemon(x.lead) + x.reserve = [self._mask_pokemon(p) for p in x.reserve] + return x, y + + def __repr__(self) -> str: + return f"TeamMasker(attrs={self.attrs_prob_range})" + + +class NamesOnlyMasker(TeamMasker): + """Toy masker: only masks Pokemon names.""" + + def __init__(self, mask_all: bool = True): + super().__init__() + self.mask_all = mask_all + + def mask(self, team: TeamSet) -> Tuple[TeamSet, TeamSet]: + y = copy.deepcopy(team) + x = copy.deepcopy(team) + + all_pokemon = [x.lead] + list(x.reserve) + if self.mask_all: + indices = list(range(len(all_pokemon))) + else: + k = random.randint(1, len(all_pokemon)) + indices = random.sample(range(len(all_pokemon)), k) + + for i in indices: + all_pokemon[i].name = PokemonSet.MISSING_NAME + + x.lead = all_pokemon[0] + x.reserve = all_pokemon[1:] + return x, y + + def __repr__(self) -> str: + return f"NamesOnlyMasker(mask_all={self.mask_all})" + + +class CurriculumMasker(TeamMasker): + """Masker with curriculum: masking rate anneals from min to max over warmup steps.""" + + def __init__( + self, + warmup_steps: int = 20_000, + attrs_prob: float = 1.0, + min_attrs_prob: float = 0.25, + include_stats: bool = False, + ): + self.include_stats = include_stats + self.warmup_steps = warmup_steps + self._attrs_prob = attrs_prob + self._min_attrs_prob = min_attrs_prob + self._shared_step = mp.Value(ctypes.c_int, 0) + + def set_step(self, step: int) -> None: + self._shared_step.value = step + + @property + def _step(self) -> int: + return self._shared_step.value + + @property + def progress(self) -> float: + return min(self._step / max(self.warmup_steps, 1), 1.0) + + @property + def attrs_prob_range(self) -> Tuple[float, float]: + current = self._min_attrs_prob + self.progress * ( + self._attrs_prob - self._min_attrs_prob + ) + return (0.0, current) + + def __repr__(self) -> str: + return ( + f"CurriculumMasker(step={self._step}/{self.warmup_steps}, " + f"progress={self.progress:.1%}, attrs=[0,{self.attrs_prob_range[1]:.2f}])" + ) diff --git a/metamon/backend/team_prediction/model.py b/metamon/backend/team_prediction/model.py deleted file mode 100644 index e71f42aa60..0000000000 --- a/metamon/backend/team_prediction/model.py +++ /dev/null @@ -1,115 +0,0 @@ -import torch -import torch.nn as nn -from metamon.backend.team_prediction.vocabulary import Vocabulary - - -class TeamTransformer(nn.Module): - """ - A simple Transformer encoder model for team prediction. - - Embeddings: - - token embedding (vocab_size x d_model) - - type embedding (type_vocab_size x d_model) - - position embedding (max_seq_len x d_model) - - Args: - max_seq_len (int): Maximum sequence length for positional embeddings - d_model (int): Embedding dimension (default: 512) - nhead (int): Number of attention heads (default: 8) - num_layers (int): Number of Transformer encoder layers (default: 6) - dim_feedforward (int): Inner dimension of feedforward networks (default: 2048) - dropout (float): Dropout probability (default: 0.1) - """ - - def __init__( - self, - max_seq_len: int = 20, - d_model: int = 512, - nhead: int = 8, - num_layers: int = 6, - dim_feedforward: int = 2048, - dropout: float = 0.1, - ): - super().__init__() - # Load vocabulary to determine sizes - self.vocab = Vocabulary() - vocab_size = len(self.vocab.tokenizer) - type_vocab_size = max(self.vocab.type_ids.values()) + 1 - self.d_model = d_model - self.max_seq_len = max_seq_len - - # Embedding layers - self.token_embedding = nn.Embedding(vocab_size, d_model) - self.type_embedding = nn.Embedding(type_vocab_size, d_model) - self.position_embedding = nn.Embedding(max_seq_len, d_model) - - # Transformer encoder - encoder_layer = nn.TransformerEncoderLayer( - d_model=d_model, - nhead=nhead, - dim_feedforward=dim_feedforward, - dropout=dropout, - batch_first=True, - ) - self.transformer_encoder = nn.TransformerEncoder( - encoder_layer, - num_layers=num_layers, - ) - - # Output projection to vocabulary size - self.output_layer = nn.Linear(d_model, vocab_size) - self.dropout = nn.Dropout(dropout) - - def forward( - self, - x_tokens: torch.LongTensor, - type_ids: torch.LongTensor, - ) -> torch.Tensor: - """ - Forward pass of the Transformer encoder. - - Args: - x_tokens (LongTensor): Tensor of shape (batch_size, seq_len) with token IDs - type_ids (LongTensor): Tensor of shape (batch_size, seq_len) with type IDs - - Returns: - logits (Tensor): Unnormalized scores of shape (batch_size, seq_len, vocab_size) - """ - batch_size, seq_len = x_tokens.size() - if seq_len > self.max_seq_len: - raise ValueError( - f"Sequence length {seq_len} exceeds maximum {self.max_seq_len}." - ) - - # Create position IDs (batch_size, seq_len) - position_ids = torch.arange(seq_len, device=x_tokens.device) - position_ids = position_ids.unsqueeze(0).expand(batch_size, seq_len) - - # Embeddings - token_emb = self.token_embedding(x_tokens) - type_emb = self.type_embedding(type_ids) - pos_emb = self.position_embedding(position_ids) - - # Combine embeddings and apply dropout - x = token_emb + type_emb + pos_emb - x = self.dropout(x) - - # Transformer encoder (batch_first=True) - x = self.transformer_encoder(x) - - # Project back to vocabulary - logits = self.output_layer(x) - return logits - - -# Example usage: -# model = TeamTransformer( -# max_seq_len=64, -# d_model=256, -# nhead=4, -# num_layers=3, -# ) -# x_tokens = torch.randint(0, 10000, (32, 64)) -# type_ids = torch.randint(0, 7, (32, 64)) -# logits = model(x_tokens, type_ids) -# print(logits.shape) # (32, 64, 10000) diff --git a/metamon/backend/team_prediction/prediction_metrics.py b/metamon/backend/team_prediction/prediction_metrics.py new file mode 100644 index 0000000000..0f4aa5c369 --- /dev/null +++ b/metamon/backend/team_prediction/prediction_metrics.py @@ -0,0 +1,533 @@ +import torch +import torch.nn.functional as F +from typing import Dict, Optional +from collections import defaultdict + + +class TeamPredictionMetrics: + """Computes evaluation metrics for team prediction.""" + + def __init__(self, vocab): + self.vocab = vocab + self.attribute_weights = vocab.attribute_weights + + def compute_all_metrics( + self, + logits: torch.Tensor, + y_tokens: torch.Tensor, + pred_mask: torch.Tensor, + type_ids: torch.Tensor, + ) -> Dict[str, float]: + """Compute all evaluation metrics.""" + metrics = {} + + # Basic accuracy + metrics["token_accuracy"] = self._token_accuracy(logits, y_tokens, pred_mask) + + # Weighted accuracy (emphasize important attributes) + metrics["weighted_accuracy"] = self._weighted_accuracy( + logits, y_tokens, pred_mask, type_ids + ) + + # Per-attribute accuracy + attr_metrics = self._per_attribute_accuracy( + logits, y_tokens, pred_mask, type_ids + ) + metrics.update(attr_metrics) + + # Top-k accuracy + for k in [3, 5, 10]: + metrics[f"top_{k}_accuracy"] = self._topk_accuracy( + logits, y_tokens, pred_mask, k + ) + + # Confidence calibration + metrics["confidence"] = self._average_confidence(logits, pred_mask) + metrics["calibration_error"] = self._calibration_error( + logits, y_tokens, pred_mask + ) + + return metrics + + def _token_accuracy( + self, + logits: torch.Tensor, + y_tokens: torch.Tensor, + pred_mask: torch.Tensor, + ) -> float: + """Standard token-level accuracy.""" + preds = logits.argmax(dim=-1) + correct = ((preds == y_tokens) * pred_mask).sum().item() + total = max(pred_mask.sum().item(), 1) + return correct / total + + def _weighted_accuracy( + self, + logits: torch.Tensor, + y_tokens: torch.Tensor, + pred_mask: torch.Tensor, + type_ids: torch.Tensor, + ) -> float: + """Accuracy weighted by attribute importance.""" + preds = logits.argmax(dim=-1) + correct = (preds == y_tokens) * pred_mask + + # Create weight tensor + weights = torch.ones_like(pred_mask, dtype=torch.float32) + + for type_name, weight in self.attribute_weights.items(): + type_id = self.vocab.type_ids.get(type_name) + if type_id is not None: + weights[type_ids == type_id] = weight + + weighted_correct = (correct.float() * weights).sum().item() + weighted_total = (pred_mask.float() * weights).sum().item() + + return weighted_correct / max(weighted_total, 1.0) + + def _per_attribute_accuracy( + self, + logits: torch.Tensor, + y_tokens: torch.Tensor, + pred_mask: torch.Tensor, + type_ids: torch.Tensor, + ) -> Dict[str, float]: + """Compute accuracy separately for each attribute type.""" + preds = logits.argmax(dim=-1) + correct = (preds == y_tokens) * pred_mask + + metrics = {} + + for type_name, type_id in self.vocab.type_ids.items(): + # Skip format (always correct, not predicted) + if type_name == "Format": + continue + + # Get mask for this attribute type + attr_mask = (type_ids == type_id) & pred_mask + + if attr_mask.sum() > 0: + attr_correct = (correct * attr_mask).sum().item() + attr_total = attr_mask.sum().item() + accuracy = attr_correct / attr_total + + # Use lowercase with underscores for metric name + metric_name = f"{type_name.lower().replace(' ', '_')}_accuracy" + metrics[metric_name] = accuracy + + return metrics + + def _topk_accuracy( + self, + logits: torch.Tensor, + y_tokens: torch.Tensor, + pred_mask: torch.Tensor, + k: int, + ) -> float: + """Top-k accuracy: is the correct token in the top-k predictions?""" + # Get top-k predictions + topk_preds = logits.topk(k, dim=-1).indices # [batch, seq_len, k] + + # Check if ground truth is in top-k + y_expanded = y_tokens.unsqueeze(-1).expand_as(topk_preds) + in_topk = (topk_preds == y_expanded).any(dim=-1) + + correct = (in_topk * pred_mask).sum().item() + total = max(pred_mask.sum().item(), 1) + + return correct / total + + def _average_confidence( + self, + logits: torch.Tensor, + pred_mask: torch.Tensor, + ) -> float: + """Average confidence (max probability) of predictions.""" + probs = F.softmax(logits, dim=-1) + max_probs = probs.max(dim=-1).values + + masked_probs = max_probs * pred_mask + avg_confidence = masked_probs.sum().item() / max(pred_mask.sum().item(), 1) + + return avg_confidence + + def _calibration_error( + self, + logits: torch.Tensor, + y_tokens: torch.Tensor, + pred_mask: torch.Tensor, + num_bins: int = 10, + ) -> float: + """Expected Calibration Error (ECE).""" + probs = F.softmax(logits, dim=-1) + confidences = probs.max(dim=-1).values + predictions = logits.argmax(dim=-1) + accuracies = (predictions == y_tokens).float() + + # Only consider masked positions + confidences = confidences[pred_mask] + accuracies = accuracies[pred_mask] + + if len(confidences) == 0: + return 0.0 + + # Bin predictions by confidence + ece = 0.0 + bin_boundaries = torch.linspace(0, 1, num_bins + 1) + + for i in range(num_bins): + bin_lower = bin_boundaries[i] + bin_upper = bin_boundaries[i + 1] + + # Find predictions in this bin + in_bin = (confidences > bin_lower) & (confidences <= bin_upper) + + if in_bin.sum() > 0: + bin_confidence = confidences[in_bin].mean() + bin_accuracy = accuracies[in_bin].mean() + bin_size = in_bin.sum().float() + + # Weighted by bin size + ece += (bin_size / len(confidences)) * abs( + bin_confidence - bin_accuracy + ) + + return ece.item() + + +class EvaluationAccumulator: + """Accumulates evaluation statistics across batches for per-generation metrics.""" + + def __init__(self, vocab): + self.vocab = vocab + self.metrics_computer = TeamPredictionMetrics(vocab) + self.attribute_weights = vocab.attribute_weights + + self.total_correct = 0 + self.total_count = 0 + self.total_loss = 0.0 + self.num_batches = 0 + self.weighted_correct = 0.0 + self.weighted_total = 0.0 + + self.gen_stats = defaultdict(lambda: {"correct": 0, "total": 0}) + self.attr_stats = defaultdict(lambda: {"correct": 0, "total": 0}) + self.gen_attr_stats = defaultdict( + lambda: defaultdict(lambda: {"correct": 0, "total": 0}) + ) + + def _extract_gen_from_tokens(self, x_tokens: torch.Tensor) -> torch.Tensor: + """Extract generation number for each sample from the format token.""" + batch_size = x_tokens.shape[0] + gens = torch.zeros(batch_size, dtype=torch.long, device=x_tokens.device) + + # First token is the format token + format_token_ids = x_tokens[:, 0] + + for i, token_id in enumerate(format_token_ids): + gens[i] = self.vocab.format_token_to_gen.get(token_id.item(), 0) + + return gens + + def add_batch( + self, + logits: torch.Tensor, + y_tokens: torch.Tensor, + pred_mask: torch.Tensor, + type_ids: torch.Tensor, + x_tokens: torch.Tensor, + loss: Optional[torch.Tensor] = None, + ): + """Add a batch to the accumulator.""" + preds = logits.argmax(dim=-1) + correct = (preds == y_tokens) & pred_mask + + # Extract generation for each sample + gens = self._extract_gen_from_tokens(x_tokens) + + # Overall stats + self.total_correct += correct.sum().item() + self.total_count += pred_mask.sum().item() + if loss is not None: + self.total_loss += loss.item() + self.num_batches += 1 + + # Weighted stats - create weight tensor + weights = torch.ones_like(pred_mask, dtype=torch.float32) + for type_name, weight in self.attribute_weights.items(): + type_id = self.vocab.type_ids.get(type_name) + if type_id is not None: + weights[type_ids == type_id] = weight + self.weighted_correct += (correct.float() * weights).sum().item() + self.weighted_total += (pred_mask.float() * weights).sum().item() + + # Per-generation stats + for b in range(x_tokens.shape[0]): + gen = gens[b].item() + sample_correct = correct[b].sum().item() + sample_total = pred_mask[b].sum().item() + + self.gen_stats[gen]["correct"] += sample_correct + self.gen_stats[gen]["total"] += sample_total + + # Per-attribute stats for this sample + for type_name, type_id in self.vocab.type_ids.items(): + if type_name == "Format": + continue + + attr_mask = (type_ids[b] == type_id) & pred_mask[b] + if attr_mask.sum() > 0: + attr_correct = (correct[b] & attr_mask).sum().item() + attr_total = attr_mask.sum().item() + + # Overall per-attribute + self.attr_stats[type_name]["correct"] += attr_correct + self.attr_stats[type_name]["total"] += attr_total + + # Per-gen per-attribute + self.gen_attr_stats[gen][type_name]["correct"] += attr_correct + self.gen_attr_stats[gen][type_name]["total"] += attr_total + + def compute_metrics(self) -> Dict[str, float]: + """Compute final metrics from accumulated statistics.""" + metrics = {} + + # Overall accuracy + metrics["token_accuracy"] = self.total_correct / max(self.total_count, 1) + metrics["weighted_accuracy"] = self.weighted_correct / max( + self.weighted_total, 1.0 + ) + metrics["loss"] = self.total_loss / max(self.num_batches, 1) + + # Per-attribute accuracy (overall) + for attr_name, stats in self.attr_stats.items(): + if stats["total"] > 0: + metric_name = f"{attr_name.lower().replace(' ', '_')}_accuracy" + metrics[metric_name] = stats["correct"] / stats["total"] + + # Per-generation accuracy + for gen, stats in sorted(self.gen_stats.items()): + if gen > 0 and stats["total"] > 0: # Skip unknown gen (0) + metrics[f"gen{gen}_accuracy"] = stats["correct"] / stats["total"] + metrics[f"gen{gen}_count"] = stats["total"] + + # Per-generation per-attribute (only for gens with data) + for gen, attr_dict in sorted(self.gen_attr_stats.items()): + if gen == 0: + continue + for attr_name, stats in attr_dict.items(): + if stats["total"] > 0: + metric_name = ( + f"gen{gen}_{attr_name.lower().replace(' ', '_')}_accuracy" + ) + metrics[metric_name] = stats["correct"] / stats["total"] + + return metrics + + +def compute_loss_and_metrics( + logits: torch.Tensor, + y_tokens: torch.Tensor, + pred_mask: torch.Tensor, + type_ids: torch.Tensor, + vocab, +) -> tuple[torch.Tensor, Dict[str, float]]: + """Combined loss and metrics computation for training.""" + vocab_size = logits.shape[-1] + + loss = F.cross_entropy( + logits.view(-1, vocab_size), + y_tokens.view(-1), + reduction="none", + ) + num_preds = max(pred_mask.sum().item(), 1) + loss = (loss * pred_mask.view(-1)).sum() / num_preds + + metrics_computer = TeamPredictionMetrics(vocab) + metrics = metrics_computer.compute_all_metrics( + logits, y_tokens, pred_mask, type_ids + ) + + return loss, metrics + + +def compute_semantic_metrics( + pred_tokens: torch.Tensor, + y_tokens: torch.Tensor, + x_tokens: torch.Tensor, + t2s, +) -> Dict[str, float]: + """ + Compute semantic metrics by converting to TeamSet and comparing. + + Matches pokemon by name and moves as sets (avoids position-based comparison). + """ + from metamon.backend.team_prediction.team import PokemonSet as P + + stats = defaultdict(lambda: {"correct": 0, "total": 0}) + + batch_size = pred_tokens.shape[0] + for b in range(batch_size): + pred_team = t2s.decode(pred_tokens[b]) + true_team = t2s.decode(y_tokens[b]) + input_team = t2s.decode(x_tokens[b]) + + # Build aligned lists: (input_pokemon, pred_pokemon, true_pokemon) + all_pokemon = [ + (input_team.lead, pred_team.lead, true_team.lead), + ] + list(zip(input_team.reserve, pred_team.reserve, true_team.reserve)) + + for input_p, pred_p, true_p in all_pokemon: + # Was this Pokemon's name masked? + name_was_masked = input_p.name == P.MISSING_NAME + name_has_label = true_p.name != P.MISSING_NAME + + if name_was_masked and name_has_label: + stats["pokemon"]["total"] += 1 + if pred_p.name == true_p.name: + stats["pokemon"]["correct"] += 1 + # Only compare attributes if Pokemon name was correct + _compare_pokemon_attrs(input_p, pred_p, true_p, stats) + elif not name_was_masked: + # Pokemon name was visible, compare attributes + _compare_pokemon_attrs(input_p, pred_p, true_p, stats) + + # Compute final metrics + metrics = {} + for attr_name, counts in stats.items(): + if counts["total"] > 0: + metrics[f"semantic_{attr_name}_accuracy"] = ( + counts["correct"] / counts["total"] + ) + metrics[f"semantic_{attr_name}_total"] = counts["total"] + + return metrics + + +def _compare_pokemon_attrs(input_p, pred_p, true_p, stats, gen_stats=None): + """Set-based attribute comparison for matched Pokemon.""" + from metamon.backend.team_prediction.team import PokemonSet as P + + def _incr(attr: str, correct: bool): + stats[attr]["total"] += 1 + if correct: + stats[attr]["correct"] += 1 + if gen_stats is not None: + gen_stats[attr]["total"] += 1 + if correct: + gen_stats[attr]["correct"] += 1 + + # Moves: set-based comparison + # Get ground truth moves that were masked and have real labels + true_labeled_moves = set() + for input_m, true_m in zip(input_p.moves, true_p.moves): + if input_m == P.MISSING_MOVE and true_m not in (P.MISSING_MOVE, P.NO_MOVE): + true_labeled_moves.add(true_m) + + # Get predicted moves (excluding missing/nomove) + pred_moves = set(m for m in pred_p.moves if m not in (P.MISSING_MOVE, P.NO_MOVE)) + + # Count how many ground truth moves are in predicted set + for move in true_labeled_moves: + _incr("move", move in pred_moves) + + # Ability - only count if masked AND ground truth is a real ability + if input_p.ability == P.MISSING_ABILITY and true_p.ability not in ( + P.MISSING_ABILITY, + P.NO_ABILITY, + ): + _incr("ability", pred_p.ability == true_p.ability) + + # Item - only count if masked AND ground truth is a real item + if input_p.item == P.MISSING_ITEM and true_p.item not in ( + P.MISSING_ITEM, + P.NO_ITEM, + ): + _incr("item", pred_p.item == true_p.item) + + # Tera type - only count if masked AND ground truth is a real tera + if input_p.tera_type == P.MISSING_TERA_TYPE and true_p.tera_type not in ( + P.MISSING_TERA_TYPE, + P.NO_TERA_TYPE, + ): + _incr("tera", pred_p.tera_type == true_p.tera_type) + + +class SemanticMetricsAccumulator: + """Accumulates semantic metrics across batches with per-generation tracking.""" + + def __init__(self, vocab): + self.vocab = vocab + # Overall stats + self.stats = defaultdict(lambda: {"correct": 0, "total": 0}) + # Per-generation stats + self.gen_stats = defaultdict( + lambda: defaultdict(lambda: {"correct": 0, "total": 0}) + ) + + def _extract_gen_from_tokens(self, x_tokens: torch.Tensor) -> int: + """Extract generation number from the format token (first token).""" + format_token_id = x_tokens[0].item() + return self.vocab.format_token_to_gen.get(format_token_id, 0) + + def add_batch( + self, + pred_tokens: torch.Tensor, + y_tokens: torch.Tensor, + x_tokens: torch.Tensor, + t2s, + ): + """Add a batch of predictions to the accumulator.""" + from metamon.backend.team_prediction.team import PokemonSet as P + + batch_size = pred_tokens.shape[0] + for b in range(batch_size): + gen = self._extract_gen_from_tokens(x_tokens[b]) + pred_team = t2s.decode(pred_tokens[b]) + true_team = t2s.decode(y_tokens[b]) + input_team = t2s.decode(x_tokens[b]) + + all_pokemon = [ + (input_team.lead, pred_team.lead, true_team.lead), + ] + list(zip(input_team.reserve, pred_team.reserve, true_team.reserve)) + + for input_p, pred_p, true_p in all_pokemon: + name_was_masked = input_p.name == P.MISSING_NAME + name_has_label = true_p.name != P.MISSING_NAME + + if name_was_masked and name_has_label: + self.stats["pokemon"]["total"] += 1 + self.gen_stats[gen]["pokemon"]["total"] += 1 + if pred_p.name == true_p.name: + self.stats["pokemon"]["correct"] += 1 + self.gen_stats[gen]["pokemon"]["correct"] += 1 + _compare_pokemon_attrs( + input_p, pred_p, true_p, self.stats, self.gen_stats[gen] + ) + elif not name_was_masked: + _compare_pokemon_attrs( + input_p, pred_p, true_p, self.stats, self.gen_stats[gen] + ) + + def compute_metrics(self) -> Dict[str, float]: + """Compute final metrics from accumulated stats.""" + metrics = {} + + # Overall semantic metrics + for attr_name, counts in self.stats.items(): + if counts["total"] > 0: + metrics[f"{attr_name}_accuracy"] = counts["correct"] / counts["total"] + metrics[f"{attr_name}_total"] = counts["total"] + + # Per-generation semantic metrics + for gen, gen_attr_stats in sorted(self.gen_stats.items()): + if gen == 0: + continue # Skip unknown gen + for attr_name, counts in gen_attr_stats.items(): + if counts["total"] > 0: + metrics[f"gen{gen}_{attr_name}_accuracy"] = ( + counts["correct"] / counts["total"] + ) + metrics[f"gen{gen}_{attr_name}_total"] = counts["total"] + + return metrics diff --git a/metamon/backend/team_prediction/prediction_model.py b/metamon/backend/team_prediction/prediction_model.py new file mode 100644 index 0000000000..7c9e96ac7a --- /dev/null +++ b/metamon/backend/team_prediction/prediction_model.py @@ -0,0 +1,534 @@ +import torch +import torch.nn as nn +from pathlib import Path +from typing import Any, Dict, List, Optional, Type, Union + +from metamon.backend.team_prediction.team import TeamSet, Team2Seq +from metamon.backend.team_prediction.vocabulary import Vocabulary, get_vocab +from metamon.backend.team_prediction.iterative_decoder import ( + Decoder, + IterativeTeamDecoder, + IterativeDecodingStats, + OneShotDecoder, +) + +################################## +## Neural Network Architectures ## +################################## + + +class TeamTransformer(nn.Module): + """Transformer encoder for team prediction (token + type + position embeddings).""" + + def __init__( + self, + include_stats: bool = False, + d_model: int = 512, + nhead: int = 8, + num_layers: int = 6, + dim_feedforward: int = 2048, + dropout: float = 0.1, + norm_first: bool = True, + ): + super().__init__() + self.include_stats = include_stats + self.seq_len = Team2Seq.seq_len(include_stats) + self.vocab = Vocabulary() + vocab_size = len(self.vocab.tokenizer) + type_vocab_size = max(self.vocab.type_ids.values()) + 1 + self.d_model = d_model + self.token_embedding = nn.Embedding(vocab_size, d_model) + self.type_embedding = nn.Embedding(type_vocab_size, d_model) + self.position_embedding = nn.Embedding(self.seq_len, d_model) + encoder_layer = nn.TransformerEncoderLayer( + d_model=d_model, + nhead=nhead, + dim_feedforward=dim_feedforward, + dropout=dropout, + activation="gelu", + batch_first=True, + norm_first=norm_first, + ) + self.transformer_encoder = nn.TransformerEncoder( + encoder_layer, + num_layers=num_layers, + norm=nn.LayerNorm(d_model) if norm_first else None, + ) + + self.output_layer = nn.Linear(d_model, vocab_size) + self.dropout = nn.Dropout(dropout) + + @torch.compile + def forward( + self, + x_tokens: torch.LongTensor, + type_ids: torch.LongTensor, + ) -> torch.Tensor: + batch_size, seq_len = x_tokens.size() + if seq_len > self.seq_len: + raise ValueError( + f"Sequence length {seq_len} exceeds expected {self.seq_len}." + ) + + position_ids = torch.arange(seq_len, device=x_tokens.device) + position_ids = position_ids.unsqueeze(0).expand(batch_size, seq_len) + token_emb = self.token_embedding(x_tokens) + type_emb = self.type_embedding(type_ids) + pos_emb = self.position_embedding(position_ids) + x = token_emb + type_emb + pos_emb + x = self.dropout(x) + x = self.transformer_encoder(x) + logits = self.output_layer(x) + return logits + + +class LocalGlobalTeamTransformer(nn.Module): + """ + Alternating local (per-pokemon) and global (full-team) attention. + + Local attention: Each pokemon attends to its own tokens + format token. + Global attention: Full sequence attention over the entire team. + + The format token embedding is kept constant throughout - it provides + context but is never updated since it never needs predicting. + """ + + NUM_POKEMON = 6 + + def __init__( + self, + include_stats: bool = False, + d_model: int = 512, + nhead: int = 8, + num_blocks: int = 6, + dim_feedforward: int = 2048, + dropout: float = 0.1, + norm_first: bool = True, + ): + super().__init__() + self.include_stats = include_stats + self.seq_len = Team2Seq.seq_len(include_stats) + self.attrs_per_pokemon = ( + Team2Seq.ATTRS_PER_POKEMON_WITH_STATS + if include_stats + else Team2Seq.ATTRS_PER_POKEMON_BASE + ) + self.num_blocks = num_blocks + self.vocab = Vocabulary() + vocab_size = len(self.vocab.tokenizer) + type_vocab_size = max(self.vocab.type_ids.values()) + 1 + self.d_model = d_model + self.token_embedding = nn.Embedding(vocab_size, d_model) + self.type_embedding = nn.Embedding(type_vocab_size, d_model) + self.position_embedding = nn.Embedding(self.seq_len, d_model) + + self.local_layers = nn.ModuleList( + [ + nn.TransformerEncoderLayer( + d_model=d_model, + nhead=nhead, + dim_feedforward=dim_feedforward, + dropout=dropout, + activation="gelu", + batch_first=True, + norm_first=norm_first, + ) + for _ in range(num_blocks) + ] + ) + + self.global_layers = nn.ModuleList( + [ + nn.TransformerEncoderLayer( + d_model=d_model, + nhead=nhead, + dim_feedforward=dim_feedforward, + dropout=dropout, + activation="gelu", + batch_first=True, + norm_first=norm_first, + ) + for _ in range(num_blocks) + ] + ) + + self.final_norm = nn.LayerNorm(d_model) if norm_first else None + self.output_layer = nn.Linear(d_model, vocab_size) + self.dropout = nn.Dropout(dropout) + + def _fold_for_local( + self, pokemon_emb: torch.Tensor, format_emb: torch.Tensor + ) -> torch.Tensor: + """(batch, 6*attrs, d) + format -> (batch*6, 1+attrs, d).""" + batch_size = pokemon_emb.size(0) + pokemon_emb = pokemon_emb.view( + batch_size, self.NUM_POKEMON, self.attrs_per_pokemon, self.d_model + ) + format_expanded = format_emb.unsqueeze(1).expand( + batch_size, self.NUM_POKEMON, 1, self.d_model + ) + local_seq = torch.cat([format_expanded, pokemon_emb], dim=2) + local_seq = local_seq.view( + batch_size * self.NUM_POKEMON, 1 + self.attrs_per_pokemon, self.d_model + ) + return local_seq + + def _unfold_from_local( + self, local_out: torch.Tensor, batch_size: int + ) -> torch.Tensor: + """(batch*6, 1+attrs, d) -> (batch, 6*attrs, d), dropping format token.""" + local_out = local_out.view( + batch_size, self.NUM_POKEMON, 1 + self.attrs_per_pokemon, self.d_model + ) + pokemon_emb = local_out[:, :, 1:, :] + pokemon_emb = pokemon_emb.reshape( + batch_size, self.NUM_POKEMON * self.attrs_per_pokemon, self.d_model + ) + return pokemon_emb + + @torch.compile + def forward( + self, + x_tokens: torch.LongTensor, + type_ids: torch.LongTensor, + ) -> torch.Tensor: + batch_size, seq_len = x_tokens.size() + if seq_len > self.seq_len: + raise ValueError( + f"Sequence length {seq_len} exceeds expected {self.seq_len}." + ) + + # standard embedding (tokens, position, types) + position_ids = torch.arange(seq_len, device=x_tokens.device) + position_ids = position_ids.unsqueeze(0).expand(batch_size, seq_len) + token_emb = self.token_embedding(x_tokens) + type_emb = self.type_embedding(type_ids) + pos_emb = self.position_embedding(position_ids) + x = token_emb + type_emb + pos_emb + x = self.dropout(x) + + # split format token (constant) from pokemon tokens (updated) + format_emb = x[:, 0:1, :] # (batch, 1, d_model) - kept constant + pokemon_emb = x[:, 1:, :] # (batch, 6*attrs, d_model) - gets updated + + # alternating local and global attention + for local_layer, global_layer in zip(self.local_layers, self.global_layers): + # local attention: each pokemon sees format + its own tokens + local_in = self._fold_for_local(pokemon_emb, format_emb) + local_out = local_layer(local_in) + pokemon_emb = self._unfold_from_local(local_out, batch_size) + # global attention: full sequence + global_in = torch.cat([format_emb, pokemon_emb], dim=1) + global_out = global_layer(global_in) + pokemon_emb = global_out[:, 1:, :] # discard format output + + output = torch.cat([format_emb, pokemon_emb], dim=1) + if self.final_norm is not None: + output = self.final_norm(output) + logits = self.output_layer(output) + return logits + + +############################## +## High-Level Model Wrapper ## +############################## + + +class TeamPredictionModel: + """ + High-level wrapper: TeamSet <-> tensors for training and inference. + + Decoder options go in iterative_decoder_kwargs / oneshot_decoder_kwargs. + """ + + def __init__( + self, + model_class: Type[nn.Module] = TeamTransformer, + model_kwargs: Optional[Dict[str, Any]] = None, + iterative_decoder_kwargs: Optional[Dict[str, Any]] = None, + oneshot_decoder_kwargs: Optional[Dict[str, Any]] = None, + include_stats: bool = False, + device: Optional[Union[str, torch.device]] = None, + ): + self.model_class = model_class + self.model_kwargs = model_kwargs or {} + self.iterative_decoder_kwargs = iterative_decoder_kwargs or {} + self.oneshot_decoder_kwargs = oneshot_decoder_kwargs or {} + self.include_stats = include_stats + + # Determine device + if device is None: + self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu") + else: + self.device = torch.device(device) + + # Initialize components + self.vocab = get_vocab() + self.t2s = Team2Seq(include_stats=include_stats) + + # Create model (pass include_stats for seq length calculation) + model_kwargs_with_stats = {"include_stats": include_stats, **self.model_kwargs} + self._model = self.model_class(**model_kwargs_with_stats).to(self.device) + + # Create decoders (lazy - only when needed) + self._iterative_decoder: Optional[IterativeTeamDecoder] = None + self._oneshot_decoder: Optional[OneShotDecoder] = None + + @property + def model(self) -> nn.Module: + """The underlying nn.Module.""" + return self._model + + @property + def iterative_decoder(self) -> IterativeTeamDecoder: + """The iterative decoder (created lazily).""" + if self._iterative_decoder is None: + self._iterative_decoder = IterativeTeamDecoder( + model=self._model, + include_stats=self.include_stats, + **self.iterative_decoder_kwargs, + ) + return self._iterative_decoder + + @property + def oneshot_decoder(self) -> OneShotDecoder: + """The one-shot decoder (created lazily).""" + if self._oneshot_decoder is None: + self._oneshot_decoder = OneShotDecoder( + model=self._model, + include_stats=self.include_stats, + **self.oneshot_decoder_kwargs, + ) + return self._oneshot_decoder + + def forward( + self, + x_tokens: torch.Tensor, + type_ids: torch.Tensor, + ) -> torch.Tensor: + """Training forward: logits for loss computation.""" + return self._model(x_tokens, type_ids) + + def predict( + self, + team: TeamSet, + return_stats: bool = False, + ) -> Union[TeamSet, tuple[TeamSet, IterativeDecodingStats]]: + """Fill $missing_*$ tokens for one team (user-facing inference).""" + return self.predict_batch([team], return_stats=return_stats)[0] + + def predict_batch( + self, + teams: List[TeamSet], + return_stats: bool = False, + ) -> Union[List[TeamSet], List[tuple[TeamSet, IterativeDecodingStats]]]: + """Fill $missing_*$ tokens for a batch of teams.""" + self._model.eval() + + if not teams: + return [] + + # Encode all teams + batch_x, batch_type_ids, batch_mask = [], [], [] + for team in teams: + x_tokens, type_ids, pred_mask = self.t2s.encode(team) + batch_x.append(x_tokens) + batch_type_ids.append(type_ids) + batch_mask.append(pred_mask) + + # Stack into batches + x_tokens = torch.stack(batch_x).to(self.device) + type_ids = torch.stack(batch_type_ids).to(self.device) + pred_mask = torch.stack(batch_mask).to(self.device) + + # Run iterative decoding + with torch.no_grad(): + pred_tokens, stats = self.iterative_decoder.decode( + x_tokens, type_ids, pred_mask, track_tokens=return_stats + ) + + # Decode back to TeamSets + results = [self.t2s.decode(pred_tokens[i]) for i in range(len(teams))] + + if return_stats: + # Return list of (team, stats) tuples + # Note: stats is shared across batch, individual tracking would need changes + return [(team, stats) for team in results] + return results + + def iterative_forward( + self, + x_tokens: torch.Tensor, + type_ids: torch.Tensor, + pred_mask: torch.Tensor, + track_tokens: bool = False, + ) -> tuple[torch.Tensor, IterativeDecodingStats]: + """ + Tensor-level iterative decoding for eval (pre-encoded x/y pairs). + + Unlike predict(), does not round-trip through TeamSet. + """ + return self.iterative_decoder.decode( + x_tokens, type_ids, pred_mask, track_tokens + ) + + def oneshot_forward( + self, + x_tokens: torch.Tensor, + type_ids: torch.Tensor, + pred_mask: torch.Tensor, + ) -> torch.Tensor: + """One-shot decode for comparison with iterative decoding during eval.""" + return self.oneshot_decoder.decode(x_tokens, type_ids, pred_mask) + + def save_checkpoint( + self, + path: Union[str, Path], + optimizer: Optional[torch.optim.Optimizer] = None, + extra_state: Optional[Dict[str, Any]] = None, + ) -> None: + """Save checkpoint (optional optimizer and extra_state).""" + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + + checkpoint = { + "model_state_dict": self._model.state_dict(), + "model_class": self.model_class.__name__, + "model_kwargs": self.model_kwargs, + "iterative_decoder_kwargs": self.iterative_decoder_kwargs, + "oneshot_decoder_kwargs": self.oneshot_decoder_kwargs, + "include_stats": self.include_stats, + } + + if optimizer is not None: + checkpoint["optimizer_state_dict"] = optimizer.state_dict() + + if extra_state is not None: + checkpoint["extra_state"] = extra_state + + torch.save(checkpoint, path) + + def load_checkpoint( + self, + path: Union[str, Path], + optimizer: Optional[torch.optim.Optimizer] = None, + strict: bool = True, + ) -> Dict[str, Any]: + """Load checkpoint; returns saved extra_state (or {}).""" + checkpoint = torch.load(path, map_location=self.device, weights_only=False) + + self._model.load_state_dict(checkpoint["model_state_dict"], strict=strict) + + if optimizer is not None and "optimizer_state_dict" in checkpoint: + optimizer.load_state_dict(checkpoint["optimizer_state_dict"]) + + # Reset decoders since model changed + self._iterative_decoder = None + self._oneshot_decoder = None + + return checkpoint.get("extra_state", {}) + + def train(self) -> "TeamPredictionModel": + """Set model to training mode.""" + self._model.train() + return self + + def eval(self) -> "TeamPredictionModel": + """Set model to evaluation mode.""" + self._model.eval() + return self + + def to(self, device: Union[str, torch.device]) -> "TeamPredictionModel": + """Move model to device.""" + self.device = torch.device(device) + self._model.to(self.device) + # Reset decoders since they hold reference to model + self._iterative_decoder = None + self._oneshot_decoder = None + return self + + def parameters(self): + """Return model parameters (for optimizer).""" + return self._model.parameters() + + def state_dict(self): + """Return model state dict.""" + return self._model.state_dict() + + def load_state_dict(self, state_dict, strict=True): + """Load model state dict.""" + self._model.load_state_dict(state_dict, strict=strict) + self._iterative_decoder = None + self._oneshot_decoder = None + + +def create_model( + model_type: str = "TeamTransformer", + d_model: int = 512, + nhead: int = 8, + num_layers: int = 6, + dim_feedforward: int = 2048, + dropout: float = 0.1, + # Iterative decoder settings + num_iterations: int = 8, + iterative_temperature: float = 1.0, + iterative_top_p: float = 0.9, + iterative_deterministic: bool = False, + # One-shot decoder settings + oneshot_temperature: float = 1.0, + oneshot_top_p: float = 0.9, + oneshot_deterministic: bool = True, # Argmax by default for one-shot + include_stats: bool = False, + device: Optional[str] = None, +) -> TeamPredictionModel: + """Factory for TeamPredictionModel with gin-friendly defaults.""" + model_classes = { + "TeamTransformer": TeamTransformer, + "LocalGlobalTeamTransformer": LocalGlobalTeamTransformer, + } + + if model_type not in model_classes: + raise ValueError( + f"Unknown model type: {model_type}. " + f"Available: {list(model_classes.keys())}" + ) + + # Build model kwargs based on architecture + if model_type == "LocalGlobalTeamTransformer": + model_kwargs = { + "d_model": d_model, + "nhead": nhead, + "num_blocks": num_layers, # LocalGlobal uses num_blocks + "dim_feedforward": dim_feedforward, + "dropout": dropout, + } + else: + model_kwargs = { + "d_model": d_model, + "nhead": nhead, + "num_layers": num_layers, + "dim_feedforward": dim_feedforward, + "dropout": dropout, + } + + iterative_decoder_kwargs = { + "num_iterations": num_iterations, + "temperature": iterative_temperature, + "top_p": iterative_top_p, + "deterministic": iterative_deterministic, + } + + oneshot_decoder_kwargs = { + "temperature": oneshot_temperature, + "top_p": oneshot_top_p, + "deterministic": oneshot_deterministic, + } + + return TeamPredictionModel( + model_class=model_classes[model_type], + model_kwargs=model_kwargs, + iterative_decoder_kwargs=iterative_decoder_kwargs, + oneshot_decoder_kwargs=oneshot_decoder_kwargs, + include_stats=include_stats, + device=device, + ) diff --git a/metamon/backend/team_prediction/predictor.py b/metamon/backend/team_prediction/predictor.py index a4247b985f..40f73f3da3 100644 --- a/metamon/backend/team_prediction/predictor.py +++ b/metamon/backend/team_prediction/predictor.py @@ -17,14 +17,21 @@ ) from metamon.backend.team_prediction.usage_stats import ( PreloadedSmogonUsageStats, + DEFAULT_USAGE_RANK, + resolve_usage_rank, ) from metamon.backend.replay_parser.str_parsing import pokemon_name from metamon.backend.team_prediction.team import TeamSet, PokemonSet, Roster class TeamPredictor(ABC): - def __init__(self, replay_stats_dir: Optional[str] = None): + def __init__( + self, + replay_stats_dir: Optional[str] = None, + usage_stats_rank: int = DEFAULT_USAGE_RANK, + ): self.replay_stats_dir = replay_stats_dir + self.usage_stats_rank = usage_stats_rank def bin_usage_stats_dates( self, date: datetime.date @@ -44,26 +51,70 @@ def bin_usage_stats_dates( end_date = datetime.date(year, 12, 1) return start_date, end_date - def get_legacy_team_builder(self, format: str, date: datetime.date) -> TeamBuilder: + def get_legacy_team_builder( + self, + format: str, + date: datetime.date, + rating: Optional[int | str] = None, + gameid: Optional[str] = None, + ) -> TeamBuilder: + """ + Build a TeamBuilder using two independent axes: + + - **Time window** (from battle date): ``bin_usage_stats_dates`` picks the + half-year bin containing the battle, then monthly JSON files in that + range are merged (e.g. Feb 2026 battle -> Jan 1 .. Jun 1 2026). + - **Skill tier** (from player rating/gameid): ``resolve_usage_rank`` picks + the Smogon Glicko cutoff subdir (0, 1500, 1630, 1760, ...). + + Final path shape: ``movesets_data/gen{N}/{tier}/{rank}/{YYYY-MM}.json``. + Per-Pokemon field gaps may still be filled from lower ranks or all_tiers + at lookup time inside ``PreloadedSmogonUsageStats._inclusive_search``; + that is a separate completeness fallback, not a substitute for rank + or date selection. + """ start_date, end_date = self.bin_usage_stats_dates(date) + if rating is not None or gameid is not None: + rank = resolve_usage_rank(format, rating=rating, gameid=gameid) + else: + rank = self.usage_stats_rank return TeamBuilder( format=format, start_date=start_date, end_date=end_date, + rank=rank, ) def get_usage_stats( - self, format: str, date: datetime.date + self, + format: str, + date: datetime.date, + rating: Optional[int | str] = None, + gameid: Optional[str] = None, ) -> PreloadedSmogonUsageStats: # route this through the same binning method as the TeamBuilder - return self.get_legacy_team_builder(format, date).stat + return self.get_legacy_team_builder( + format, date, rating=rating, gameid=gameid + ).stat - def predict(self, team: TeamSet, date: datetime.date) -> TeamSet: + def predict( + self, + team: TeamSet, + date: datetime.date, + rating: Optional[int | str] = None, + gameid: Optional[str] = None, + ) -> TeamSet: copy_team = copy.deepcopy(team) - return self.fill_team(copy_team, date=date) + return self.fill_team(copy_team, date=date, rating=rating, gameid=gameid) @abstractmethod - def fill_team(self, team: TeamSet, date: datetime.date): + def fill_team( + self, + team: TeamSet, + date: datetime.date, + rating: Optional[int | str] = None, + gameid: Optional[str] = None, + ): raise NotImplementedError @@ -77,8 +128,16 @@ class NaiveUsagePredictor(TeamPredictor): the generated team. """ - def fill_team(self, team: TeamSet, date: datetime.date): - team_builder = self.get_legacy_team_builder(team.format, date) + def fill_team( + self, + team: TeamSet, + date: datetime.date, + rating: Optional[int | str] = None, + gameid: Optional[str] = None, + ): + team_builder = self.get_legacy_team_builder( + team.format, date, rating=rating, gameid=gameid + ) gen = int(team.format.split("gen")[1][0]) pokemon = [team.lead] + team.reserve # use legacy team builder to generate a team of 6 Pokémon based on the ones we already @@ -209,9 +268,13 @@ def __init__( top_k_scored_teams: int = 10, top_k_scored_movesets: int = 3, replay_stats_dir: Optional[str] = None, + usage_stats_rank: int = DEFAULT_USAGE_RANK, ): assert not isinstance(top_k_consistent_teams, str) - super().__init__(replay_stats_dir) + super().__init__( + replay_stats_dir, + usage_stats_rank=usage_stats_rank, + ) self.stat_format = None self.top_k_consistent_teams = top_k_consistent_teams self.top_k_consistent_movesets = top_k_consistent_movesets @@ -407,12 +470,20 @@ def emergency_fill_team(self, team: TeamSet): pokemon.name = extra break - def fill_team(self, team: TeamSet, date: datetime.date) -> TeamSet: + def fill_team( + self, + team: TeamSet, + date: datetime.date, + rating: Optional[int | str] = None, + gameid: Optional[str] = None, + ) -> TeamSet: if team.format not in {"gen1ou", "gen2ou", "gen3ou", "gen4ou"}: # we only trust our stats for the big OU formats for now - return super().fill_team(team, date=date) + return super().fill_team(team, date=date, rating=rating, gameid=gameid) - self.smogon_stat = self.get_usage_stats(team.format, date) + self.smogon_stat = self.get_usage_stats( + team.format, date, rating=rating, gameid=gameid + ) if self.stat_format != team.format: # load the stats on a format change @@ -465,7 +536,7 @@ def fill_team(self, team: TeamSet, date: datetime.date) -> TeamSet: pokemon.fill_from_PokemonSet(new_pokemon) # fall back to old method for any remaining info - return super().fill_team(team, date=date) + return super().fill_team(team, date=date, rating=rating, gameid=gameid) ALL_PREDICTORS = { diff --git a/metamon/backend/team_prediction/team.py b/metamon/backend/team_prediction/team.py index 513d1baa38..2f423de27d 100644 --- a/metamon/backend/team_prediction/team.py +++ b/metamon/backend/team_prediction/team.py @@ -6,7 +6,7 @@ from datetime import date import functools from dataclasses import dataclass -from typing import List, Optional +from typing import List, Optional, Tuple import metamon from metamon.backend.replay_parser.replay_state import ( @@ -97,6 +97,7 @@ def __post_init__(self): assert self.item is not None assert self.ability is not None self.missing_strings = [ + self.MISSING_NAME, self.MISSING_MOVE, self.MISSING_ABILITY, self.MISSING_ITEM, @@ -176,10 +177,120 @@ def revealed_details(self) -> int: @property def revealed_moves(self) -> int: + """Counts the number of moves revealed in this PokemonSet.""" + return len(set(self.moves) - {self.MISSING_MOVE}) + + @property + def set_key(self) -> tuple: + """Canonical (species, moves, item, ability) key — the competitive definition of a "set". + + Two PokemonSets with the same species, moveset (unordered), item, and + ability are considered the same set, regardless of nature, EVs, IVs, + or tera type. Sentinel / missing values are stripped before comparison. """ - Counts the number of moves revealed in this PokemonSet. + _skip = {self.MISSING_MOVE, self.NO_MOVE} + moves = frozenset(m for m in self.moves if m not in _skip) + return (self.name, moves, self.item, self.ability) + + @classmethod + def max_relevant_attrs(cls, gen: int, include_stats: bool = False) -> int: """ - return len(set(self.moves) - {self.MISSING_MOVE}) + Get the maximum number of relevant attributes per Pokemon for a generation. + """ + count = 0 + if gen <= 4: + # name is only relevant in Gen 1-4 (no team preview) + count += 1 + count += 4 # 4 moves always + if gen >= 2: + count += 1 # item + if gen >= 3: + count += 1 # ability + if gen == 9: + count += 1 # tera_type + if include_stats: + count += 1 # nature + count += 6 # evs + count += 6 # ivs + return count + + def revealed_relevant_attrs(self, include_stats: bool = False) -> int: + """ + Count the number of revealed attributes that are "relevant" for this Pokemon. + """ + count = 0 + # name is only relevant in Gen 1-4 (no team preview) + if self.gen <= 4 and self.name != self.MISSING_NAME: + count += 1 + # ability (gen 3+) + if self.gen >= 3 and self.ability != self.MISSING_ABILITY: + count += 1 + # item (gen 2+) + if self.gen >= 2 and self.item != self.MISSING_ITEM: + count += 1 + # tera_type (gen 9 only) + if self.gen == 9 and self.tera_type != self.MISSING_TERA_TYPE: + count += 1 + # moves (always, up to 4) + for move in self.moves: + if move != self.MISSING_MOVE and move != self.NO_MOVE: + count += 1 + if include_stats: + if self.nature != self.MISSING_NATURE: + count += 1 + for ev in self.evs: + if ev != self.MISSING_EV: + count += 1 + for iv in self.ivs: + if iv != self.MISSING_IV: + count += 1 + return count + + def revealed_score(self, include_stats: bool = False) -> float: + """ + Compute the revealed score for this Pokemon. + + Score = revealed_relevant_attrs / max_relevant_attrs + """ + max_attrs = PokemonSet.max_relevant_attrs(self.gen, include_stats) + if max_attrs == 0: + return 0.0 + return self.revealed_relevant_attrs(include_stats) / max_attrs + + @property + def is_present(self) -> bool: + """Check if this Pokemon is actually present (not a placeholder for unknown mon).""" + return self.name != self.MISSING_NAME + + def get_maskable_attrs(self, include_stats: bool = False) -> list: + """ + Get list of (key, subkey) tuples for revealed attributes that can be masked. + Respects generation constraints (no abilities in gen1-2, no tera in gen1-8, etc.) + """ + maskable = [] + + if self.name != self.MISSING_NAME: + maskable.append(("name", None)) + if self.gen >= 3 and self.ability != self.MISSING_ABILITY: + maskable.append(("ability", None)) + if self.gen >= 2 and self.item != self.MISSING_ITEM: + maskable.append(("item", None)) + if self.gen == 9 and self.tera_type != self.MISSING_TERA_TYPE: + maskable.append(("tera_type", None)) + + for i, move in enumerate(self.moves): + if move != self.MISSING_MOVE and move != self.NO_MOVE: + maskable.append(("moves", i)) + + if include_stats: + for i, ev in enumerate(self.evs): + if ev != self.MISSING_EV: + maskable.append(("evs", i)) + for i, iv in enumerate(self.ivs): + if iv != self.MISSING_IV: + maskable.append(("ivs", i)) + + return maskable def __eq__(self, other): if not isinstance(other, PokemonSet): @@ -475,6 +586,7 @@ def from_showdown_block(cls, block: str, gen: int): def to_dict(self): return { + "name": self.name, "moves": self.moves, "gen": self.gen, "ability": self.ability, @@ -507,26 +619,6 @@ def from_dict(cls, d: dict): tera_type=d["tera_type"], ) - def to_seq(self, include_stats: bool = True): - """ " - Creates a simple sequence format that is used by a team prediction model. - """ - seq = [ - f"Mon: {self.name}", - f"Ability: {self.ability}", - f"Item: {self.item}", - f"Tera Type: {self.tera_type}", - ] - moves = [f"Move: {move}" for move in self.moves] - seq += moves - if include_stats: - nature = f"Nature: {self.nature}" - evs = [f"EVs: {ev}" for ev in self.evs] - ivs = [f"IV: {iv}" for iv in self.ivs] - seq += [nature] + evs + ivs - mask = [bool(self.missing_regex.search(word)) for word in seq] - return seq, mask - @classmethod def from_seq(cls, seq: List[str], gen: int, include_stats: bool = True): """ @@ -680,10 +772,35 @@ class TeamSet: reserve: List[PokemonSet] format: str + @property + def gen(self) -> int: + return metamon.backend.format_to_gen(self.format) + @property def known_pokemon(self) -> List[PokemonSet]: return [p for p in self.pokemon if p.name != PokemonSet.MISSING_NAME] + def revealed_score(self, include_stats: bool = False) -> float: + """ + Compute the revealed score for the entire team. + + Score = (total revealed relevant attrs) / (total possible relevant attrs) + """ + gen = self.gen + all_pokemon = [self.lead] + list(self.reserve) + + total_revealed = 0 + total_possible = 0 + + for pokemon in all_pokemon: + total_possible += PokemonSet.max_relevant_attrs(gen, include_stats) + total_revealed += pokemon.revealed_relevant_attrs(include_stats) + + if total_possible == 0: + return 0.0 + + return total_revealed / total_possible + def __eq__(self, other): """ Note that in gen1-4 the leads need to match, but the rest of the roster can be in any order @@ -760,22 +877,6 @@ def to_dict(self): out[p.name] = p.to_dict() return out - def to_seq(self, include_stats: bool = True): - lead_seq, lead_mask = self.lead.to_seq(include_stats=include_stats) - reserve_seq, reserve_mask = [], [] - for p in self.reserve: - p_seq, p_mask = p.to_seq(include_stats=include_stats) - reserve_seq.append(p_seq) - reserve_mask.append(p_mask) - - # add format to the beginning (which never needs to be predicted) - seq = [f"Format: {self.format}"] + lead_seq - mask = [False] + lead_mask - for reserve_seq, reserve_mask in zip(reserve_seq, reserve_mask): - seq += reserve_seq - mask += reserve_mask - return seq, mask - @classmethod def from_seq(cls, seq: List[str], include_stats: bool = True): format = seq[0].split(":")[1].strip() @@ -795,30 +896,6 @@ def from_seq(cls, seq: List[str], include_stats: bool = True): idx += poke_seq_len return cls(lead=lead, reserve=reserve, format=format) - def shuffle(self): - random.shuffle(self.reserve) - for p in [self.lead] + self.reserve: - random.shuffle(p.moves) - return self - - def to_prediction_pair( - self, mask_pokemon_prob: float = 0.1, mask_attrs_prob: float = 0.1 - ): - gen = int(self.format.split("gen")[1][0]) - y = copy.deepcopy(self) - y.shuffle() - x = copy.deepcopy(y) - masked_lead = x.lead.masked(mask_attrs_prob=mask_attrs_prob) - masked_reserve = [] - for p in x.reserve: - if random.random() < mask_pokemon_prob: - masked_reserve.append(PokemonSet.missing_pokemon(gen=gen)) - else: - masked_reserve.append(p.masked(mask_attrs_prob=mask_attrs_prob)) - x.reserve = masked_reserve - x.lead = masked_lead - return x, y - def fill_from_Roster(self, roster: Roster): """ Fill in missing Pokemon names from a Roster. @@ -839,37 +916,274 @@ def fill_from_Roster(self, roster: Roster): pokemon.name = new_pokemon.pop() -if __name__ == "__main__": - import os - from metamon import METAMON_CACHE_DIR +def _pokemon_sort_key(x_p: PokemonSet, y_p: PokemonSet) -> Tuple[int, int, str]: + """ + Sort key: visible first, then masked-with-label, then masked-no-label. - TEAM_DIR = os.path.join( - METAMON_CACHE_DIR, "parsed-replays", "revealed_teams", "gen9ou" - ) - print(TEAM_DIR) - team_files = [] - for root, dirs, files in os.walk(TEAM_DIR): - for file in files: - if file.endswith("team") or file.startswith("team"): - team_files.append(os.path.join(root, file)) + Order: + 1. Visible Pokemon (alphabetically) + 2. Masked Pokemon with labels (alphabetically by y's name) + 3. Masked Pokemon without labels (y is also $missing_name$) + """ + if x_p.name == PokemonSet.MISSING_NAME: + # If y is also missing, no ground truth - sort after labeled positions + if y_p.name == PokemonSet.MISSING_NAME: + return (2, 0, y_p.name) + # Masked with label - sort alphabetically by y + return (1, 0, y_p.name) + return (0, 0, x_p.name) - print(f"Found {len(team_files)} team files.") - random.shuffle(team_files) - for path in team_files: - print(f"\nLoading team from: {path}") - with open(path, "r") as f: - txt = f.read() - print(txt) - team = TeamSet.from_showdown_file(path, "gen9ou") - print("---------------------------------------------------------") - x, y = team.to_prediction_pair() - print(x.to_str()) - print("---------------------------------------------------------") - print(y.to_str()) - print(x.to_seq(include_stats=False)) - print(y.to_seq(include_stats=False)) - assert len(x.to_seq(include_stats=False)) == len(y.to_seq(include_stats=False)) - y_copy = TeamSet.from_seq(y.to_seq(include_stats=True)[0], include_stats=True) - print(y_copy.to_str()) - input() +def _move_sort_key(x_move: str, y_move: str) -> Tuple[int, int, str]: + """ + Sort key: visible first, then masked-with-label, then masked-no-label, then . + + Order: + 1. Visible moves (alphabetically) + 2. Masked moves with labels (alphabetically by y) + 3. Masked moves without labels (y is also $missing_move$) + 4. empty slots + """ + if x_move == PokemonSet.NO_MOVE: + return (3, 0, x_move) + if x_move == PokemonSet.MISSING_MOVE: + # If y is also missing, no ground truth - sort after labeled positions + if y_move == PokemonSet.MISSING_MOVE: + return (2, 0, y_move) + # Masked with label - sort alphabetically by y + return (1, 0, y_move) + return (0, 0, x_move) + + +def _compute_ordering(x_items: List, y_items: List, sort_key) -> List[int]: + """Compute permutation indices to sort items by key (using y as tie-breaker for masked x).""" + indexed = [(i, sort_key(x, y)) for i, (x, y) in enumerate(zip(x_items, y_items))] + indexed.sort(key=lambda x: x[1]) + return [i for i, _ in indexed] + + +def _apply_ordering(items: List, order: List[int]) -> List: + """Apply permutation to reorder items.""" + return [items[i] for i in order] + + +class Team2Seq: + """ + Converts TeamSets to model-ready sequences and token IDs with standard ordering. + Ordering rules: + - Pokemon: lead first, then reserve by visible name (alphabetically), then masked + - Moves: visible first (alphabetically), then $missing_move$, then + """ + + NUM_POKEMON = 6 + ATTRS_PER_POKEMON_BASE = 8 # name, ability, item, tera, 4 moves + ATTRS_PER_POKEMON_WITH_STATS = 21 # + nature + 6 evs + 6 ivs + + @staticmethod + def seq_len(include_stats: bool = False) -> int: + """Compute total sequence length for a team.""" + attrs = ( + Team2Seq.ATTRS_PER_POKEMON_WITH_STATS + if include_stats + else Team2Seq.ATTRS_PER_POKEMON_BASE + ) + return 1 + Team2Seq.NUM_POKEMON * attrs # format token + pokemon + + def __init__(self, include_stats: bool = False): + self.include_stats = include_stats + # Attributes per Pokemon in sequence + self._attrs_per_pokemon = 8 # name, ability, item, tera, move0-3 + if include_stats: + self._attrs_per_pokemon += 1 + 6 + 6 # nature + evs + ivs + + @property + def vocab(self): + from metamon.backend.team_prediction.vocabulary import get_vocab + + return get_vocab() + + def get_pokemon_indices(self, positions: "torch.Tensor") -> "torch.Tensor": + """Which Pokemon (0-5) does each position belong to? Format (pos 0) → -1.""" + import torch + + # Pos 1-8 → Pokemon 0, Pos 9-16 → Pokemon 1, etc. + return torch.where( + positions == 0, -1, (positions - 1) // self._attrs_per_pokemon + ) + + def get_name_positions(self, pokemon_indices: "torch.Tensor") -> "torch.Tensor": + """Get the sequence position of each Pokemon's name. Index -1 → 0.""" + import torch + + # Pokemon 0 name at pos 1, Pokemon 1 name at pos 9, etc. + return torch.where( + pokemon_indices < 0, 0, 1 + pokemon_indices * self._attrs_per_pokemon + ) + + def get_all_name_positions(self) -> list[int]: + """Get sequence positions of all 6 Pokemon names.""" + return [1 + p * self._attrs_per_pokemon for p in range(6)] + + def get_move_positions_for_pokemon(self, pokemon_idx: int) -> list[int]: + """Get the 4 move sequence positions for a specific Pokemon (0-5).""" + # Sequence layout: format(0), then per pokemon: name, ability, item, tera, move0-3 + # Pokemon 0 moves at positions 5,6,7,8 + # Pokemon 1 moves at positions 13,14,15,16, etc. + base = ( + 1 + pokemon_idx * self._attrs_per_pokemon + 4 + ) # +4 skips name,ability,item,tera + return [base, base + 1, base + 2, base + 3] + + def encode( + self, team: TeamSet + ) -> Tuple["torch.Tensor", "torch.Tensor", "torch.Tensor"]: + """ + Encode a team to token IDs for inference. + + Returns: + (tokens, type_ids, pred_mask) where pred_mask indicates missing tokens. + """ + import torch + + seq, mask = self.to_seq(team) + tokens, type_ids = self.vocab.pokeset_seq_to_ints(seq) + return ( + torch.from_numpy(tokens).long(), + torch.from_numpy(type_ids).long(), + torch.tensor(mask), + ) + + def encode_pair( + self, x: TeamSet, y: TeamSet + ) -> Tuple["torch.Tensor", "torch.Tensor", "torch.Tensor", "torch.Tensor"]: + """ + Encode (masked, ground_truth) pair to token IDs for training. + + Returns: + (x_tokens, type_ids, y_tokens, pred_mask) + """ + import torch + + x_seq, y_seq, pred_mask = self.to_seq_pair(x, y) + x_tokens, x_type_ids = self.vocab.pokeset_seq_to_ints(x_seq) + y_tokens, y_type_ids = self.vocab.pokeset_seq_to_ints(y_seq) + assert (x_type_ids == y_type_ids).all() + return ( + torch.from_numpy(x_tokens).long(), + torch.from_numpy(x_type_ids).long(), + torch.from_numpy(y_tokens).long(), + torch.tensor(pred_mask), + ) + + def decode(self, tokens: "torch.Tensor") -> TeamSet: + """Decode token IDs back to a TeamSet.""" + seq = self.vocab.ints_to_pokeset_seq(tokens.cpu().numpy()) + return TeamSet.from_seq(seq, include_stats=self.include_stats) + + def _pokemon_to_seq(self, p: PokemonSet, moves: List[str]) -> List[str]: + """Convert a single Pokemon to sequence tokens with given move order.""" + seq = [ + f"Mon: {p.name}", + f"Ability: {p.ability}", + f"Item: {p.item}", + f"Tera Type: {p.tera_type}", + ] + seq += [f"Move: {m}" for m in moves] + if self.include_stats: + seq.append(f"Nature: {p.nature}") + seq += [f"EVs: {ev}" for ev in p.evs] + seq += [f"IV: {iv}" for iv in p.ivs] + return seq + + def _pokemon_pair_to_seq( + self, x_pokemon: PokemonSet, y_pokemon: PokemonSet + ) -> Tuple[List[str], List[str], List[bool]]: + """Convert Pokemon pair to sequences with coordinated move ordering.""" + move_order = _compute_ordering(x_pokemon.moves, y_pokemon.moves, _move_sort_key) + x_moves = _apply_ordering(x_pokemon.moves, move_order) + y_moves = _apply_ordering(y_pokemon.moves, move_order) + + x_seq = self._pokemon_to_seq(x_pokemon, x_moves) + y_seq = self._pokemon_to_seq(y_pokemon, y_moves) + + # pred_mask: True where x is missing but y has a real value + x_mask = [bool(x_pokemon.missing_regex.search(w)) for w in x_seq] + y_mask = [bool(y_pokemon.missing_regex.search(w)) for w in y_seq] + pred_mask = [xm and not ym for xm, ym in zip(x_mask, y_mask)] + + return x_seq, y_seq, pred_mask + + def to_seq(self, team: TeamSet) -> Tuple[List[str], List[bool]]: + """ + Convert a team to sequence format (for inference). + Returns (sequence, needs_prediction_mask). + + The mask indicates which tokens are missing and need prediction. + """ + # Get the ordered sequence (use to_seq_pair for consistent ordering) + seq, _, _ = self.to_seq_pair(team, team) + + # Compute mask: True for any missing token + # (different from to_seq_pair which computes xm and not ym) + mask = [bool(team.lead.missing_regex.search(w)) for w in seq] + return seq, mask + + def to_seq_pair( + self, x: TeamSet, y: TeamSet + ) -> Tuple[List[str], List[str], List[bool]]: + """ + Convert (x, y) pair to sequences with coordinated ordering. + Ordering determined by x's visible state (with y as tie-breaker for masked). + Returns (x_seq, y_seq, pred_mask). + """ + reserve_order = _compute_ordering(x.reserve, y.reserve, _pokemon_sort_key) + x_all = [x.lead] + _apply_ordering(x.reserve, reserve_order) + y_all = [y.lead] + _apply_ordering(y.reserve, reserve_order) + + x_seq = [f"Format: {x.format}"] + y_seq = [f"Format: {y.format}"] + pred_mask = [False] + + for xp, yp in zip(x_all, y_all): + px, py, pm = self._pokemon_pair_to_seq(xp, yp) + x_seq.extend(px) + y_seq.extend(py) + pred_mask.extend(pm) + + return x_seq, y_seq, pred_mask + + def compute_permutation(self, team: TeamSet) -> List[int]: + """ + Compute the permutation that to_seq applies to put team in sorted order. + """ + # Position 0 (Format) stays fixed + permutation = [0] + + # Compute Pokemon ordering (lead stays first, reserve gets sorted) + # Pass team.reserve twice since we don't have separate ground truth + reserve_order = _compute_ordering(team.reserve, team.reserve, _pokemon_sort_key) + pokemon_order = [0] + [ + i + 1 for i in reserve_order + ] # 0=lead, then reserve indices + + # For each Pokemon in new order, compute its attribute positions + all_pokemon = [team.lead] + list(team.reserve) + for old_p_idx in pokemon_order: + pokemon = all_pokemon[old_p_idx] + old_pokemon_start = 1 + old_p_idx * self._attrs_per_pokemon + + # First 4 attributes (name, ability, item, tera) keep relative order + for attr_offset in range(4): + permutation.append(old_pokemon_start + attr_offset) + + # Moves get reordered within Pokemon (pass moves twice, no ground truth) + move_order = _compute_ordering(pokemon.moves, pokemon.moves, _move_sort_key) + for old_move_idx in move_order: + permutation.append(old_pokemon_start + 4 + old_move_idx) + + # Stats if included (nature, evs, ivs keep relative order) + if self.include_stats: + for stat_offset in range(1 + 6 + 6): + permutation.append(old_pokemon_start + 8 + stat_offset) + + return permutation diff --git a/metamon/backend/team_prediction/team_index.py b/metamon/backend/team_prediction/team_index.py new file mode 100644 index 0000000000..8b68427b59 --- /dev/null +++ b/metamon/backend/team_prediction/team_index.py @@ -0,0 +1,167 @@ +"""index.csv helpers for Metamon team set directories.""" + +from __future__ import annotations + +import os +import re +from pathlib import Path +from typing import Iterable, Optional + +INDEX_FILENAME = "index.csv" +_FORMAT_DIR_RE = re.compile(r"^gen\d+[a-z0-9]+$") + + +def team_file_suffix(battle_format: str) -> str: + return f".{battle_format.lower()}_team" + + +def index_path_for(format_dir: os.PathLike[str] | str) -> Path: + return Path(format_dir) / INDEX_FILENAME + + +def resolve_format_dir(team_root: os.PathLike[str] | str, battle_format: str) -> Path: + """Return the directory containing team files for a format.""" + root = Path(team_root) + nested = root / battle_format.lower() + if nested.is_dir(): + return nested + return root + + +def scan_team_filenames( + format_dir: os.PathLike[str] | str, battle_format: str +) -> list[str]: + """List team filenames relative to format_dir (sorted).""" + format_dir = Path(format_dir) + suffix = team_file_suffix(battle_format) + rel_paths: list[str] = [] + for dirpath, _, filenames in os.walk(format_dir): + for name in filenames: + if not name.endswith(suffix): + continue + full = Path(dirpath) / name + rel_paths.append(full.relative_to(format_dir).as_posix()) + rel_paths.sort() + return rel_paths + + +def write_team_index( + format_dir: os.PathLike[str] | str, filenames: Iterable[str] +) -> Path: + format_dir = Path(format_dir) + index_path = index_path_for(format_dir) + with open(index_path, "w", encoding="utf-8") as f: + f.write("filename\n") + for name in filenames: + f.write(f"{name}\n") + return index_path + + +def refresh_team_index( + format_dir: os.PathLike[str] | str, battle_format: str +) -> tuple[Path, int]: + filenames = scan_team_filenames(format_dir, battle_format) + index_path = write_team_index(format_dir, filenames) + return index_path, len(filenames) + + +def load_team_files( + team_root: os.PathLike[str] | str, + battle_format: str, +) -> tuple[list[str], bool]: + """ + Load absolute paths to team files from index.csv if present, else scan disk. + + Returns (paths, loaded_from_index). + """ + format_dir = resolve_format_dir(team_root, battle_format) + index_path = index_path_for(format_dir) + if not index_path.is_file(): + names = scan_team_filenames(format_dir, battle_format) + return [str(format_dir / name) for name in names], False + + suffix = team_file_suffix(battle_format) + team_files: list[str] = [] + missing = 0 + with open(index_path, encoding="utf-8") as f: + f.readline() # header + for line in f: + rel = line.strip() + if not rel: + continue + full = format_dir / rel + if full.is_file(): + team_files.append(str(full)) + else: + missing += 1 + + if not team_files: + names = scan_team_filenames(format_dir, battle_format) + return [str(format_dir / name) for name in names], False + + if missing: + print( + f"Warning: {missing} entries in {index_path} missing on disk; " + f"loaded {len(team_files):,} teams" + ) + + return team_files, True + + +def infer_battle_format(format_dir: Path) -> Optional[str]: + for entry in sorted(format_dir.iterdir()): + if not entry.is_file(): + continue + match = re.match(r".+\.(gen\d+[a-z0-9]+)_team$", entry.name) + if match: + return match.group(1) + return None + + +def is_format_team_dir(path: Path) -> bool: + if not path.is_dir(): + return False + if _FORMAT_DIR_RE.match(path.name): + return infer_battle_format(path) is not None + return infer_battle_format(path) is not None + + +def iter_format_dirs(set_dir: Path) -> Iterable[tuple[Path, str]]: + """Yield (format_dir, battle_format) under a team set directory.""" + if is_format_team_dir(set_dir): + fmt = infer_battle_format(set_dir) or set_dir.name + yield set_dir, fmt + return + + for child in sorted(set_dir.iterdir()): + if not child.is_dir(): + continue + if not is_format_team_dir(child): + continue + fmt = infer_battle_format(child) or child.name + yield child, fmt + + +PUBLIC_TEAM_SETS = frozenset( + { + "competitive", + "paper_variety", + "paper_replays", + "modern_replays", + "modern_replays_v2", + "gl_05_26", + "hl_05_26", + } +) + + +def should_index_set_dir(name: str, public_only: bool = True) -> bool: + if name.startswith("."): + return False + if name.endswith("-unfiltered"): + return False + if name in {"analysis", "select"}: + return False + if public_only and name not in PUBLIC_TEAM_SETS: + return False + return True diff --git a/metamon/backend/team_prediction/train.py b/metamon/backend/team_prediction/train.py deleted file mode 100644 index e4eb76b057..0000000000 --- a/metamon/backend/team_prediction/train.py +++ /dev/null @@ -1,447 +0,0 @@ -""" -Model-based team prediction began as part of the changes that became version 1.0. -However, we already added an improved ReplayPredictor, and the need for the further -(learned) improvements is unclear at this time. Therefore work on team prediction -training is on hold and this script is mostly untested/TODO. - -05/13/2025 -""" - -import os -import argparse -import random -import re -from typing import Optional - -import tqdm -import torch -import torch.nn.functional as F -from torch import nn -from torch.utils.data import DataLoader -import wandb - -from metamon.backend.team_prediction.dataset import ( - TeamPredictionDataset, - CompetitiveTeamPredictionDataset, -) -from metamon.backend.team_prediction.model import TeamTransformer -from metamon.backend.team_prediction.vocabulary import Vocabulary -from metamon.backend.team_prediction.team import TeamSet -from metamon.tokenizer import UNKNOWN_TOKEN - - -def compute_loss_and_accuracy( - logits: torch.Tensor, y_tokens: torch.Tensor, pred_mask: torch.Tensor -) -> tuple[torch.Tensor, float]: - """ - Computes cross-entropy loss and accuracy. Only masked positions are used for loss/accuracy. - Returns: (loss, accuracy) - """ - B, L, V = logits.shape - loss = F.cross_entropy( - logits.view(-1, V), - y_tokens.view(-1), - reduction="none", - ignore_index=UNKNOWN_TOKEN, - ) - num_preds = max(pred_mask.sum().item(), 1) - loss = (loss * pred_mask.view(-1)).sum() / num_preds - preds = logits.argmax(dim=-1) - correct = ((preds == y_tokens) * pred_mask).sum().item() - accuracy = correct / num_preds - return loss, accuracy - - -def evaluate( - model: nn.Module, - dataloader: DataLoader, - device: torch.device, - max_steps: Optional[int] = None, -) -> tuple[float, float]: - """ - Evaluate model on a dataloader. Returns (avg_loss, avg_accuracy). - If max_steps is provided, only evaluate on that many batches. - """ - model.eval() - total_loss = 0.0 - total_acc = 0.0 - num_steps = 0 - with torch.no_grad(): - for batch in dataloader: - x_tokens, type_ids, y_tokens, pred_mask = batch - x_tokens = x_tokens.to(device) - type_ids = type_ids.to(device) - y_tokens = y_tokens.to(device) - pred_mask = pred_mask.to(device) - logits = model(x_tokens, type_ids) - loss, acc = compute_loss_and_accuracy(logits, y_tokens, pred_mask) - total_loss += loss.item() - total_acc += acc - num_steps += 1 - if max_steps is not None and num_steps >= max_steps: - break - avg_loss = total_loss / max(num_steps, 1) - avg_acc = total_acc / max(num_steps, 1) - return avg_loss, avg_acc - - -def wandb_to_console_color(text: str) -> str: - # Replace :blue[], :red[], :green[] with ANSI codes - text = re.sub(r":blue\[(.*?)\]", r"\033[94m\1\033[0m", text) - text = re.sub(r":red\[(.*?)\]", r"\033[91m\1\033[0m", text) - text = re.sub(r":green\[(.*?)\]", r"\033[92m\1\033[0m", text) - return text - - -def log_example_predictions( - model: nn.Module, - vocab: Vocabulary, - x_tokens: torch.Tensor, - type_ids: torch.Tensor, - y_tokens: torch.Tensor, - pred_masks: torch.Tensor, - device: torch.device, - num_examples: int, - use_wandb: bool, - epoch: int, -): - """ - Log example predictions to wandb or print to console. - """ - model.eval() - x_tokens = x_tokens.to(device) - type_ids = type_ids.to(device) - pred_masks = pred_masks.to(device) - logits = model(x_tokens, type_ids) - probs = torch.softmax(logits, dim=-1) - filt = vocab.filter_probs(probs, type_ids) - bs, seq_len, vs = filt.shape - flat = filt.view(-1, vs) - sampled = torch.multinomial(flat, 1).view(bs, seq_len) - # Use sampled predictions where pred_mask is True, otherwise keep input tokens - merged = torch.where(pred_masks, sampled, x_tokens).cpu() - - table = wandb.Table(columns=["input", "predicted", "ground_truth"]) - for i in range(min(bs, num_examples)): - x_seq = vocab.ints_to_pokeset_seq(x_tokens[i].cpu().tolist()) - pred_seq = vocab.ints_to_pokeset_seq(merged[i].tolist()) - true_seq = vocab.ints_to_pokeset_seq(y_tokens[i].tolist()) - mask = pred_masks[i] - x_str = " ".join(f":green[{x}]" if m else x for x, m in zip(x_seq, mask)) - pred_str = [] - true_str = [] - for p, t, m in zip(pred_seq, true_seq, mask): - if m: - color = ":blue[" if p == t else ":red[" - end = "]" - else: - color = "" - end = "" - pred_str.append(f"{color}{p}{end}") - true_str.append(f"{color}{t}{end}") - pred_str = " ".join(pred_str) - true_str = " ".join(true_str) - - table.add_data( - f"**Input**:\n{x_str}", - f"**Predicted**:\n{pred_str}", - f"**Ground truth**:\n{true_str}", - ) - if use_wandb: - wandb.log({"val/example_predictions": table}, step=epoch) - else: - print(f"Examples at epoch {epoch}:") - for i in range(min(bs, num_examples)): - print("---") - # Use the same strings as above, but convert color markup to ANSI - x_str_console = wandb_to_console_color(table.data[i][0].split("\n", 1)[1]) - pred_str_console = wandb_to_console_color( - table.data[i][1].split("\n", 1)[1] - ) - true_str_console = wandb_to_console_color( - table.data[i][2].split("\n", 1)[1] - ) - print(f"**Input**:\n{x_str_console}") - print(f"**Predicted**:\n{pred_str_console}") - print(f"**Ground truth**:\n{true_str_console}") - - -def train(config, use_wandb: bool = True): - # config: hyperparameters namespace or wandb.config - - # Set random seed - random.seed(config.seed) - torch.manual_seed(config.seed) - - # Prepare datasets - train_dset = TeamPredictionDataset( - data_dir=config.train_data_dir, - split="train", - validation_ratio=config.val_ratio, - mask_pokemon_prob_range=(config.mask_pokemon_prob, config.mask_pokemon_prob), - mask_attrs_prob_range=(config.mask_attrs_prob, config.mask_attrs_prob), - seed=config.seed, - use_cached_filenames=True, - verbose=True, - ) - val_dset = TeamPredictionDataset( - data_dir=config.train_data_dir, - split="val", - validation_ratio=config.val_ratio, - mask_pokemon_prob_range=(config.mask_pokemon_prob, config.mask_pokemon_prob), - mask_attrs_prob_range=(config.mask_attrs_prob, config.mask_attrs_prob), - seed=config.seed, - use_cached_filenames=True, - verbose=True, - ) - comp_dset = CompetitiveTeamPredictionDataset( - mask_pokemon_prob_range=(config.mask_pokemon_prob, config.mask_pokemon_prob), - mask_attrs_prob_range=(config.mask_attrs_prob, config.mask_attrs_prob), - verbose=True, - ) - - # DataLoaders - train_loader = DataLoader( - train_dset, - batch_size=config.batch_size, - shuffle=True, - num_workers=config.num_workers, - ) - val_loader = DataLoader( - val_dset, - batch_size=config.batch_size, - shuffle=True, - num_workers=config.num_workers, - ) - comp_loader = DataLoader( - comp_dset, - batch_size=config.batch_size, - shuffle=True, - num_workers=config.num_workers, - ) - - # Initialize model - model = TeamTransformer( - max_seq_len=config.max_seq_len, - d_model=config.d_model, - nhead=config.nhead, - num_layers=config.num_layers, - dim_feedforward=config.dim_ff, - dropout=config.dropout, - ) - device = torch.device("cuda" if torch.cuda.is_available() else "cpu") - model = model.to(device) - optimizer = torch.optim.AdamW( - model.parameters(), lr=config.learning_rate, weight_decay=config.weight_decay - ) - vocab = Vocabulary() - - # Create checkpoint directory (per run) and artifacts subdir - ckpt_dir = os.path.join(config.checkpoint_dir, config.run_name) - artifact_dir = os.path.join(ckpt_dir, "artifacts") - os.makedirs(artifact_dir, exist_ok=True) - - # Training loop with early stopping (step-based) - best_val_loss = float("inf") - patience_count = 0 - global_step = 0 - running_loss = 0.0 - running_acc = 0.0 - steps_since_eval = 0 - - train_iter = iter(train_loader) - pbar = tqdm.tqdm(total=config.max_steps, desc="Training") - - while global_step < config.max_steps: - # Get next batch, restart iterator if exhausted - try: - x_tokens, type_ids, y_tokens, pred_mask = next(train_iter) - except StopIteration: - train_iter = iter(train_loader) - x_tokens, type_ids, y_tokens, pred_mask = next(train_iter) - - model.train() - x_tokens = x_tokens.to(device) - type_ids = type_ids.to(device) - y_tokens = y_tokens.to(device) - pred_mask = pred_mask.to(device) - logits = model(x_tokens, type_ids) - loss, acc = compute_loss_and_accuracy(logits, y_tokens, pred_mask) - optimizer.zero_grad() - loss.backward() - optimizer.step() - - running_loss += loss.item() - running_acc += acc - global_step += 1 - steps_since_eval += 1 - pbar.update(1) - - # Evaluate every eval_every_steps - if global_step % config.eval_every_steps == 0: - train_loss = running_loss / steps_since_eval - train_acc = running_acc / steps_since_eval - running_loss = 0.0 - running_acc = 0.0 - steps_since_eval = 0 - - val_loss, val_acc = evaluate( - model, val_loader, device, max_steps=config.max_eval_steps - ) - comp_loss, comp_acc = evaluate( - model, comp_loader, device, max_steps=config.max_eval_steps - ) - - # Log metrics for each dataset split - metrics = { - "train": {"loss": train_loss, "accuracy": train_acc}, - "val": { - "replay_loss": val_loss, - "replay_accuracy": val_acc, - "competitive_loss": comp_loss, - "competitive_accuracy": comp_acc, - }, - } - if use_wandb: - # Log all metrics to wandb - wandb_metrics = {} - for split, split_metrics in metrics.items(): - for metric_name, value in split_metrics.items(): - wandb_metrics[f"{split}/{metric_name}"] = value - wandb.log(wandb_metrics, step=global_step) - else: - # Print metrics to console - print(f"\nStep {global_step}") - print( - f"Train - loss: {train_loss:.4f}, accuracy: {train_acc:.4f}" - ) - print(f"Replay Val - loss: {val_loss:.4f}, accuracy: {val_acc:.4f}") - print( - f"Competitive - loss: {comp_loss:.4f}, accuracy: {comp_acc:.4f}\n" - ) - - example_batch = next(iter(val_loader)) - x_tokens, type_ids, y_tokens, pred_masks = example_batch - log_example_predictions( - model=model, - vocab=vocab, - x_tokens=x_tokens, - type_ids=type_ids, - y_tokens=y_tokens, - pred_masks=pred_masks, - device=device, - num_examples=config.num_examples, - use_wandb=use_wandb, - epoch=global_step, - ) - - # Early stopping check - if val_loss < best_val_loss: - best_val_loss = val_loss - patience_count = 0 - best_model = os.path.join(ckpt_dir, "best_model.pt") - torch.save(model.state_dict(), best_model) - print(f"New best model saved to {best_model}") - if use_wandb: - # Log best checkpoint as Artifact - artifact = wandb.Artifact( - f"{config.run_name}-best-model", type="model" - ) - artifact.add_file(best_model) - wandb.log_artifact(artifact) - else: - patience_count += 1 - if patience_count >= config.patience: - print(f"Early stopping at step {global_step}") - break - - pbar.close() - - # Save final model - final_model = os.path.join(ckpt_dir, "final_model.pt") - torch.save(model.state_dict(), final_model) - if use_wandb: - # Log final model as Artifact - artifact = wandb.Artifact(f"{config.run_name}-final-model", type="model") - artifact.add_file(final_model) - wandb.log_artifact(artifact) - else: - print(f"Final model saved to {final_model}") - - -if __name__ == "__main__": - from metamon.data.download import download_revealed_teams - - parser = argparse.ArgumentParser( - description="Train TeamTransformer with optional W&B" - ) - parser.add_argument("--project", type=str, help="W&B project name") - parser.add_argument("--entity", type=str, help="W&B entity/user") - parser.add_argument( - "--group", type=str, default=None, help="W&B group name for sweeps" - ) - parser.add_argument( - "--no-wandb", - action="store_true", - help="Disable W&B logging and print to console instead", - ) - parser.add_argument( - "--name", type=str, default=None, help="Run name to use for checkpoints and W&B" - ) - parser.add_argument( - "--checkpoint-dir", - type=str, - default="checkpoints", - help="Directory to save model checkpoints", - ) - args = parser.parse_args() - - # Default hyperparameters - sweep_defaults = { - "train_data_dir": download_revealed_teams(), - "val_ratio": 0.1, - "batch_size": 8, - "num_workers": 4, - "mask_pokemon_prob": 0.1, - "mask_attrs_prob": 0.1, - "seed": 42, - "max_seq_len": 64, - "d_model": 256, - "nhead": 4, - "num_layers": 3, - "dim_ff": 1024, - "dropout": 0.0, - "learning_rate": 1e-3, - "max_steps": 100000, - "eval_every_steps": 1000, - "max_eval_steps": 100, - "patience": 5, - "weight_decay": 1e-4, - "num_examples": 4, - } - - # Determine whether to use WandB - use_wandb = not args.no_wandb - if use_wandb: - # Initialize WandB run - wandb.init( - project=args.project, - entity=args.entity, - group=args.group, - config=sweep_defaults, - name=args.name, - ) - cfg = wandb.config - # Override checkpoint dir & run name in config - cfg.checkpoint_dir = args.checkpoint_dir - cfg.run_name = wandb.run.name - else: - # Use local config namespace - from argparse import Namespace - - cfg = Namespace(**sweep_defaults) - cfg.checkpoint_dir = args.checkpoint_dir - cfg.run_name = args.name or "local_run" - - # Start training - train(cfg, use_wandb) diff --git a/metamon/backend/team_prediction/train_prediction_model.py b/metamon/backend/team_prediction/train_prediction_model.py new file mode 100644 index 0000000000..81d7308341 --- /dev/null +++ b/metamon/backend/team_prediction/train_prediction_model.py @@ -0,0 +1,1562 @@ +import os +import argparse +import html +import random +import warnings +from collections import Counter +from typing import Optional +from dataclasses import dataclass + +import tqdm +import torch +import torch.nn.functional as F +from torch.utils.data import DataLoader +import wandb + +from metamon.backend.team_prediction.dataset import ( + TeamPredictionDataset, + ScoredTeamPredictionDataset, +) +from metamon.backend.team_prediction.prediction_model import ( + TeamPredictionModel, + create_model, +) +from metamon.backend.team_prediction.vocabulary import Vocabulary +from metamon.backend.team_prediction.masking import ( + TeamMasker, + NamesOnlyMasker, + CurriculumMasker, +) +from metamon.backend.team_prediction.prediction_metrics import ( + compute_loss_and_metrics, + EvaluationAccumulator, + SemanticMetricsAccumulator, +) +from metamon.backend.team_prediction.team import Team2Seq, TeamSet, PokemonSet +from metamon.backend.team_prediction.iterative_decoder import IterativeStatsAccumulator + + +def create_demo_teams() -> list[TeamSet]: + """ + Create demonstration teams for iterative decoding visualization. + Each team has minimal information to show the full decoding process. + """ + demos = [] + + # Gen 1: Only Gengar name visible + gen1_lead = PokemonSet( + name="Gengar", + gen=1, + ability=PokemonSet.NO_ABILITY, + item=PokemonSet.NO_ITEM, + nature=PokemonSet.NO_NATURE, + moves=[PokemonSet.MISSING_MOVE] * 4, + evs=[252] * 6, + ivs=[31] * 6, + tera_type=PokemonSet.NO_TERA_TYPE, + ) + demos.append( + TeamSet( + format="gen1ou", + lead=gen1_lead, + reserve=[PokemonSet.missing_pokemon(gen=1) for _ in range(5)], + ) + ) + + # Gen 2: Only Cloyster name visible + gen2_lead = PokemonSet( + name="Cloyster", + gen=2, + ability=PokemonSet.NO_ABILITY, + item=PokemonSet.MISSING_ITEM, + nature=PokemonSet.NO_NATURE, + moves=[PokemonSet.MISSING_MOVE] * 4, + evs=[252] * 6, + ivs=[31] * 6, + tera_type=PokemonSet.NO_TERA_TYPE, + ) + demos.append( + TeamSet( + format="gen2ou", + lead=gen2_lead, + reserve=[PokemonSet.missing_pokemon(gen=2) for _ in range(5)], + ) + ) + + # Gen 3: Only Tyranitar name visible + gen3_lead = PokemonSet( + name="Tyranitar", + gen=3, + ability=PokemonSet.MISSING_ABILITY, + item=PokemonSet.MISSING_ITEM, + nature=PokemonSet.NO_NATURE, + moves=[PokemonSet.MISSING_MOVE] * 4, + evs=[252] * 6, + ivs=[31] * 6, + tera_type=PokemonSet.NO_TERA_TYPE, + ) + demos.append( + TeamSet( + format="gen3ou", + lead=gen3_lead, + reserve=[PokemonSet.missing_pokemon(gen=3) for _ in range(5)], + ) + ) + + # Gen 4: Only Metagross name visible + gen4_lead = PokemonSet( + name="Metagross", + gen=4, + ability=PokemonSet.MISSING_ABILITY, + item=PokemonSet.MISSING_ITEM, + nature=PokemonSet.NO_NATURE, + moves=[PokemonSet.MISSING_MOVE] * 4, + evs=[252] * 6, + ivs=[31] * 6, + tera_type=PokemonSet.NO_TERA_TYPE, + ) + demos.append( + TeamSet( + format="gen4ou", + lead=gen4_lead, + reserve=[PokemonSet.missing_pokemon(gen=4) for _ in range(5)], + ) + ) + + # Gen 9: All 6 names visible, everything else masked + gen9_names = [ + "Gholdengo", + "Darkrai", + "Clefable", + "Ting-Lu", + "Dragonite", + "Pecharunt", + ] + + def gen9_pokemon(name: str) -> PokemonSet: + return PokemonSet( + name=name, + gen=9, + ability=PokemonSet.MISSING_ABILITY, + item=PokemonSet.MISSING_ITEM, + nature=PokemonSet.NO_NATURE, + moves=[PokemonSet.MISSING_MOVE] * 4, + evs=[252] * 6, + ivs=[31] * 6, + tera_type=PokemonSet.MISSING_TERA_TYPE, + ) + + demos.append( + TeamSet( + format="gen9ou", + lead=gen9_pokemon(gen9_names[0]), + reserve=[gen9_pokemon(n) for n in gen9_names[1:]], + ) + ) + + return demos + + +def log_demo_decoding( + prediction_model: TeamPredictionModel, + vocab: Vocabulary, + step: int, +): + """ + Log iterative decoding demonstrations showing progression for each generation. + Highlights tokens committed at each iteration. + """ + demos = create_demo_teams() + + # Build wandb table + num_cols = prediction_model.iterative_decoder.num_iterations + 1 + columns = ["format", "input"] + [f"iter_{i}" for i in range(1, num_cols)] + table = wandb.Table(columns=columns) + + for team in demos: + # Run iterative decoding with token tracking + _, stats = prediction_model.predict(team, return_stats=True) + tokens_per_iter = stats.tokens_per_iter + # Build row with format and each iteration + row_data = [team.format] + + # Track token counts (handles duplicates like common moves on multiple pokemon) + input_seq = vocab.ints_to_pokeset_seq(tokens_per_iter[0][0].tolist()) + previously_visible = Counter(tok for tok in input_seq if "$" not in tok) + + for iter_idx, tokens in enumerate(tokens_per_iter): + seq = vocab.ints_to_pokeset_seq(tokens[0].tolist()) # batch dim 0 + currently_visible = Counter(tok for tok in seq if "$" not in tok) + + # Counter subtraction gives positive counts for newly added tokens + # e.g., if "Earthquake" was 1, now 2, diff["Earthquake"] = 1 + newly_committed = currently_visible - previously_visible + + parts = [] + for tok_str in seq: + tok_escaped = html.escape(tok_str) + + if newly_committed.get(tok_str, 0) > 0: + # Newly predicted this iteration - orange + parts.append( + f'{tok_escaped}' + ) + newly_committed[tok_str] -= 1 # consume one highlight + elif "$" in tok_str: + # Still masked - gray + parts.append(f'{tok_escaped}') + else: + # Already visible (from input or previous iterations) + parts.append(tok_escaped) + + row_data.append(wandb.Html(" ".join(parts))) + + # Update for next iteration + previously_visible = currently_visible + + table.add_data(*row_data) + + wandb.log({"demo_iterative_decoding": table}, step=step) + + +def log_demo_oneshot( + prediction_model: TeamPredictionModel, + vocab: Vocabulary, + step: int, +): + """ + Log one-shot predictions for demo teams. + Shows input and single-pass prediction side by side. + """ + demos = create_demo_teams() + t2s = Team2Seq() + + columns = ["format", "input", "one_shot_prediction"] + table = wandb.Table(columns=columns) + + for team in demos: + # Encode the team + x_tokens, type_ids, pred_mask = t2s.encode(team) + x_tokens = x_tokens.unsqueeze(0).to(prediction_model.device) + type_ids = type_ids.unsqueeze(0).to(prediction_model.device) + pred_mask = pred_mask.unsqueeze(0).to(prediction_model.device) + + # Get one-shot prediction + oneshot_preds = prediction_model.oneshot_forward(x_tokens, type_ids, pred_mask) + + # Convert to sequences + input_seq = vocab.ints_to_pokeset_seq(x_tokens[0].tolist()) + pred_seq = vocab.ints_to_pokeset_seq(oneshot_preds[0].tolist()) + + # Build input HTML (gray for masked) + input_parts = [] + for tok_str in input_seq: + tok_escaped = html.escape(tok_str) + if "$" in tok_str: + input_parts.append(f'{tok_escaped}') + else: + input_parts.append(tok_escaped) + + # Build prediction HTML (highlight predictions, i.e. positions that were masked in input) + input_visible = set(i for i, tok in enumerate(input_seq) if "$" not in tok) + pred_parts = [] + for i, tok_str in enumerate(pred_seq): + tok_escaped = html.escape(tok_str) + if i not in input_visible: + # This was predicted (was masked in input) + pred_parts.append( + f'{tok_escaped}' + ) + else: + pred_parts.append(tok_escaped) + + table.add_data( + team.format, + wandb.Html(" ".join(input_parts)), + wandb.Html(" ".join(pred_parts)), + ) + + wandb.log({"demo_oneshot": table}, step=step) + + +@dataclass +class EvalResults: + oneshot_metrics: dict # Position-based metrics for one-shot + oneshot_semantic_metrics: dict # Semantic (set-based) metrics for one-shot + iterative_metrics: Optional[dict] = None # Position-based metrics for iterative + iterative_semantic_metrics: Optional[dict] = None # Semantic metrics for iterative + examples: Optional[list] = None + iter_stats: Optional[dict] = None + mask_counts: Optional[list] = None + revealed_counts: Optional[list] = None + + +def evaluate( + prediction_model: TeamPredictionModel, + dataloader: DataLoader, + max_steps: Optional[int] = None, + include_iterative: bool = True, + num_examples: int = 0, + desc: str = "Eval", +) -> EvalResults: + prediction_model.eval() + vocab = prediction_model.vocab + device = prediction_model.device + num_iterations = prediction_model.iterative_decoder.num_iterations + + t2s = Team2Seq() + oneshot_accumulator = EvaluationAccumulator(vocab) + oneshot_semantic_accumulator = SemanticMetricsAccumulator(vocab) + iterative_accumulator = EvaluationAccumulator(vocab) if include_iterative else None + iterative_semantic_accumulator = ( + SemanticMetricsAccumulator(vocab) if include_iterative else None + ) + iter_stats_accumulator = ( + IterativeStatsAccumulator(num_iterations) if include_iterative else None + ) + val_mask_counts = [] + val_revealed_counts = [] + + # Collect batches + batches = [] + num_steps = 0 + for batch in dataloader: + batches.append(batch) + num_steps += 1 + if max_steps is not None and num_steps >= max_steps: + break + + examples = [] + + full_desc = desc if not include_iterative else f"{desc} (one-shot + iterative)" + with torch.no_grad(): + for batch_idx, batch in enumerate( + tqdm.tqdm(batches, desc=full_desc, leave=False) + ): + x_tokens, type_ids, y_tokens, pred_mask = batch + x_tokens = x_tokens.to(device) + type_ids = type_ids.to(device) + y_tokens = y_tokens.to(device) + pred_mask = pred_mask.to(device) + + val_mask_counts.extend(pred_mask.sum(dim=1).cpu().tolist()) + # Count actually revealed tokens (not $missing_*$ in ground truth) + missing_set = torch.tensor(vocab.missing_mask, device=y_tokens.device) + is_revealed = ~torch.isin(y_tokens, missing_set) + val_revealed_counts.extend(is_revealed.sum(dim=1).cpu().tolist()) + + # one-shot eval + logits = prediction_model.forward(x_tokens, type_ids) + loss = F.cross_entropy( + logits.view(-1, logits.shape[-1]), + y_tokens.view(-1), + reduction="none", + ) + loss = (loss * pred_mask.view(-1)).sum() / max(pred_mask.sum().item(), 1) + oneshot_accumulator.add_batch( + logits, y_tokens, pred_mask, type_ids, x_tokens, loss=loss + ) + # Get one-shot predictions using the decoder + oneshot_preds = prediction_model.oneshot_forward( + x_tokens, type_ids, pred_mask + ) + + # One-shot semantic metrics (set-based comparison) + oneshot_semantic_accumulator.add_batch( + oneshot_preds.cpu(), y_tokens.cpu(), x_tokens.cpu(), t2s + ) + + # iterative eval + iterative_preds = None + iter_stats_for_examples = None + if include_iterative: + # Track tokens for visualization on first batch only + track = batch_idx == 0 and num_examples > 0 + iterative_preds, stats = prediction_model.iterative_forward( + x_tokens, type_ids, pred_mask, track_tokens=track + ) + if track: + iter_stats_for_examples = stats + iter_stats_accumulator.add_batch(stats) + # placeholder logits + vocab_size = len(vocab.tokenizer) + iter_logits = torch.zeros( + iterative_preds.shape[0], + iterative_preds.shape[1], + vocab_size, + device=device, + ) + iter_logits.scatter_(2, iterative_preds.unsqueeze(-1), 1.0) + iterative_accumulator.add_batch( + iter_logits, y_tokens, pred_mask, type_ids, x_tokens + ) + # Iterative semantic metrics (set-based comparison) + iterative_semantic_accumulator.add_batch( + iterative_preds.cpu(), y_tokens.cpu(), x_tokens.cpu(), t2s + ) + + # save some predictions for fancy wandb example viz + if batch_idx == 0 and num_examples > 0: + for i in range(min(num_examples, x_tokens.shape[0])): + # Extract per-sample tokens from each iteration + tokens_per_iter = None + if iter_stats_for_examples is not None: + tokens_per_iter = [ + t[i] for t in iter_stats_for_examples.tokens_per_iter + ] + examples.append( + { + "input": x_tokens[i].cpu(), + "ground_truth": y_tokens[i].cpu(), + "oneshot_pred": oneshot_preds[i].cpu(), + "iterative_pred": ( + iterative_preds[i].cpu() + if iterative_preds is not None + else None + ), + "mask": pred_mask[i].cpu(), + "tokens_per_iter": tokens_per_iter, + } + ) + + # summarize metrics + oneshot_metrics = oneshot_accumulator.compute_metrics() + oneshot_semantic_metrics = oneshot_semantic_accumulator.compute_metrics() + iterative_metrics = ( + iterative_accumulator.compute_metrics() if iterative_accumulator else None + ) + iterative_semantic_metrics = ( + iterative_semantic_accumulator.compute_metrics() + if iterative_semantic_accumulator + else None + ) + iter_stats = ( + iter_stats_accumulator.compute_results() if iter_stats_accumulator else None + ) + + return EvalResults( + oneshot_metrics=oneshot_metrics, + oneshot_semantic_metrics=oneshot_semantic_metrics, + iterative_metrics=iterative_metrics, + iterative_semantic_metrics=iterative_semantic_metrics, + examples=examples if num_examples > 0 else None, + iter_stats=iter_stats, + mask_counts=val_mask_counts, + revealed_counts=val_revealed_counts, + ) + + +def log_example_predictions( + examples: list, + vocab: Vocabulary, + step: int, + include_iterative: bool = True, + table_name: str = "val_examples", +): + """ + Log example predictions to wandb with colored HTML output. + """ + if not examples: + return + + columns = ["input", "oneshot_pred", "ground_truth"] + if include_iterative: + columns = ["input", "oneshot_pred", "iterative_pred", "ground_truth"] + + table = wandb.Table(columns=columns) + + for ex in examples: + x_seq = vocab.ints_to_pokeset_seq(ex["input"].tolist()) + oneshot_seq = vocab.ints_to_pokeset_seq(ex["oneshot_pred"].tolist()) + true_seq = vocab.ints_to_pokeset_seq(ex["ground_truth"].tolist()) + mask = ex["mask"] + + # Build HTML for input (green = masked) + x_parts = [] + for x, m in zip(x_seq, mask): + x_escaped = html.escape(x) + if m: + x_parts.append( + f'{x_escaped}' + ) + else: + x_parts.append(x_escaped) + x_html = " ".join(x_parts) + + # Build HTML for one-shot predictions (blue = correct, red = wrong) + oneshot_parts = [] + for p, t, m in zip(oneshot_seq, true_seq, mask): + p_escaped = html.escape(p) + if m: + color = "blue" if p == t else "red" + oneshot_parts.append( + f'{p_escaped}' + ) + else: + oneshot_parts.append(p_escaped) + oneshot_html = " ".join(oneshot_parts) + + # Build HTML for ground truth + true_parts = [] + for t, m in zip(true_seq, mask): + t_escaped = html.escape(t) + if m: + true_parts.append( + f'{t_escaped}' + ) + else: + true_parts.append(t_escaped) + true_html = " ".join(true_parts) + + if include_iterative and ex["iterative_pred"] is not None: + iter_seq = vocab.ints_to_pokeset_seq(ex["iterative_pred"].tolist()) + iter_parts = [] + for p, t, m in zip(iter_seq, true_seq, mask): + p_escaped = html.escape(p) + if m: + color = "blue" if p == t else "red" + iter_parts.append( + f'{p_escaped}' + ) + else: + iter_parts.append(p_escaped) + iter_html = " ".join(iter_parts) + + table.add_data( + wandb.Html(x_html), + wandb.Html(oneshot_html), + wandb.Html(iter_html), + wandb.Html(true_html), + ) + else: + table.add_data( + wandb.Html(x_html), + wandb.Html(oneshot_html), + wandb.Html(true_html), + ) + + wandb.log({table_name: table}, step=step) + + +def log_iterative_decoding_process( + examples: list, + vocab: Vocabulary, + step: int, + table_name: str = "iterative_decoding_process", +): + """ + Log the iterative decoding process to wandb, showing tokens at each iteration. + """ + if not examples: + return + + # Find max iterations across examples + max_iters = max(len(ex.get("tokens_per_iter", [])) for ex in examples) + if max_iters == 0: + return + + # Columns: iter_0 (input), iter_1, ..., iter_N, ground_truth + columns = [f"iter_{i}" for i in range(max_iters)] + ["ground_truth"] + table = wandb.Table(columns=columns) + + for ex in examples: + tokens_per_iter = ex.get("tokens_per_iter", []) + if not tokens_per_iter: + continue + + mask = ex["mask"] + true_seq = vocab.ints_to_pokeset_seq(ex["ground_truth"].tolist()) + + row_data = [] + for iter_idx, tokens in enumerate(tokens_per_iter): + seq = vocab.ints_to_pokeset_seq(tokens.tolist()) + + # For iteration 0 (input), green = masked + # For later iterations, blue = correct, red = wrong, green = still masked + parts = [] + for pos, (tok_str, is_masked, true_str) in enumerate( + zip(seq, mask, true_seq) + ): + tok_escaped = html.escape(tok_str) + + if iter_idx == 0: + # Input: just show masked in green + if is_masked: + parts.append( + f'{tok_escaped}' + ) + else: + parts.append(tok_escaped) + else: + # Later iterations: check if token was originally masked + if is_masked: + # Check if it's been filled (no longer a $missing$ token) + if "$" not in tok_str: # filled in + color = "blue" if tok_str == true_str else "red" + parts.append( + f'{tok_escaped}' + ) + else: + # Still masked + parts.append( + f'{tok_escaped}' + ) + else: + parts.append(tok_escaped) + + row_data.append(wandb.Html(" ".join(parts))) + + # Pad with empty if fewer iterations + while len(row_data) < max_iters: + row_data.append(wandb.Html("")) + + # Add ground truth + true_parts = [] + for t, m in zip(true_seq, mask): + t_escaped = html.escape(t) + if m: + true_parts.append( + f'{t_escaped}' + ) + else: + true_parts.append(t_escaped) + row_data.append(wandb.Html(" ".join(true_parts))) + + table.add_data(*row_data) + + wandb.log({table_name: table}, step=step) + + +def train(config, use_wandb: bool = True): + random.seed(config.seed) + torch.manual_seed(config.seed) + + vocab = Vocabulary() + + # maskers (create training examples) + if config.toy_names_only: + # debug toy: only predict pokemon names + train_masker = NamesOnlyMasker(mask_all=False) # random 1-6 for context + val_masker_standard = NamesOnlyMasker(mask_all=True) + val_masker_low = NamesOnlyMasker(mask_all=True) + print("Using NamesOnlyMasker (toy mode)") + elif config.curriculum_mask: + # curriculum: masking rate anneals from 0.25 to max + train_masker = CurriculumMasker( + warmup_steps=config.curriculum_mask_warmup_steps, + attrs_prob=config.mask_attrs_prob, + ) + val_masker_standard = TeamMasker( + attrs_prob_range=( + config.val_hard_mask_attrs_prob, + config.val_hard_mask_attrs_prob, + ), + ) + val_masker_low = TeamMasker( + attrs_prob_range=( + config.val_easy_mask_attrs_prob, + config.val_easy_mask_attrs_prob, + ), + ) + print( + f"Using CurriculumMasker over {config.curriculum_mask_warmup_steps} steps" + ) + else: + # variable: random masking rate each sample + train_masker = TeamMasker( + attrs_prob_range=(0.1, config.mask_attrs_prob), + ) + val_masker_standard = TeamMasker( + attrs_prob_range=( + config.val_hard_mask_attrs_prob, + config.val_hard_mask_attrs_prob, + ), + ) + val_masker_low = TeamMasker( + attrs_prob_range=( + config.val_easy_mask_attrs_prob, + config.val_easy_mask_attrs_prob, + ), + ) + print(f"Using variable TeamMasker") + + # datasets + if config.gen_weights is not None: + print( + f"Training on specific generations: {sorted(config.gen_weights.keys())} (uniform sampling)" + ) + if config.curriculum_dset: + # curriculum: start with low percentile (few samples), anneal up to 100% + train_dset = ScoredTeamPredictionDataset( + data_dir=config.train_data_dir, + masker=train_masker, + gen_weights=config.gen_weights, + percentile=config.curriculum_dset_start_pct, + split="train", + validation_ratio=config.val_ratio, + seed=config.seed, + verbose=True, + ) + train_dset.enable_curriculum(config.curriculum_dset_start_pct) + print( + f"Curriculum dataset: top {config.curriculum_dset_start_pct}% -> {config.curriculum_dset_end_pct}% over {config.curriculum_dset_warmup_steps} steps" + ) + else: + train_dset = TeamPredictionDataset( + data_dir=config.train_data_dir, + masker=train_masker, + gen_weights=config.gen_weights, + split="train", + validation_ratio=config.val_ratio, + seed=config.seed, + verbose=True, + ) + + # Val: all teams, standard masking + val_dset = TeamPredictionDataset( + data_dir=config.train_data_dir, + masker=val_masker_standard, + gen_weights=config.gen_weights, + split="val", + validation_ratio=config.val_ratio, + seed=config.seed, + verbose=True, + ) + + # Val clean: top percentile teams, low masking (easy) + val_clean_dset = ScoredTeamPredictionDataset( + data_dir=config.train_data_dir, + masker=val_masker_low, + gen_weights=config.gen_weights, + percentile=config.val_clean_percentile, + split="val", + validation_ratio=config.val_ratio, + seed=config.seed, + verbose=True, + ) + + # Val clean hard: top percentile teams, standard masking (hard) + val_clean_hard_dset = ScoredTeamPredictionDataset( + data_dir=config.train_data_dir, + masker=val_masker_standard, + gen_weights=config.gen_weights, + percentile=config.val_clean_percentile, + split="val", + validation_ratio=config.val_ratio, + seed=config.seed, + verbose=True, + ) + + if config.debug_overfit: + print(f"DEBUG OVERFIT MODE: Using {config.batch_size} samples") + from torch.utils.data import Subset + + indices = list(range(min(config.batch_size, len(train_dset)))) + train_dset = Subset(train_dset, indices) + val_dset = Subset(train_dset, indices) + val_clean_indices = list(range(min(config.batch_size, len(val_clean_dset)))) + val_clean_dset = Subset(val_clean_dset, val_clean_indices) + val_clean_hard_dset = Subset(val_clean_hard_dset, val_clean_indices) + + # dataloaders + shuffle = not config.debug_overfit + num_workers = 0 if config.debug_overfit else config.num_workers + persistent = num_workers > 0 + + train_loader = DataLoader( + train_dset, + batch_size=config.batch_size, + shuffle=shuffle, + num_workers=num_workers, + persistent_workers=persistent, + ) + val_loader = DataLoader( + val_dset, + batch_size=config.batch_size, + shuffle=shuffle, + num_workers=num_workers, + persistent_workers=persistent, + ) + val_clean_loader = DataLoader( + val_clean_dset, + batch_size=config.batch_size, + shuffle=shuffle, + num_workers=num_workers, + persistent_workers=persistent, + ) + val_clean_hard_loader = DataLoader( + val_clean_hard_dset, + batch_size=config.batch_size, + shuffle=shuffle, + num_workers=num_workers, + persistent_workers=persistent, + ) + + device = torch.device("cuda" if torch.cuda.is_available() else "cpu") + + prediction_model = create_model( + model_type=config.model_type, + d_model=config.d_model, + nhead=config.nhead, + num_layers=config.num_layers, + dim_feedforward=config.dim_ff, + dropout=config.dropout, + num_iterations=config.eval_num_iterations, + iterative_deterministic=True, # For fair comparison with one-shot + oneshot_deterministic=True, # Argmax for evaluation + device=device, + ) + + # optimizer + optimizer = torch.optim.AdamW( + prediction_model.parameters(), + lr=config.learning_rate, + weight_decay=config.weight_decay, + betas=(0.9, 0.999), + ) + + def lr_lambda(step): + if step < config.warmup_steps: + return step / max(1, config.warmup_steps) + return 1.0 + + scheduler = torch.optim.lr_scheduler.LambdaLR(optimizer, lr_lambda) + + ckpt_dir = os.path.join(config.checkpoint_dir, config.run_name) + os.makedirs(ckpt_dir, exist_ok=True) + best_val_accuracy = 0.0 + patience_count = 0 + global_step = 0 + + if config.from_ckpt: + # start from ckpt + ckpt_path = os.path.join(ckpt_dir, "best_model.pt") + if not os.path.exists(ckpt_path): + raise FileNotFoundError(f"No checkpoint found at {ckpt_path}") + print(f"Loading checkpoint from {ckpt_path}") + extra_state = prediction_model.load_checkpoint(ckpt_path, optimizer) + global_step = extra_state.get("step", 0) + best_val_accuracy = extra_state.get("val_accuracy", 0.0) + # Fast-forward scheduler to match checkpoint (suppress expected warning) + with warnings.catch_warnings(): + warnings.filterwarnings( + "ignore", message=".*lr_scheduler.step.*optimizer.step.*" + ) + for _ in range(global_step): + scheduler.step() + print( + f"Resumed from step {global_step}, best_val_accuracy={best_val_accuracy:.4f}" + ) + print("\nRunning initial evaluation on loaded checkpoint...") + val_results = evaluate( + prediction_model, + val_loader, + max_steps=config.max_eval_steps, + include_iterative=config.eval_with_iterative, + num_examples=0, + ) + val_oneshot = val_results.oneshot_metrics + val_iter = val_results.iterative_metrics + print( + f"Checkpoint eval - one-shot acc: {val_oneshot['token_accuracy']:.4f}, loss: {val_oneshot['loss']:.4f}" + ) + if val_iter: + print(f"Checkpoint eval - iterative acc: {val_iter['token_accuracy']:.4f}") + + # Log demo tables to wandb + if use_wandb: + log_demo_oneshot(prediction_model, vocab, step=global_step) + if config.eval_with_iterative: + log_demo_decoding(prediction_model, vocab, step=global_step) + + running_loss = 0.0 + running_metrics = {} + steps_since_eval = 0 + train_mask_counts = [] + train_revealed_counts = [] + train_iter = iter(train_loader) + pbar = tqdm.tqdm(total=config.max_steps, initial=global_step, desc="Training") + t2s = Team2Seq() + train_semantic_accumulator = SemanticMetricsAccumulator(vocab) + + print( + f"Model parameters: {sum(p.numel() for p in prediction_model.parameters()):,}" + ) + + while global_step < config.max_steps: + try: + x_tokens, type_ids, y_tokens, pred_mask = next(train_iter) + except StopIteration: + train_iter = iter(train_loader) + x_tokens, type_ids, y_tokens, pred_mask = next(train_iter) + + train_masker.set_step(global_step) + + # update curriculum dataset percentile if enabled + if config.curriculum_dset: + progress = min(1.0, global_step / config.curriculum_dset_warmup_steps) + new_percentile = config.curriculum_dset_start_pct + progress * ( + config.curriculum_dset_end_pct - config.curriculum_dset_start_pct + ) + train_dset.set_curriculum_percentile(new_percentile) + + # training + prediction_model.train() + x_tokens = x_tokens.to(device) + type_ids = type_ids.to(device) + y_tokens = y_tokens.to(device) + pred_mask = pred_mask.to(device) + + logits = prediction_model.forward(x_tokens, type_ids) + loss, metrics = compute_loss_and_metrics( + logits, y_tokens, pred_mask, type_ids, vocab + ) + optimizer.zero_grad() + loss.backward() + torch.nn.utils.clip_grad_norm_( + prediction_model.parameters(), config.max_grad_norm + ) + optimizer.step() + scheduler.step() + + train_mask_counts.extend(pred_mask.sum(dim=1).cpu().tolist()) + missing_set = torch.tensor(vocab.missing_mask, device=y_tokens.device) + is_revealed = ~torch.isin(y_tokens, missing_set) + train_revealed_counts.extend(is_revealed.sum(dim=1).cpu().tolist()) + running_loss += loss.item() + for k, v in metrics.items(): + running_metrics[k] = running_metrics.get(k, 0.0) + v + + # accumulate semantic metrics every N steps (decoding is expensive) + semantic_accumulate_every = max(1, config.semantic_train_every_steps // 10) + if global_step % semantic_accumulate_every == 0: + with torch.no_grad(): + probs = torch.softmax(logits, dim=-1) + filt = vocab.filter_probs(probs, type_ids) + train_preds = x_tokens.clone() + train_preds[pred_mask] = filt.argmax(dim=-1)[pred_mask] + train_semantic_accumulator.add_batch( + train_preds.cpu(), y_tokens.cpu(), x_tokens.cpu(), t2s + ) + + global_step += 1 + steps_since_eval += 1 + + pbar.set_postfix( + { + "loss": f"{loss.item():.4f}", + "acc": f"{metrics['token_accuracy']:.2%}", + "lr": f"{scheduler.get_last_lr()[0]:.2e}", + } + ) + pbar.update(1) + + if use_wandb and global_step % config.log_train_every_steps == 0: + avg_loss = running_loss / steps_since_eval + avg_metrics = {k: v / steps_since_eval for k, v in running_metrics.items()} + + log_dict = { + "global_step": global_step, + "train/loss": avg_loss, + "train/learning_rate": scheduler.get_last_lr()[0], + **{f"train/{k}": v for k, v in avg_metrics.items()}, + } + if config.curriculum_dset: + log_dict["train/curriculum_percentile"] = train_dset.percentile + wandb.log(log_dict, step=global_step) + + if train_mask_counts: + wandb.log( + { + "train/num_blanks": wandb.Histogram(train_mask_counts), + "train/num_revealed": wandb.Histogram(train_revealed_counts), + }, + step=global_step, + ) + train_mask_counts = [] + train_revealed_counts = [] + + if global_step % config.eval_every_steps != 0: + running_loss = 0.0 + running_metrics = {} + steps_since_eval = 0 + + if use_wandb and global_step % config.semantic_train_every_steps == 0: + train_semantic_metrics = train_semantic_accumulator.compute_metrics() + wandb.log( + {f"train/semantic/{k}": v for k, v in train_semantic_metrics.items()}, + step=global_step, + ) + train_semantic_accumulator = SemanticMetricsAccumulator(vocab) + + # evaluation + if global_step % config.eval_every_steps == 0: + train_loss = running_loss / steps_since_eval + train_metrics = { + k: v / steps_since_eval for k, v in running_metrics.items() + } + + running_loss = 0.0 + running_metrics = {} + steps_since_eval = 0 + + print(f"\n\nEvaluating at step {global_step}...") + + val_results = evaluate( + prediction_model, + val_loader, + max_steps=config.max_eval_steps, + include_iterative=config.eval_with_iterative, + num_examples=config.num_examples if use_wandb else 0, + desc="val", + ) + + val_clean_results = evaluate( + prediction_model, + val_clean_loader, + max_steps=config.max_eval_steps, + include_iterative=config.eval_with_iterative, + num_examples=config.num_examples if use_wandb else 0, + desc="val_clean", + ) + + val_clean_hard_results = evaluate( + prediction_model, + val_clean_hard_loader, + max_steps=config.max_eval_steps, + include_iterative=config.eval_with_iterative, + num_examples=config.num_examples if use_wandb else 0, + desc="val_clean_hard", + ) + + # Position-based metrics + val_oneshot = val_results.oneshot_metrics + val_iter = val_results.iterative_metrics + val_clean_oneshot = val_clean_results.oneshot_metrics + val_clean_iter = val_clean_results.iterative_metrics + val_clean_hard_oneshot = val_clean_hard_results.oneshot_metrics + val_clean_hard_iter = val_clean_hard_results.iterative_metrics + # Semantic metrics (set-based) + val_oneshot_sem = val_results.oneshot_semantic_metrics + val_iter_sem = val_results.iterative_semantic_metrics + val_clean_oneshot_sem = val_clean_results.oneshot_semantic_metrics + val_clean_iter_sem = val_clean_results.iterative_semantic_metrics + val_clean_hard_oneshot_sem = val_clean_hard_results.oneshot_semantic_metrics + val_clean_hard_iter_sem = val_clean_hard_results.iterative_semantic_metrics + + print(f"\nStep {global_step}:") + print( + f" Train Loss: {train_loss:.4f} | Acc: {train_metrics['token_accuracy']:.3f}" + ) + val_acc_str = f"{val_oneshot['token_accuracy']:.3f}" + if val_iter: + val_acc_str += f" (iter: {val_iter['token_accuracy']:.3f})" + print(f" Val Loss: {val_oneshot['loss']:.4f} | Acc: {val_acc_str}") + + val_clean_acc_str = f"{val_clean_oneshot['token_accuracy']:.3f}" + if val_clean_iter: + val_clean_acc_str += f" (iter: {val_clean_iter['token_accuracy']:.3f})" + print( + f" Val Clean (low mask): {val_clean_oneshot['loss']:.4f} | Acc: {val_clean_acc_str}" + ) + + val_clean_hard_acc_str = f"{val_clean_hard_oneshot['token_accuracy']:.3f}" + if val_clean_hard_iter: + val_clean_hard_acc_str += ( + f" (iter: {val_clean_hard_iter['token_accuracy']:.3f})" + ) + print( + f" Val Clean (std mask): {val_clean_hard_oneshot['loss']:.4f} | Acc: {val_clean_hard_acc_str}" + ) + + print("\n Per-Generation Validation Accuracy:") + for gen in range(1, 10): + gen_key = f"gen{gen}_accuracy" + count_key = f"gen{gen}_count" + if gen_key in val_oneshot: + count = val_oneshot.get(count_key, 0) + iter_str = "" + if val_iter and gen_key in val_iter: + iter_str = f" (iter: {val_iter[gen_key]:.3f})" + print( + f" Gen{gen}: {val_oneshot[gen_key]:.3f}{iter_str} (n={int(count)})" + ) + + print("\n Per-Attribute Validation Accuracy (position-based):") + for k, v in sorted(val_oneshot.items()): + if ( + k.endswith("_accuracy") + and k != "token_accuracy" + and not k.startswith("gen") + ): + print(f" {k}: {v:.3f}") + + # Print semantic metrics (set-based comparison) + print("\n Semantic Metrics (set-based):") + for attr in ["pokemon", "move", "ability", "item", "tera"]: + key = f"{attr}_accuracy" + if key in val_oneshot_sem: + oneshot_val = val_oneshot_sem[key] + total = val_oneshot_sem.get(f"{attr}_total", 0) + iter_str = "" + if val_iter_sem and key in val_iter_sem: + iter_str = f" (iter: {val_iter_sem[key]:.3f})" + print(f" {attr}: {oneshot_val:.3f}{iter_str} (n={int(total)})") + + # Print iterative decoder diagnostics + if val_results.iter_stats and config.eval_with_iterative: + stats = val_results.iter_stats + print("\n Iterative Decoder Diagnostics:") + names_committed = stats.get("names_committed_per_iter", []) + moves_committed = stats.get("moves_committed_per_iter", []) + names_reset = stats.get("names_reset_per_iter", []) + moves_reset = stats.get("moves_reset_per_iter", []) + if names_committed or moves_committed: + print(" Per-iteration commits (names/moves):") + for i in range(len(names_committed)): + nc = names_committed[i] if i < len(names_committed) else 0 + mc = moves_committed[i] if i < len(moves_committed) else 0 + print(f" iter {i}: {nc} names, {mc} moves") + if names_reset or moves_reset: + total_names_reset = sum(names_reset) if names_reset else 0 + total_moves_reset = sum(moves_reset) if moves_reset else 0 + print( + f" Uniqueness resets: {total_names_reset} names, {total_moves_reset} moves" + ) + if total_names_reset > 0 or total_moves_reset > 0: + print(" Per-iteration resets:") + for i in range(max(len(names_reset), len(moves_reset))): + nr = names_reset[i] if i < len(names_reset) else 0 + mr = moves_reset[i] if i < len(moves_reset) else 0 + if nr > 0 or mr > 0: + print(f" iter {i}: {nr} names, {mr} moves") + + if use_wandb: + log_dict = {"global_step": global_step} + + # Position-based metrics: {dset}/one_shot/position/ + log_dict.update( + {f"val/one_shot/position/{k}": v for k, v in val_oneshot.items()} + ) + log_dict.update( + { + f"val_clean/one_shot/position/{k}": v + for k, v in val_clean_oneshot.items() + } + ) + log_dict.update( + { + f"val_clean_hard/one_shot/position/{k}": v + for k, v in val_clean_hard_oneshot.items() + } + ) + + # Semantic metrics for one-shot: {dset}/one_shot/semantic/ + log_dict.update( + { + f"val/one_shot/semantic/{k}": v + for k, v in val_oneshot_sem.items() + } + ) + log_dict.update( + { + f"val_clean/one_shot/semantic/{k}": v + for k, v in val_clean_oneshot_sem.items() + } + ) + log_dict.update( + { + f"val_clean_hard/one_shot/semantic/{k}": v + for k, v in val_clean_hard_oneshot_sem.items() + } + ) + + # Position-based metrics for iterative: {dset}/iterative/position/ + if val_iter: + log_dict.update( + {f"val/iterative/position/{k}": v for k, v in val_iter.items()} + ) + if val_clean_iter: + log_dict.update( + { + f"val_clean/iterative/position/{k}": v + for k, v in val_clean_iter.items() + } + ) + if val_clean_hard_iter: + log_dict.update( + { + f"val_clean_hard/iterative/position/{k}": v + for k, v in val_clean_hard_iter.items() + } + ) + + # Semantic metrics for iterative: {dset}/iterative/semantic/ + if val_iter_sem: + log_dict.update( + { + f"val/iterative/semantic/{k}": v + for k, v in val_iter_sem.items() + } + ) + if val_clean_iter_sem: + log_dict.update( + { + f"val_clean/iterative/semantic/{k}": v + for k, v in val_clean_iter_sem.items() + } + ) + if val_clean_hard_iter_sem: + log_dict.update( + { + f"val_clean_hard/iterative/semantic/{k}": v + for k, v in val_clean_hard_iter_sem.items() + } + ) + + if val_results.iter_stats: + stats = val_results.iter_stats + for i, (mask_ratio, frac) in enumerate( + zip(stats["mask_ratios"], stats["remaining_frac"]) + ): + log_dict[f"val/iterative/iter_{i}_target_mask_ratio"] = ( + mask_ratio + ) + log_dict[f"val/iterative/iter_{i}_remaining_frac"] = frac + + wandb.log(log_dict, step=global_step) + + if val_results.examples: + log_example_predictions( + examples=val_results.examples, + vocab=vocab, + step=global_step, + include_iterative=config.eval_with_iterative, + table_name="val_examples", + ) + if config.eval_with_iterative: + log_iterative_decoding_process( + examples=val_results.examples, + vocab=vocab, + step=global_step, + table_name="val_iterative_process", + ) + if val_clean_results.examples: + log_example_predictions( + examples=val_clean_results.examples, + vocab=vocab, + step=global_step, + include_iterative=config.eval_with_iterative, + table_name="val_clean_examples", + ) + if config.eval_with_iterative: + log_iterative_decoding_process( + examples=val_clean_results.examples, + vocab=vocab, + step=global_step, + table_name="val_clean_iterative_process", + ) + if val_clean_hard_results.examples: + log_example_predictions( + examples=val_clean_hard_results.examples, + vocab=vocab, + step=global_step, + include_iterative=config.eval_with_iterative, + table_name="val_clean_hard_examples", + ) + if config.eval_with_iterative: + log_iterative_decoding_process( + examples=val_clean_hard_results.examples, + vocab=vocab, + step=global_step, + table_name="val_clean_hard_iterative_process", + ) + + # Demo predictions on fixed examples per generation + log_demo_oneshot(prediction_model, vocab, step=global_step) + if config.eval_with_iterative: + log_demo_decoding(prediction_model, vocab, step=global_step) + + if val_results.iter_stats: + hist_dict = {} + stats = val_results.iter_stats + + # Overall confidences + for i, conf in enumerate(stats["confidences"]): + if len(conf) > 0: + finite_conf = conf[torch.isfinite(conf)] + if len(finite_conf) > 0: + hist_dict[f"val/iterative/iter_{i}_confidences"] = ( + wandb.Histogram(finite_conf.numpy(), num_bins=50) + ) + + # Name confidences (diagnostic) + for i, conf in enumerate(stats.get("name_confidences", [])): + if len(conf) > 0: + finite_conf = conf[torch.isfinite(conf)] + if len(finite_conf) > 0: + hist_dict[ + f"val/iterative/diag/iter_{i}_name_confidences" + ] = wandb.Histogram(finite_conf.numpy(), num_bins=50) + + # Move confidences (diagnostic) + for i, conf in enumerate(stats.get("move_confidences", [])): + if len(conf) > 0: + finite_conf = conf[torch.isfinite(conf)] + if len(finite_conf) > 0: + hist_dict[ + f"val/iterative/diag/iter_{i}_move_confidences" + ] = wandb.Histogram(finite_conf.numpy(), num_bins=50) + + # Committed counts + committed = stats.get("committed_per_iter", []) + if committed: + for i, count in enumerate(committed): + hist_dict[f"val/iterative/iter_{i}_committed"] = count + + # Names committed per iter + names_committed = stats.get("names_committed_per_iter", []) + if names_committed: + for i, count in enumerate(names_committed): + hist_dict[ + f"val/iterative/diag/iter_{i}_names_committed" + ] = count + + # Moves committed per iter + moves_committed = stats.get("moves_committed_per_iter", []) + if moves_committed: + for i, count in enumerate(moves_committed): + hist_dict[ + f"val/iterative/diag/iter_{i}_moves_committed" + ] = count + + # Names reset by uniqueness constraints per iter + names_reset = stats.get("names_reset_per_iter", []) + if names_reset: + for i, count in enumerate(names_reset): + hist_dict[f"val/iterative/diag/iter_{i}_names_reset"] = ( + count + ) + # Total names reset across all iterations + hist_dict["val/iterative/diag/total_names_reset"] = sum( + names_reset + ) + + # Moves reset by uniqueness constraints per iter + moves_reset = stats.get("moves_reset_per_iter", []) + if moves_reset: + for i, count in enumerate(moves_reset): + hist_dict[f"val/iterative/diag/iter_{i}_moves_reset"] = ( + count + ) + # Total moves reset across all iterations + hist_dict["val/iterative/diag/total_moves_reset"] = sum( + moves_reset + ) + + if hist_dict: + wandb.log(hist_dict, step=global_step) + + if val_results.mask_counts: + wandb.log( + { + "val/num_blanks": wandb.Histogram(val_results.mask_counts), + "val/num_revealed": wandb.Histogram( + val_results.revealed_counts + ), + }, + step=global_step, + ) + if val_clean_results.mask_counts: + wandb.log( + { + "val_clean/num_blanks": wandb.Histogram( + val_clean_results.mask_counts + ), + "val_clean/num_revealed": wandb.Histogram( + val_clean_results.revealed_counts + ), + }, + step=global_step, + ) + if val_clean_hard_results.mask_counts: + wandb.log( + { + "val_clean_hard/num_blanks": wandb.Histogram( + val_clean_hard_results.mask_counts + ), + "val_clean_hard/num_revealed": wandb.Histogram( + val_clean_hard_results.revealed_counts + ), + }, + step=global_step, + ) + + # checkpointing + if not config.debug_overfit: + # Use val_clean_hard semantic move accuracy as early stopping metric + # (prefer iterative if available, fallback to one-shot) + if ( + val_clean_hard_iter_sem + and "move_accuracy" in val_clean_hard_iter_sem + ): + val_score = val_clean_hard_iter_sem["move_accuracy"] + else: + val_score = val_clean_hard_oneshot_sem.get("move_accuracy", 0.0) + + if val_score > best_val_accuracy: + # early stopping + best_val_accuracy = val_score + patience_count = 0 + + best_model_path = os.path.join(ckpt_dir, "best_model.pt") + prediction_model.save_checkpoint( + best_model_path, + optimizer=optimizer, + extra_state={ + "step": global_step, + "val_semantic_move_accuracy": val_score, + "val_loss": val_oneshot["loss"], + }, + ) + + print(f"\nNew best model! Semantic Move Acc: {val_score:.3f}") + else: + patience_count += 1 + if patience_count >= config.patience: + print(f"\nEarly stopping at step {global_step}") + break + + pbar.close() + + print("\nTraining complete! Saving final model...") + final_model_path = os.path.join(ckpt_dir, "final_model.pt") + prediction_model.save_checkpoint( + final_model_path, + optimizer=optimizer, + extra_state={"step": global_step}, + ) + + print(f"Models saved to {ckpt_dir}") + + +if __name__ == "__main__": + from metamon.data.download import download_revealed_teams + + parser = argparse.ArgumentParser(description="Improved TeamTransformer training") + parser.add_argument("--project", type=str, help="W&B project name") + parser.add_argument("--entity", type=str, help="W&B entity/user") + parser.add_argument("--group", type=str, default=None, help="W&B group for sweeps") + parser.add_argument("--no-wandb", action="store_true", help="Disable W&B logging") + parser.add_argument("--name", type=str, default=None, help="Run name") + parser.add_argument("--checkpoint-dir", type=str, default="checkpoints") + parser.add_argument("--debug-overfit", action="store_true") + parser.add_argument("--toy-names-only", action="store_true") + parser.add_argument("--curriculum-mask", action="store_true") + parser.add_argument("--curriculum-dset", action="store_true") + parser.add_argument( + "--gens", + type=int, + nargs="+", + default=None, + help="Limit training to specific generations (e.g., --gens 1 9). " + "Samples uniformly across specified generations.", + ) + parser.add_argument( + "--from-ckpt", + action="store_true", + help="Resume training from latest checkpoint (uses --checkpoint-dir and --name)", + ) + + args = parser.parse_args() + + if args.gens is not None: + gen_weights = {g: 1.0 for g in args.gens} # uniform across specified gens + else: + gen_weights = None # natural distribution + + sweep_defaults = { + # dataset + "train_data_dir": download_revealed_teams(), + "val_ratio": 0.1, + "batch_size": 128, + "num_workers": 4, + "seed": 42, + "gen_weights": gen_weights, + # architecture + "model_type": "LocalGlobalTeamTransformer", # or "LocalGlobalTeamTransformer" + "d_model": 400, + "nhead": 16, + "num_layers": 8, + "dim_ff": 1600, + "dropout": 0.05, + # training + "learning_rate": 1e-4, + "weight_decay": 1e-4, + "max_grad_norm": 1.0, + "warmup_steps": 5000, + "max_steps": 5_000_000, + "log_train_every_steps": 100, + "semantic_train_every_steps": 5_000, + "eval_every_steps": 5000, + "max_eval_steps": 10, + "patience": 500, + "num_examples": 4, # for wandb viz + "from_ckpt": args.from_ckpt, + # masking params + "mask_attrs_prob": 1.0, # training max (curriculum starts at 0.25) + "val_easy_mask_attrs_prob": 0.2, + "val_hard_mask_attrs_prob": 0.5, + "toy_names_only": False, + "curriculum_mask": args.curriculum_mask, + "curriculum_mask_warmup_steps": 100_000, + "eval_with_iterative": True, + "eval_num_iterations": 8, + "debug_overfit": False, + "val_clean_percentile": 15.0, + # curriculum dataset + "curriculum_dset": args.curriculum_dset, + "curriculum_dset_start_pct": 10.0, + "curriculum_dset_end_pct": 100.0, + "curriculum_dset_warmup_steps": 75_000, + } + + if args.debug_overfit: + sweep_defaults.update( + { + "debug_overfit": True, + "log_train_every_steps": 1, + "semantic_train_every_steps": 10, + "eval_every_steps": 10, + "max_steps": 1000, + } + ) + if args.toy_names_only: + sweep_defaults["toy_names_only"] = True + + use_wandb = not args.no_wandb + + if use_wandb: + wandb.init( + project=args.project, + entity=args.entity, + group=args.group, + config=sweep_defaults, + name=args.name, + ) + cfg = wandb.config + cfg.checkpoint_dir = args.checkpoint_dir + cfg.run_name = wandb.run.name + wandb.define_metric("global_step") + wandb.define_metric("train/*", step_metric="global_step") + wandb.define_metric("val/*", step_metric="global_step") + wandb.define_metric("val_clean/*", step_metric="global_step") + wandb.define_metric("val_clean_hard/*", step_metric="global_step") + else: + from argparse import Namespace + + cfg = Namespace(**sweep_defaults) + cfg.checkpoint_dir = args.checkpoint_dir + cfg.run_name = args.name or "local_run" + + train(cfg, use_wandb) diff --git a/metamon/backend/team_prediction/usage_stats/__init__.py b/metamon/backend/team_prediction/usage_stats/__init__.py index 3eeaf626dc..2f27dd0f97 100644 --- a/metamon/backend/team_prediction/usage_stats/__init__.py +++ b/metamon/backend/team_prediction/usage_stats/__init__.py @@ -1,2 +1,13 @@ -from .stat_reader import get_usage_stats, PreloadedSmogonUsageStats +from .stat_reader import ( + get_usage_stats, + PreloadedSmogonUsageStats, + DEFAULT_USAGE_RANK, + UsageStatsLoadError, + list_available_usage_ranks, + resolve_effective_rating, + rating_to_usage_rank, + resolve_usage_rank, + assert_usage_rank_available, + assert_usage_stats_loaded, +) from .legacy_team_builder import TeamBuilder, PokemonStatsLookupError diff --git a/metamon/backend/team_prediction/usage_stats/create_usage_jsons.py b/metamon/backend/team_prediction/usage_stats/create_usage_jsons.py index de1bf1def7..15ce9c5f9e 100644 --- a/metamon/backend/team_prediction/usage_stats/create_usage_jsons.py +++ b/metamon/backend/team_prediction/usage_stats/create_usage_jsons.py @@ -1,6 +1,7 @@ import os import json import argparse +from collections import defaultdict from tqdm import tqdm from metamon.backend.team_prediction.usage_stats.format_rules import ( @@ -18,56 +19,71 @@ def main(args): total_iterations = 9 * 12 * 12 * len(VALID_TIERS) with tqdm(total=total_iterations, desc="Processing movesets") as pbar: for gen in range(1, 10): - for year in range(2014, 2026): + for year in range(2014, 2027): for month in range(1, 13): + date = f"{year}-{month:02d}" stat_dir = os.path.join(args.smogon_stat_dir) - valid_movesets = [] + valid_movesets_by_rank = defaultdict(list) for format in VALID_TIERS: format_name = f"gen{gen}{format.name.lower()}" - stat = SmogonStat( + + ranks = SmogonStat.available_ranks( format_name, raw_stats_dir=stat_dir, - date=f"{year}-{month:02d}", + date=date, ) - if stat.movesets: - # if we find data for this, save it + + for rank in ranks: + stat = SmogonStat( + format_name, + raw_stats_dir=stat_dir, + date=date, + rank=rank, + verbose=False, + ) + if not stat.movesets: + continue + path = os.path.join( args.save_dir, "movesets_data", f"gen{gen}", f"{format.name.lower()}", - f"{year}-{month:02d}.json", + str(rank), + f"{date}.json", ) os.makedirs(os.path.dirname(path), exist_ok=True) with open(path, "w") as f: json.dump(stat.movesets, f) - valid_movesets.append(stat.movesets) - check_cheatsheet = {} - for mon in stat.movesets.keys(): - checks = stat.movesets[mon]["checks"] - check_cheatsheet[mon] = checks + check_cheatsheet = { + mon: stat.movesets[mon]["checks"] + for mon in stat.movesets.keys() + } path = os.path.join( args.save_dir, "checks_data", f"gen{gen}", f"{format.name.lower()}", - f"{year}-{month:02d}.json", + str(rank), + f"{date}.json", ) os.makedirs(os.path.dirname(path), exist_ok=True) with open(path, "w") as f: json.dump(check_cheatsheet, f) + + valid_movesets_by_rank[rank].append(stat.movesets) pbar.update(1) - if valid_movesets: - # merge all the tiers. used to lookup rare Pokémon choices, i.e. fooling around - # with low-tier Pokémon in OverUsed - inclusive_movesets = merge_movesets(valid_movesets) + + for rank, tier_movesets in valid_movesets_by_rank.items(): + inclusive_movesets = merge_movesets(tier_movesets) path = os.path.join( args.save_dir, "movesets_data", f"gen{gen}", "all_tiers", - f"{year}-{month:02d}.json", + str(rank), + f"{date}.json", ) os.makedirs(os.path.dirname(path), exist_ok=True) with open(path, "w") as f: diff --git a/metamon/backend/team_prediction/usage_stats/legacy_team_builder.py b/metamon/backend/team_prediction/usage_stats/legacy_team_builder.py index 024536b8f8..0ec1a618bf 100644 --- a/metamon/backend/team_prediction/usage_stats/legacy_team_builder.py +++ b/metamon/backend/team_prediction/usage_stats/legacy_team_builder.py @@ -7,7 +7,10 @@ import numpy as np import metamon -from metamon.backend.team_prediction.usage_stats import get_usage_stats +from metamon.backend.team_prediction.usage_stats import ( + get_usage_stats, + DEFAULT_USAGE_RANK, +) from metamon.backend.team_prediction.usage_stats.constants import ( HIDDEN_POWER_IVS, HIDDEN_POWER_DVS, @@ -43,12 +46,18 @@ def __init__( format: str, start_date: datetime.date, end_date: datetime.date, + rank: int = DEFAULT_USAGE_RANK, verbose: bool = False, remove_banned: bool = False, ): self.format = format self.gen = metamon.backend.format_to_gen(format) - self.stat = get_usage_stats(format, start_date, end_date) + self.stat = get_usage_stats( + format, + start_date, + end_date, + rank=rank, + ) if remove_banned: self.stat.remove_banned_pm() self.verbose = verbose @@ -197,10 +206,22 @@ def generate_moveset(self, pokemon): if self.verbose: print(f"Hidden Power {hp_type} detected, IVs set to {ivs}") break + + # Gen 1-2 had no abilities; gen 1 had no held items. + # Usage stats contain bogus data for these, so we override. + if self.gen <= 2: + ability = "No Ability" + else: + ability = weighted_random_choice(abilities, 1)[0] + if self.gen <= 1: + item = "" + else: + item = weighted_random_choice(items, 1)[0] + return { "name": pokemon, - "ability": weighted_random_choice(abilities, 1)[0], - "item": weighted_random_choice(items, 1)[0], + "ability": ability, + "item": item, "spread": weighted_random_choice(spreads, 1)[0], "tera_type": weighted_random_choice(tera_types, 1)[0], "IVs": ivs, @@ -247,10 +268,16 @@ def generate_partial_moveset( print(f"Hidden Power {hp_type} detected, IVs set to {ivs}") break - if not ability: + # Gen 1-2 had no abilities; gen 1 had no held items. + # Usage stats contain bogus data for these, so we override. + if self.gen <= 2: + ability = "No Ability" + elif not ability: ability = weighted_random_choice(abilities, 1)[0] - if not item: + if self.gen <= 1: + item = "" + elif not item: item = weighted_random_choice(items, 1)[0] if not spread: diff --git a/metamon/backend/team_prediction/usage_stats/stat_reader.py b/metamon/backend/team_prediction/usage_stats/stat_reader.py index d3f9f0bc39..acfe6469b2 100644 --- a/metamon/backend/team_prediction/usage_stats/stat_reader.py +++ b/metamon/backend/team_prediction/usage_stats/stat_reader.py @@ -1,15 +1,15 @@ import os -import copy import re import json import datetime import functools import warnings -from typing import Optional +from typing import Optional, List from termcolor import colored import metamon from metamon.config import format_for_agent +from metamon.data.download import download_usage_stats from metamon.backend.team_prediction.usage_stats.format_rules import ( get_valid_pokemon, Tier, @@ -17,7 +17,6 @@ from metamon.backend.replay_parser.str_parsing import pokemon_name from metamon.backend.showdown_dex.dex import Dex - TIER_MAP = { "ubers": Tier.UBERS, "ou": Tier.OU, @@ -29,7 +28,148 @@ } EARLIEST_USAGE_STATS_DATE = datetime.date(2014, 1, 1) -LATEST_USAGE_STATS_DATE = datetime.date(2025, 12, 1) +LATEST_USAGE_STATS_DATE = datetime.date(2026, 4, 1) +DEFAULT_USAGE_RANK = 1500 + +ELITE_REPLAY_SOURCES = ("smogtours",) + + +class UsageStatsLoadError(Exception): + """Raised when usage stats cannot be loaded at the requested rank/time window.""" + + +def assert_usage_rank_available(format: str, rank: int) -> None: + available = list_available_usage_ranks(format) + if not available: + raise UsageStatsLoadError( + f"No usage rank directories found for {format}. " + f"Run `python -m metamon download usage-stats`." + ) + if rank not in available: + raise UsageStatsLoadError( + f"Usage rank {rank} is not available for {format}. " + f"Available ranks on disk: {available}." + ) + + +def assert_usage_stats_loaded( + format: str, + start_date: datetime.date, + end_date: datetime.date, + requested_rank: int, + stats: "PreloadedSmogonUsageStats", +) -> None: + if stats.rank != requested_rank: + raise UsageStatsLoadError( + f"Usage stats for {format} fell back from rank={requested_rank} " + f"to rank={stats.rank} between {start_date} and {end_date}. " + f"Refusing to use a different skill tier silently." + ) + if not stats.movesets: + raise UsageStatsLoadError( + f"Usage stats for {format} at rank={requested_rank} " + f"between {start_date} and {end_date} loaded empty movesets." + ) + + +def _is_elite_replay_source(gameid: Optional[str]) -> bool: + if not gameid: + return False + gameid_lower = gameid.lower() + return any(src in gameid_lower for src in ELITE_REPLAY_SOURCES) + + +def resolve_effective_rating( + rating: Optional[int | str], + gameid: Optional[str], + format: str, +) -> int: + if isinstance(rating, int): + return rating + if _is_elite_replay_source(gameid): + ranks = list_available_usage_ranks(format) + positive = [r for r in ranks if r > 0] + if positive: + return max(positive) + return DEFAULT_USAGE_RANK + + +def rating_to_usage_rank(effective_rating: int, available_ranks: List[int]) -> int: + if not available_ranks: + raise UsageStatsLoadError( + "Cannot map rating to usage rank: no rank directories found on disk." + ) + eligible = [r for r in available_ranks if r <= effective_rating] + return max(eligible) if eligible else min(available_ranks) + + +def resolve_usage_rank( + format: str, + rating: Optional[int | str] = None, + gameid: Optional[str] = None, +) -> int: + available_ranks = list_available_usage_ranks(format) + if not available_ranks: + raise UsageStatsLoadError( + f"No usage rank directories found for {format}. " + f"Run `python -m metamon download usage-stats`." + ) + effective = resolve_effective_rating(rating, gameid, format) + rank = rating_to_usage_rank(effective, available_ranks) + assert_usage_rank_available(format, rank) + return rank + + +def rank_from_moveset_filename(fmt: str, filename: str) -> Optional[int]: + """ + Extract the baseline/rank from a Smogon moveset filename. + Examples: gen1ou-0.txt, gen1ou-1500.txt, gen1ou-1630.txt, gen1ou-1760.txt + Returns rank as int or None if not applicable. + """ + if not filename.startswith(fmt): + return None + if filename.endswith(".txt.gz") or filename.endswith(".gz"): + return None + if not filename.endswith(".txt"): + return None + + stem = filename[:-4] + if stem == fmt: + # Smogon convention: no explicit baseline means 1500. + return 1500 + + m = re.match(rf"^{re.escape(fmt)}-(\d+(?:\.\d+)?)$", stem) + if not m: + return None + return int(float(m.group(1))) + + +def list_available_ranks_in_moveset_dir(moveset_dir: str, fmt: str) -> List[int]: + if not os.path.isdir(moveset_dir): + return [] + ranks = set() + for fn in os.listdir(moveset_dir): + r = rank_from_moveset_filename(fmt, fn) + if r is not None: + ranks.add(r) + return sorted(ranks) + + +def list_available_usage_ranks(format: str) -> List[int]: + """ + List available baseline/rank subdirectories in the processed usage-stats dataset + for a given format (e.g., gen4ou). + """ + gen, tier = int(format[3]), format[4:] + usage_stats_path = download_usage_stats(gen) + base = os.path.join(usage_stats_path, "movesets_data", f"gen{gen}", f"{tier}") + if not os.path.isdir(base): + return [] + ranks = [] + for d in os.listdir(base): + if os.path.isdir(os.path.join(base, d)) and re.fullmatch(r"\d+", d): + ranks.append(int(d)) + return sorted(ranks) def parse_pokemon_moveset(file_path): @@ -46,16 +186,23 @@ def parse_pokemon_moveset(file_path): } def p_name(data_cache): + if not data_cache: + return moveset_data_list name = data_cache[0][2:-2].strip() moveset_data_list["name"].append(name) return moveset_data_list def p_count(data_cache): + if not data_cache: + return moveset_data_list count = int(data_cache[0][2:-2].split(":")[1].strip()) moveset_data_list["count"].append(count) return moveset_data_list def p_abilities(data_cache): + if not data_cache: + moveset_data_list["abilities"].append({}) + return moveset_data_list _abilities = {} assert "Abilities" in data_cache[0], "Abilities not found" for line in data_cache[1:]: @@ -69,6 +216,9 @@ def p_abilities(data_cache): return moveset_data_list def p_items(data_cache): + if not data_cache: + moveset_data_list["items"].append({}) + return moveset_data_list _items = {} assert "Items" in data_cache[0], "Items not found" for line in data_cache[1:]: @@ -82,6 +232,9 @@ def p_items(data_cache): return moveset_data_list def p_spreads(data_cache): + if not data_cache: + moveset_data_list["spreads"].append({}) + return moveset_data_list _spreads = {} assert "Spreads" in data_cache[0], "Spreads not found" for line in data_cache[1:]: @@ -93,6 +246,9 @@ def p_spreads(data_cache): return moveset_data_list def p_moves(data_cache): + if not data_cache: + moveset_data_list["moves"].append({}) + return moveset_data_list _moves = {} assert "Moves" in data_cache[0], "Moves not found" for line in data_cache[1:]: @@ -105,6 +261,9 @@ def p_moves(data_cache): return moveset_data_list def p_tera_types(data_cache): + if not data_cache: + moveset_data_list["tera_types"].append({}) + return moveset_data_list _tera_types = {} assert "Tera Types" in data_cache[0], "Tera Types not found" for line in data_cache[1:]: @@ -116,6 +275,9 @@ def p_tera_types(data_cache): return moveset_data_list def p_teammates(data_cache): + if not data_cache: + moveset_data_list["teammates"].append({}) + return moveset_data_list _teammates = {} assert "Teammates" in data_cache[0], "Teammates not found" for line in data_cache[1:]: @@ -134,6 +296,9 @@ def p_teammates(data_cache): return moveset_data_list def p_checks(data_cache): + if not data_cache: + moveset_data_list["checks"].append({}) + return moveset_data_list _checks = {} assert "Checks and Counters" in data_cache[0], "Checks and Counters not found" for i in range(1, len(data_cache), 2): @@ -181,17 +346,41 @@ def p_checks(data_cache): data_cache = [] for line in lines: - # print(line) - if line.startswith(" +-"): + stripped = line.lstrip() + if stripped.startswith("+-"): section_order[current_section](data_cache) current_section = (current_section + 1) % len(section_order) data_cache = [] - elif line.startswith(" |"): - data_cache.append(line.strip()) + elif stripped.startswith("|"): + if ( + current_section >= 0 + and section_order[current_section] is section_order[-1] + ): + # After the trailing noop section (double boundary between + # Pokemon), the next row is the following Pokemon's name. + current_section = 0 + data_cache = [" " + stripped] + else: + data_cache.append(" " + stripped) n = len(moveset_data_list["name"]) if len(moveset_data_list["tera_types"]) == 0: moveset_data_list["tera_types"] = [{"Nothing": 1.0} for _ in range(n)] + else: + while len(moveset_data_list["tera_types"]) < n: + moveset_data_list["tera_types"].append({"Nothing": 1.0}) + + for key, default in ( + ("count", 0), + ("abilities", {}), + ("items", {}), + ("spreads", {}), + ("moves", {}), + ("teammates", {}), + ("checks", {}), + ): + while len(moveset_data_list[key]) < n: + moveset_data_list[key].append(default) moveset_data = {} for i in range(n): @@ -256,7 +445,7 @@ def __init__( format: str, raw_stats_dir: str, date=None, - rank=None, + rank: Optional[int] = None, verbose: bool = True, ) -> None: if date and type(date) == str: @@ -274,11 +463,21 @@ def __init__( self._movesets = {} self._inclusive = {} self._usage = None + self._available_ranks: List[int] = [] self._load() self._name_conversion = { pokemon_name(pokemon): pokemon for pokemon in self._movesets.keys() } + @staticmethod + def available_ranks(format: str, raw_stats_dir: str, date: str) -> List[int]: + moveset_dir = os.path.join(raw_stats_dir, date, "moveset") + return list_available_ranks_in_moveset_dir(moveset_dir, format) + + @property + def available_ranks_loaded(self) -> List[int]: + return list(self._available_ranks) + def _load(self): moveset_paths = [] for data_path in self.data_paths: @@ -287,21 +486,49 @@ def _load(self): moveset_paths.append(moveset_path) if len(moveset_paths) == 0: - print(f"No moveset data found for {self.format} in {self.data_paths}") + if self.verbose: + print(f"No moveset data found for {self.format} in {self.data_paths}") self._movesets = {} + self._available_ranks = [] return _movesets = [] + ranks_seen = set() for moveset_path in moveset_paths: - format_data = [ - x for x in os.listdir(moveset_path) if x.startswith(self.format + "-") - ] - if self.rank is not None: - format_data = [x for x in format_data if self.rank in x] - _movesets += [ - parse_pokemon_moveset(os.path.join(moveset_path, x)) - for x in format_data - ] + files_by_rank = {} + for fn in os.listdir(moveset_path): + r = rank_from_moveset_filename(self.format, fn) + if r is None: + continue + files_by_rank.setdefault(r, []).append(fn) + + ranks_seen.update(files_by_rank.keys()) + + if not files_by_rank: + continue + + if self.rank is None: + available = sorted(files_by_rank.keys()) + raise ValueError( + f"SmogonStat requires a baseline/rank for {self.format}. " + f"Available ranks in {moveset_path}: {available}" + ) + filenames = files_by_rank.get(self.rank, []) + + for fn in filenames: + fp = os.path.join(moveset_path, fn) + try: + _movesets.append(parse_pokemon_moveset(fp)) + except Exception as e: + if self.verbose: + warnings.warn(colored(f"Failed parsing {fp}: {e}", "red")) + + self._available_ranks = sorted(ranks_seen) + + if not _movesets: + self._movesets = {} + return + self._movesets = { pokemon_name(k): v for k, v in merge_movesets(_movesets).items() } @@ -361,7 +588,12 @@ def usage(self): def load_between_dates( - dir_path: str, start_year: int, start_month: int, end_year: int, end_month: int + dir_path: str, + start_year: int, + start_month: int, + end_year: int, + end_month: int, + warn_if_empty: bool = True, ) -> dict: start_date = datetime.date(start_year, start_month, 1) end_date = datetime.date(end_year, end_month, 1) @@ -378,6 +610,8 @@ def load_between_dates( selected_data = [] for json_file in os.listdir(dir_path): + if not json_file.endswith(".json"): + continue year, month = json_file.replace(".json", "").split("-") date = datetime.date(year=int(year), month=int(month), day=1) if not start_date <= date <= end_date: @@ -385,8 +619,7 @@ def load_between_dates( with open(os.path.join(dir_path, json_file), "r") as file: data = json.load(file) selected_data.append(data) - if not selected_data: - breakpoint() + if not selected_data and warn_if_empty: warnings.warn( colored( f"No Showdown usage stats found in {dir_path} between {start_date} and {end_date}", @@ -402,23 +635,76 @@ def __init__( format, start_date: datetime.date, end_date: datetime.date, + rank: int = DEFAULT_USAGE_RANK, + load_nearest_lower_rank: bool = True, + search_lower_ranks_on_miss: bool = True, verbose: bool = True, ): self.format = format.strip().lower() - self.rank = None + self.rank = int(rank) self.start_date = start_date self.end_date = end_date self.verbose = verbose self._usage = None gen, tier = int(self.format[3]), self.format[4:] self.gen = gen - usage_stats_path = metamon.data.download.download_usage_stats(gen) - movesets_path = os.path.join( + usage_stats_path = download_usage_stats(gen) + movesets_base = os.path.join( usage_stats_path, "movesets_data", f"gen{gen}", f"{tier}" ) - inclusive_path = os.path.join( + inclusive_base = os.path.join( usage_stats_path, "movesets_data", f"gen{gen}", "all_tiers" ) + movesets_path = os.path.join(movesets_base, str(self.rank)) + inclusive_path = os.path.join(inclusive_base, str(self.rank)) + + def _avail_ranks(base: str) -> list[int]: + if not os.path.isdir(base): + return [] + ranks = [] + for d in os.listdir(base): + if os.path.isdir(os.path.join(base, d)) and re.fullmatch(r"\d+", d): + ranks.append(int(d)) + return sorted(ranks) + + def _nearest_lower_rank(target: int, candidates: list[int]) -> Optional[int]: + lower = [r for r in candidates if r < target] + return max(lower) if lower else None + + if not os.path.isdir(movesets_path): + avail = _avail_ranks(movesets_base) + fallback_rank = ( + _nearest_lower_rank(self.rank, avail) + if load_nearest_lower_rank + else None + ) + if fallback_rank is not None: + if self.verbose: + warnings.warn( + colored( + f"Requested rank={self.rank} not found for {self.format}. " + f"Falling back to nearest rank={fallback_rank}.", + "yellow", + ) + ) + self.rank = fallback_rank + movesets_path = os.path.join(movesets_base, str(self.rank)) + inclusive_path = os.path.join(inclusive_base, str(self.rank)) + else: + raise FileNotFoundError( + f"Movesets data not found for {self.format} at rank={self.rank}. " + f"Available ranks: {avail}. " + f"Run `python -m metamon download usage-stats` to get the latest data." + ) + + if not os.path.isdir(inclusive_path): + avail = _avail_ranks(inclusive_base) + raise FileNotFoundError( + f"All-tiers movesets not found for gen{gen} at rank={self.rank}. " + f"Available ranks: {avail}. " + f"Run `python -m metamon download usage-stats` to get the latest data." + ) + # data is split by year and month if not os.path.exists(movesets_path) or not os.path.exists(inclusive_path): raise FileNotFoundError( @@ -431,6 +717,11 @@ def __init__( end_year=end_date.year, end_month=end_date.month, ) + if not self._movesets: + raise FileNotFoundError( + f"No usage stats found for {self.format} at rank={self.rank} " + f"between {start_date} and {end_date} in {movesets_path}." + ) self._inclusive = load_between_dates( inclusive_path, start_year=EARLIEST_USAGE_STATS_DATE.year, @@ -438,6 +729,38 @@ def __init__( end_year=LATEST_USAGE_STATS_DATE.year, end_month=LATEST_USAGE_STATS_DATE.month, ) + self._lower_rank_fallbacks: list[tuple[int, dict, dict]] = [] + if search_lower_ranks_on_miss: + avail = _avail_ranks(movesets_base) + lower_ranks = [r for r in avail if r < self.rank] + lower_ranks.sort(reverse=True) + for r in lower_ranks: + lower_movesets_path = os.path.join(movesets_base, str(r)) + lower_inclusive_path = os.path.join(inclusive_base, str(r)) + if not os.path.isdir(lower_movesets_path) or not os.path.isdir( + lower_inclusive_path + ): + continue + lower_movesets = load_between_dates( + lower_movesets_path, + start_year=start_date.year, + start_month=start_date.month, + end_year=end_date.year, + end_month=end_date.month, + warn_if_empty=False, + ) + lower_inclusive = load_between_dates( + lower_inclusive_path, + start_year=EARLIEST_USAGE_STATS_DATE.year, + start_month=EARLIEST_USAGE_STATS_DATE.month, + end_year=LATEST_USAGE_STATS_DATE.year, + end_month=LATEST_USAGE_STATS_DATE.month, + warn_if_empty=False, + ) + if lower_movesets or lower_inclusive: + self._lower_rank_fallbacks.append( + (r, lower_movesets, lower_inclusive) + ) def _load(self): pass @@ -447,18 +770,38 @@ def _inclusive_search(self, key): key_id = pokemon_name(key) recent = self._movesets.get(key_id, {}) alltime = self._inclusive.get(key_id, {}) - if not (recent or alltime): + if not (recent or alltime or self._lower_rank_fallbacks): return None - if recent and alltime: - # use the alltime stats to selectively get keys that exist - # in recent but are unhelpful for team prediction. - no_info = {"Nothing": 1.0} - for key, value in recent.items(): + no_info = {"Nothing": 1.0} + + def _apply_field_fallback(primary: dict, fallback: dict) -> dict: + if not fallback: + return primary + if not primary: + return fallback + for field, value in fallback.items(): if value == no_info: - if alltime.get(key, {}) != no_info: - recent[key] = alltime[key] - return recent if recent else alltime + continue + if field not in primary or primary.get(field) == no_info: + primary[field] = value + return primary + + # Start with tier stats for the requested rank; do not use all_tiers yet. + primary = recent if recent else {} + + # First, walk downward through lower-rank tier stats. + for _, lower_recent, _ in self._lower_rank_fallbacks: + primary = _apply_field_fallback(primary, lower_recent.get(key_id, {})) + + # If still missing, fall back to all_tiers for the requested rank. + primary = _apply_field_fallback(primary, alltime) + + # Finally, use lower-rank all_tiers as a last resort. + for _, _, lower_alltime in self._lower_rank_fallbacks: + primary = _apply_field_fallback(primary, lower_alltime.get(key_id, {})) + + return primary if primary else None def __getitem__(self, key): entry = Dex.from_gen(self.gen).get_pokedex_entry(key) @@ -476,6 +819,9 @@ def get_usage_stats( format, start_date: Optional[datetime.date] = None, end_date: Optional[datetime.date] = None, + rank: int = DEFAULT_USAGE_RANK, + load_nearest_lower_rank: bool = True, + search_lower_ranks_on_miss: bool = True, ) -> PreloadedSmogonUsageStats: format = format_for_agent(format) if start_date is None or start_date < EARLIEST_USAGE_STATS_DATE: @@ -488,20 +834,47 @@ def get_usage_stats( else: # force to start of months to prevent cache miss (we only have monthly stats anyway) end_date = datetime.date(end_date.year, end_date.month, 1) - return _cached_smogon_stats(format, start_date, end_date) + return _cached_smogon_stats( + format, + start_date, + end_date, + int(rank), + load_nearest_lower_rank, + search_lower_ranks_on_miss, + ) @functools.lru_cache(maxsize=64) -def _cached_smogon_stats(format, start_date: datetime.date, end_date: datetime.date): - print(f"Loading usage stats for {format} between {start_date} and {end_date}") - return PreloadedSmogonUsageStats( - format=format, start_date=start_date, end_date=end_date, verbose=False +def _cached_smogon_stats( + format, + start_date: datetime.date, + end_date: datetime.date, + rank: int, + load_nearest_lower_rank: bool, + search_lower_ranks_on_miss: bool, +): + print( + f"Loading usage stats for {format} between {start_date} and {end_date} (rank={rank})" + ) + assert_usage_rank_available(format, rank) + stats = PreloadedSmogonUsageStats( + format=format, + start_date=start_date, + end_date=end_date, + rank=rank, + load_nearest_lower_rank=load_nearest_lower_rank, + search_lower_ranks_on_miss=search_lower_ranks_on_miss, + verbose=False, ) + assert_usage_stats_loaded(format, start_date, end_date, rank, stats) + return stats if __name__ == "__main__": stats = get_usage_stats( - "gen9ou", datetime.date(2023, 1, 1), datetime.date(2025, 6, 1) + "gen9ou", + datetime.date(2023, 1, 1), + datetime.date(2025, 6, 1), ) print(len(stats.usage)) for mon in sorted( diff --git a/metamon/backend/team_prediction/usage_stats/stat_scraper.py b/metamon/backend/team_prediction/usage_stats/stat_scraper.py index 64a5f89a22..b09f146798 100644 --- a/metamon/backend/team_prediction/usage_stats/stat_scraper.py +++ b/metamon/backend/team_prediction/usage_stats/stat_scraper.py @@ -1,126 +1,217 @@ -import os -import argparse -import asyncio -import aiohttp -import aiofiles -from bs4 import BeautifulSoup -from urllib.parse import urljoin - -base_url = "https://www.smogon.com/stats/" - -parser = argparse.ArgumentParser( - description="Gathers tier usage statistics from Smogon by month across a range of years" -) -parser.add_argument( - "--start_date", - type=int, - default=2015, - help="Start date for scraping (YYYY) (inclusive)", -) -parser.add_argument( - "--end_date", - type=int, - default=2024, - help="End year for scraping (YYYY) (exclusive)", -) -parser.add_argument( - "--save_dir", - type=str, - default="./stats", - help="Local directory to save the scraped files", -) -args = parser.parse_args() - - -def ensure_dir(file_path): - if not os.path.exists(file_path): - os.makedirs(file_path) - - -async def save_text_file(session, url, local_path): - # Check if the file already exists - if os.path.isfile(local_path): - print(f"File already exists: {local_path}") - return - async with session.get(url) as response: - if response.status == 200: - text = await response.text() - async with aiofiles.open(local_path, "w", encoding="utf-8") as file: - await file.write(text) - - -async def scrape_base(session, url, local_dir, start_date, end_date): - async with session.get(url) as response: - text = await response.text() - soup = BeautifulSoup(text, "html.parser") - - tasks = [] - for link in soup.find_all("a"): - href = link.get("href") - if href and not href.startswith("?") and href != "../": - href_date = int(href[:4]) - href_full = urljoin(url, href) - local_path = os.path.join(local_dir, href) - - if ( - href.endswith("/") - and href_date >= start_date - and href_date < end_date - ): # It's a directory - ensure_dir(local_path) - task = asyncio.create_task(scrape(session, href_full, local_path)) - tasks.append(task) - - await asyncio.gather(*tasks) - - -async def scrape(session, url, local_dir): - try: - async with session.get(url) as response: - text = await response.text() - soup = BeautifulSoup(text, "html.parser") - - tasks = [] - for link in soup.find_all("a"): - href = link.get("href") - if "chaos" in href or "monotype" in href or "metagame" in href: - continue - if href and not href.startswith("?"): - href_full = urljoin(url, href) - local_path = os.path.join(local_dir, href) - - if href.endswith("/") and href != "../": # It's a directory - ensure_dir(local_path) - task = asyncio.create_task( - scrape(session, href_full, local_path) - ) - tasks.append(task) - elif href.endswith(".txt") or href.endswith( - ".json" - ): # It's a txt file - print(f"Downloading {href_full} to {local_path}") - task = asyncio.create_task( - save_text_file(session, href_full, local_path) - ) - tasks.append(task) - - await asyncio.gather(*tasks) - except Exception as e: - print(f"Error on url {url}: {e}") - - -ensure_dir(args.save_dir) - - -async def main(): - async with aiohttp.ClientSession() as session: - await scrape_base( - session, - base_url, - args.save_dir, - start_date=args.start_date, - end_date=args.end_date, - ) - - -asyncio.run(main()) +import os +import re +import argparse +import asyncio +import aiohttp +import aiofiles +from bs4 import BeautifulSoup +from urllib.parse import urljoin + +base_url = "https://www.smogon.com/stats/" + +parser = argparse.ArgumentParser( + description="Gathers tier usage statistics from Smogon by month across a range of years" +) +parser.add_argument( + "--start_date", + type=int, + default=2015, + help="Start date for scraping (YYYY) (inclusive)", +) +parser.add_argument( + "--end_date", + type=int, + default=2024, + help="End year for scraping (YYYY) (exclusive)", +) +parser.add_argument( + "--save_dir", + type=str, + default="./stats", + help="Local directory to save the scraped files", +) +parser.add_argument( + "--baselines", + type=str, + default="", + help=( + "Comma-separated baselines to keep (e.g. '0,1500,1695,1825'). " + "Empty = keep all." + ), +) +parser.add_argument( + "--min_baseline", + type=float, + default=None, + help="If set, only download files with baseline >= this value.", +) +parser.add_argument( + "--include_chaos", + action="store_true", + help="Download chaos/ JSON files (includes info.cutoff metadata).", +) +parser.add_argument( + "--max_concurrency", + type=int, + default=8, + help="Maximum number of concurrent HTTP requests.", +) +parser.add_argument( + "--max_retries", + type=int, + default=5, + help="Maximum number of retries for a failed request.", +) +parser.add_argument( + "--backoff_base", + type=float, + default=0.5, + help="Base seconds for exponential backoff (retry delay = base * 2^attempt).", +) +args = parser.parse_args() + + +BASELINE_RE = re.compile(r"-(\d+(?:\.\d+)?)\.(txt|json)$") + +SKIP_DIRS = {"monotype", "metagame", "leads"} +if not args.include_chaos: + SKIP_DIRS.add("chaos") + +allowed_baselines = None +if args.baselines.strip(): + allowed_baselines = { + float(x.strip()) for x in args.baselines.split(",") if x.strip() + } + + +def extract_baseline(href: str): + m = BASELINE_RE.search(href) + if m: + return float(m.group(1)) + if href.endswith(".txt") or href.endswith(".json"): + # Smogon convention: no explicit baseline means 1500. + return 1500.0 + return None + + +def ensure_dir(file_path): + if not os.path.exists(file_path): + os.makedirs(file_path) + + +async def save_text_file(session, url, local_path): + # Check if the file already exists + if os.path.isfile(local_path): + print(f"File already exists: {local_path}") + return + async with session.get(url) as response: + if response.status == 200: + text = await response.text() + async with aiofiles.open(local_path, "w", encoding="utf-8") as file: + await file.write(text) + + +async def _fetch_with_retries(session, url): + for attempt in range(args.max_retries + 1): + try: + async with session.get(url) as response: + if response.status != 200: + raise RuntimeError(f"HTTP {response.status}") + return await response.text() + except Exception: + if attempt >= args.max_retries: + raise + await asyncio.sleep(args.backoff_base * (2**attempt)) + + +async def scrape_base(session, url, local_dir, start_date, end_date, sem): + async with sem: + text = await _fetch_with_retries(session, url) + soup = BeautifulSoup(text, "html.parser") + tasks = [] + for link in soup.find_all("a"): + href = link.get("href") + if href and not href.startswith("?") and href != "../": + href_date = int(href[:4]) + href_full = urljoin(url, href) + local_path = os.path.join(local_dir, href) + + if ( + href.endswith("/") and href_date >= start_date and href_date < end_date + ): # It's a directory + ensure_dir(local_path) + task = asyncio.create_task(scrape(session, href_full, local_path, sem)) + tasks.append(task) + + await asyncio.gather(*tasks) + + +async def scrape(session, url, local_dir, sem): + try: + async with sem: + text = await _fetch_with_retries(session, url) + soup = BeautifulSoup(text, "html.parser") + + tasks = [] + for link in soup.find_all("a"): + href = link.get("href") + if not href or href.startswith("?"): + continue + if href.endswith("/"): + if href == "../": + continue + dirname = href.rstrip("/") + if dirname in SKIP_DIRS: + continue + href_full = urljoin(url, href) + local_path = os.path.join(local_dir, href) + ensure_dir(local_path) + task = asyncio.create_task(scrape(session, href_full, local_path, sem)) + tasks.append(task) + continue + + if href.endswith(".txt") or href.endswith(".json"): + baseline = extract_baseline(href) + if ( + allowed_baselines is not None + and baseline is not None + and baseline not in allowed_baselines + ): + continue + if ( + args.min_baseline is not None + and baseline is not None + and baseline < args.min_baseline + ): + continue + href_full = urljoin(url, href) + local_path = os.path.join(local_dir, href) + print(f"Downloading {href_full} to {local_path}") + task = asyncio.create_task( + save_text_file(session, href_full, local_path) + ) + tasks.append(task) + + await asyncio.gather(*tasks) + except Exception as e: + print(f"Error on url {url}: {e}") + + +ensure_dir(args.save_dir) + + +async def main(): + connector = aiohttp.TCPConnector(limit=args.max_concurrency) + sem = asyncio.Semaphore(args.max_concurrency) + async with aiohttp.ClientSession(connector=connector) as session: + await scrape_base( + session, + base_url, + args.save_dir, + start_date=args.start_date, + end_date=args.end_date, + sem=sem, + ) + + +asyncio.run(main()) diff --git a/metamon/backend/team_prediction/validate.py b/metamon/backend/team_prediction/validate.py index 3475d51d87..1dae77c0f9 100644 --- a/metamon/backend/team_prediction/validate.py +++ b/metamon/backend/team_prediction/validate.py @@ -1,14 +1,20 @@ -import subprocess -import random -from typing import List, Tuple -import gc import argparse +import atexit +import json import os +from dataclasses import dataclass +from multiprocessing import Pool, cpu_count +from pathlib import Path +import random +import shutil +import subprocess +from typing import List, Optional import tqdm from poke_env.teambuilder import ConstantTeambuilder from metamon.backend.team_prediction.team import TeamSet +from metamon.backend.team_prediction.team_index import refresh_team_index from metamon.env import BattleAgainstBaseline from metamon.baselines.heuristic.basic import RandomBaseline from metamon.interface import ( @@ -19,13 +25,473 @@ ) from metamon.tokenizer import get_tokenizer +_REPO_ROOT = Path(__file__).resolve().parents[3] +_PERSISTENT_VALIDATOR = None +_PERSISTENT_VALIDATOR_DISABLED = False +_SHOWDOWN_PROCESS_MARKERS = ( + "dist/server/sockets.js", + "dist/server/room-battle.js", + "pokemon-showdown", +) + + +def _valid_showdown_dist(dist: Path) -> bool: + return (dist / "sim" / "team-validator.js").exists() + + +def _dist_candidates(root: Path) -> List[Path]: + return [root / "dist", root / "pokemon-showdown" / "dist"] + + +def _showdown_roots_from_path(path: Path) -> List[Path]: + roots: List[Path] = [] + seen: set[Path] = set() + current = path.resolve() + if current.is_file(): + current = current.parent + for candidate in [current, *current.parents]: + if candidate.name == "dist" and _valid_showdown_dist(candidate): + root = candidate.parent + else: + root = candidate + for dist in _dist_candidates(root): + if _valid_showdown_dist(dist) and root not in seen: + seen.add(root) + roots.append(root) + break + if roots: + break + return roots + + +def _showdown_roots_from_running_processes() -> List[Path]: + proc_root = Path("/proc") + if not proc_root.is_dir(): + return [] + + roots: List[Path] = [] + seen: set[Path] = set() + + def add_root(root: Path) -> None: + root = root.resolve() + if root in seen: + return + for dist in _dist_candidates(root): + if _valid_showdown_dist(dist): + seen.add(root) + roots.append(root) + return + + for entry in proc_root.iterdir(): + if not entry.name.isdigit(): + continue + cmdline_file = entry / "cmdline" + try: + raw = cmdline_file.read_bytes() + except OSError: + continue + if not raw: + continue + cmdline = raw.replace(b"\0", b" ").decode("utf-8", errors="ignore") + if not any(marker in cmdline for marker in _SHOWDOWN_PROCESS_MARKERS): + continue + + for part in raw.split(b"\0"): + if not part: + continue + text = part.decode("utf-8", errors="ignore") + if "dist/server/" not in text: + continue + path = Path(text) + if "dist" not in path.parts: + continue + root = Path(*path.parts[: path.parts.index("dist")]) + add_root(root) + + try: + add_root((entry / "cwd").resolve()) + except OSError: + pass + + return roots + + +def _candidate_showdown_roots(repo_root: Path) -> List[Path]: + seen: set[Path] = set() + roots: List[Path] = [] + + def add(path: Path) -> None: + for root in _showdown_roots_from_path(path): + resolved = root.resolve() + if resolved not in seen: + seen.add(resolved) + roots.append(resolved) + + extra = os.environ.get("SHOWDOWN_ROOT") + if extra: + add(Path(extra)) + + for root in _showdown_roots_from_running_processes(): + resolved = root.resolve() + if resolved not in seen: + seen.add(resolved) + roots.append(resolved) + + showdown_bin = shutil.which("pokemon-showdown") + if showdown_bin: + add(Path(showdown_bin)) + + bundled = repo_root / "server" / "pokemon-showdown" + if bundled.exists(): + add(bundled) + + return roots + + +def _candidate_node_cwds(repo_root: Path) -> List[Path]: + return _candidate_showdown_roots(repo_root) + + +def _showdown_node_paths(repo_root: Path) -> List[str]: + """NODE_PATH entries so tools/persistent_showdown_validator.js can require pokemon-showdown.""" + seen: set[str] = set() + out: List[str] = [] + for cwd in _candidate_node_cwds(repo_root): + for base in (cwd, cwd / "pokemon-showdown"): + node_modules = base / "node_modules" + if (node_modules / "pokemon-showdown").exists(): + path = str(node_modules.resolve()) + if path not in seen: + seen.add(path) + out.append(path) + return out + + +def _resolve_node_cwd(repo_root: Path) -> Path: + for cwd in _candidate_node_cwds(repo_root): + for base in (cwd, cwd / "pokemon-showdown"): + if (base / "node_modules" / "pokemon-showdown").exists(): + return base + if (base / "node_modules" / ".bin" / "pokemon-showdown").exists(): + return base + return repo_root + + +def _resolve_showdown_dist(repo_root: Path) -> Optional[Path]: + explicit = os.environ.get("SHOWDOWN_DIST") + if explicit: + dist = Path(explicit) + if (dist / "sim" / "team-validator.js").exists(): + return dist.resolve() + return None + for root in _candidate_showdown_roots(repo_root): + for dist in _dist_candidates(root): + if (dist / "sim" / "team-validator.js").exists(): + return dist.resolve() + return None + + +def _validator_env(repo_root: Path) -> dict[str, str]: + env = os.environ.copy() + showdown_dist = _resolve_showdown_dist(repo_root) + if showdown_dist is not None: + env["SHOWDOWN_DIST"] = str(showdown_dist) + else: + node_paths = _showdown_node_paths(repo_root) + if node_paths: + existing = env.get("NODE_PATH", "") + combined = os.pathsep.join(node_paths + ([existing] if existing else [])) + env["NODE_PATH"] = combined + return env + + +def _find_showdown_bin(repo_root: Path) -> Optional[str]: + showdown_bin = shutil.which("pokemon-showdown") + if showdown_bin: + return showdown_bin + for cwd in _candidate_node_cwds(repo_root): + for base in (cwd, cwd / "pokemon-showdown"): + bin_path = base / "node_modules" / ".bin" / "pokemon-showdown" + if bin_path.exists(): + return str(bin_path) + return None + + +def _resolve_showdown_validate_cmd( + format_id: str, cmd: Optional[List[str]] +) -> List[str]: + if cmd is not None: + return cmd + [format_id] + showdown_bin = _find_showdown_bin(_REPO_ROOT) + if showdown_bin: + return [showdown_bin, "validate-team", format_id] + return ["npx", "pokemon-showdown", "validate-team", format_id] + + +class PersistentShowdownValidator: + def __init__(self, repo_root: Path): + self._repo_root = repo_root + self._script_path = repo_root / "tools" / "persistent_showdown_validator.js" + if not self._script_path.exists(): + raise FileNotFoundError(f"Missing validator script at {self._script_path}") + self._cwd = _resolve_node_cwd(repo_root) + self._proc = self._start_process() + if not self._ping(): + self.close() + raise RuntimeError("Persistent validator failed to start") + + def _start_process(self) -> subprocess.Popen: + return subprocess.Popen( + ["node", str(self._script_path)], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, + cwd=str(self._cwd), + env=_validator_env(self._repo_root), + bufsize=1, + ) + + def _send(self, payload: dict) -> Optional[dict]: + if self._proc.poll() is not None: + return None + if self._proc.stdin is None or self._proc.stdout is None: + return None + try: + self._proc.stdin.write(json.dumps(payload) + "\n") + self._proc.stdin.flush() + except BrokenPipeError: + return None + line = self._proc.stdout.readline() + if not line: + return None + try: + return json.loads(line) + except json.JSONDecodeError: + return None + + def _ping(self) -> bool: + response = self._send({"format": "gen1ou", "team": ""}) + return response is not None + + def validate(self, team_str: str, format_id: str) -> tuple[bool, List[str]]: + response = self._send({"format": format_id, "team": team_str}) + if response is None: + raise RuntimeError("Validator process is not responding") + ok = bool(response.get("ok")) + errors = response.get("errors") or [] + return ok, [str(err) for err in errors] + + def close(self) -> None: + if self._proc is None: + return + if self._proc.poll() is None: + try: + if self._proc.stdin is not None: + self._proc.stdin.close() + self._proc.terminate() + self._proc.wait(timeout=1) + except subprocess.TimeoutExpired: + self._proc.kill() + self._proc = None + + +_POOL_VALIDATOR: Optional["PersistentShowdownValidator"] = None + + +@dataclass +class ValidateFileResult: + file: str + passed: bool + errors: Optional[List[str]] = None + parse_error: Optional[str] = None + + +def _init_validate_pool() -> None: + global _POOL_VALIDATOR + _POOL_VALIDATOR = PersistentShowdownValidator(_REPO_ROOT) + + +def _validate_file( + filename: str, + input_dir: str, + output_dir: str, + format_id: str, + validator: PersistentShowdownValidator, + write_output: bool, +) -> ValidateFileResult: + filepath = os.path.join(input_dir, filename) + try: + team = TeamSet.from_showdown_file(filepath, format=format_id) + team_str = team.to_str() + except Exception as exc: + return ValidateFileResult(file=filename, passed=False, parse_error=str(exc)) + + try: + ok, errors = validator.validate(team_str, format_id) + except Exception as exc: + return ValidateFileResult(file=filename, passed=False, errors=[str(exc)]) + + if not ok: + return ValidateFileResult(file=filename, passed=False, errors=errors) + + if write_output: + out_dir = os.path.join(output_dir, format_id) + os.makedirs(out_dir, exist_ok=True) + with open(os.path.join(out_dir, filename), "w") as f: + f.write(team_str) + + return ValidateFileResult(file=filename, passed=True) + + +def _validate_file_pool(args: tuple[str, str, str, str]) -> ValidateFileResult: + if _POOL_VALIDATOR is None: + raise RuntimeError("Validator pool was not initialized") + filename, input_dir, output_dir, format_id = args + return _validate_file( + filename, + input_dir, + output_dir, + format_id, + _POOL_VALIDATOR, + write_output=True, + ) + + +def _list_team_files(input_dir: str) -> List[str]: + """List team files once each, in deterministic order before optional shuffle.""" + seen: set[str] = set() + files: List[str] = [] + for name in sorted(os.listdir(input_dir)): + if not name.endswith("team"): + continue + if name in seen: + raise RuntimeError(f"Duplicate team filename in {input_dir}: {name}") + seen.add(name) + files.append(name) + return files + + +def _default_workers() -> int: + return max(1, min(16, cpu_count() - 2)) + + +def _validate_directory( + format_id: str, + input_path: str, + output_path: str, + workers: int, + print_errors: bool = True, +) -> tuple[int, int, int]: + input_dir = os.path.join(input_path, format_id) + if not os.path.isdir(input_dir): + raise FileNotFoundError(f"Input directory not found: {input_dir}") + + files = _list_team_files(input_dir) + random.shuffle(files) + # One tuple per file; pool.imap_unordered dispatches each item to exactly one worker. + work = [(f, input_dir, output_path, format_id) for f in files] + + passed = 0 + rejected = 0 + parse_errors = 0 + seen_results: set[str] = set() + + def handle_result(result: ValidateFileResult) -> None: + nonlocal passed, rejected, parse_errors + if result.file in seen_results: + raise RuntimeError(f"Processed {result.file} more than once") + seen_results.add(result.file) + if result.passed: + passed += 1 + return + if result.parse_error is not None: + parse_errors += 1 + if print_errors: + print(result.parse_error) + else: + rejected += 1 + if print_errors and result.errors: + print(result.errors) + + if workers <= 1: + validator = PersistentShowdownValidator(_REPO_ROOT) + try: + for filename in tqdm.tqdm(files): + result = _validate_file( + filename, + input_dir, + output_path, + format_id, + validator, + write_output=True, + ) + handle_result(result) + finally: + validator.close() + else: + chunksize = max(1, len(work) // (workers * 8)) + with Pool(workers, initializer=_init_validate_pool) as pool: + for result in tqdm.tqdm( + pool.imap_unordered(_validate_file_pool, work, chunksize=chunksize), + total=len(work), + ): + handle_result(result) + + if len(seen_results) != len(files): + raise RuntimeError( + f"Expected {len(files)} team results, got {len(seen_results)}" + ) + if passed + rejected + parse_errors != len(files): + raise RuntimeError( + f"Result counts do not match input files: " + f"{passed + rejected + parse_errors} vs {len(files)}" + ) + + output_format_dir = os.path.join(output_path, format_id) + if passed: + index_path, index_count = refresh_team_index(output_format_dir, format_id) + print(f"Wrote {index_path} ({index_count:,} teams)") + + return passed, rejected, parse_errors + + +def _get_persistent_validator() -> Optional[PersistentShowdownValidator]: + global _PERSISTENT_VALIDATOR, _PERSISTENT_VALIDATOR_DISABLED + if _PERSISTENT_VALIDATOR_DISABLED: + return None + if _PERSISTENT_VALIDATOR is None: + try: + _PERSISTENT_VALIDATOR = PersistentShowdownValidator(_REPO_ROOT) + atexit.register(_PERSISTENT_VALIDATOR.close) + except Exception as exc: # pragma: no cover - best-effort optimization + print(f"Persistent validator unavailable, falling back to CLI: {exc}") + _PERSISTENT_VALIDATOR_DISABLED = True + return None + return _PERSISTENT_VALIDATOR + def validate_showdown_team( team_str: str, format_id: str = "gen1ou", - cmd: List[str] = ["npx", "pokemon-showdown", "validate-team"], -) -> Tuple[bool, List[str]]: - full_cmd = cmd + [format_id] + cmd: Optional[List[str]] = None, +) -> bool: + validator = _get_persistent_validator() + if validator is not None: + global _PERSISTENT_VALIDATOR_DISABLED + try: + ok, errors = validator.validate(team_str, format_id) + except Exception as exc: # pragma: no cover - best-effort optimization + validator.close() + _PERSISTENT_VALIDATOR_DISABLED = True + print(f"Persistent validator failed, falling back to CLI: {exc}") + else: + if ok: + return True + print(errors) + return False + + full_cmd = _resolve_showdown_validate_cmd(format_id, cmd) proc = subprocess.run(full_cmd, input=team_str, text=True, capture_output=True) @@ -82,30 +548,31 @@ def env_verify_team(team_str: str, format_id: str = "gen1ou") -> bool: required=True, help="Path to output directory for verified teams", ) + parser.add_argument( + "--workers", + type=int, + default=1, + help=( + "Parallel workers (each runs its own Showdown validator process). " + f"Suggested: {_default_workers()} on this machine." + ), + ) args = parser.parse_args() - print(f"Processing format: {args.format}") - - path = os.path.join(args.input_path, args.format) - if os.path.isdir(path): - files = os.listdir(path) - random.shuffle(files) - for file in tqdm.tqdm(files): - if file.endswith("team"): - filename = os.path.join(path, file) - format = path.split("/")[-1] - try: - team = TeamSet.from_showdown_file(filename, format=format) - team_str = team.to_str() - except Exception as e: - print(e) - continue - - if not validate_showdown_team(team_str, format): - continue - # if not env_verify_team(team_str, format): - # continue - - os.makedirs(os.path.join(args.output_path, format), exist_ok=True) - with open(os.path.join(args.output_path, format, file), "w") as f: - f.write(team_str) + print(f"Processing format: {args.format} (workers={args.workers})") + + passed, rejected, parse_errors = _validate_directory( + format_id=args.format, + input_path=args.input_path, + output_path=args.output_path, + workers=args.workers, + ) + total = passed + rejected + parse_errors + if total: + print( + f"Done: {passed:,}/{total:,} passed " + f"({passed / total * 100:.1f}%), " + f"{rejected:,} invalid, {parse_errors:,} parse errors" + ) + else: + print("Done: no team files found") diff --git a/metamon/backend/team_prediction/vocabulary.py b/metamon/backend/team_prediction/vocabulary.py index c101aa5eec..1a8f1b84f4 100644 --- a/metamon/backend/team_prediction/vocabulary.py +++ b/metamon/backend/team_prediction/vocabulary.py @@ -1,4 +1,5 @@ import os +from functools import lru_cache from typing import Optional from collections import defaultdict from datetime import date @@ -42,7 +43,9 @@ def create_vocabularies(scan_dataset: bool = False): for tier in ["ou", "uu", "ubers", "nu"]: format = f"gen{gen}{tier}" stat = get_usage_stats( - format, start_date=date(2015, 1, 1), end_date=date(2025, 1, 1) + format, + start_date=date(2015, 1, 1), + end_date=date(2025, 1, 1), ) for pokemon_name, data in stat._inclusive.items(): @@ -94,12 +97,15 @@ def create_vocabularies(scan_dataset: bool = False): lines = f.read().splitlines()[1:] # skip header team_files = [os.path.join(data_dir, line) for line in lines if line] + from metamon.backend.team_prediction.team import Team2Seq + + t2s = Team2Seq(include_stats=False) print(f"Scanning {len(team_files)} team files for unknown tokens...") for path in tqdm.tqdm(team_files, desc="Scanning team files"): try: format_str = to_id_str(os.path.splitext(path)[1].split("_")[0]) team = TeamSet.from_showdown_file(path, format=format_str) - seq, _ = team.to_seq(include_stats=False) + seq, _ = t2s.to_seq(team) for token in seq: team_tokenizer.add_token_for(token) except Exception as e: @@ -201,6 +207,17 @@ def __init__(self): "Tera Type": 8, }, ) + # Importance weights for weighted accuracy metric + self.attribute_weights = { + "Mon": 3.0, + "Move": 2.0, + "Ability": 1.5, + "Item": 1.5, + "Tera Type": 1.5, + "Nature": 1.0, + "EV": 0.5, + "IV": 0.5, + } self.type_id_to_mask = { 0: self.format_mask, 1: self.mon_mask, @@ -213,6 +230,18 @@ def __init__(self): 8: self.tera_type_mask, } + # Map format token IDs to generation numbers + # e.g., token for "Format: gen1ou" -> 1 + self.format_token_to_gen = {} + for token_id in self.format_mask: + token_str = self.tokenizer.all_words[token_id] # e.g., "Format: gen1ou" + # Extract gen number from format string (format is "Format: genXtier") + format_part = token_str.split(": ")[1] if ": " in token_str else token_str + for gen in range(1, 10): + if format_part.lower().startswith(f"gen{gen}"): + self.format_token_to_gen[token_id] = gen + break + def pokeset_seq_to_ints(self, seq: list[str]) -> np.ndarray: tokens = self.tokenizer.tokenize(seq) type_ids = np.array( @@ -247,6 +276,12 @@ def filter_probs(self, probs: torch.Tensor, type_ids: torch.Tensor) -> torch.Ten return filtered / (filtered.sum(dim=-1, keepdim=True) + 1e-10) +@lru_cache(maxsize=1) +def get_vocab() -> Vocabulary: + """Cached Vocabulary singleton (read-only after init).""" + return Vocabulary() + + if __name__ == "__main__": import argparse diff --git a/metamon/backend/team_prediction/write_team_indexes.py b/metamon/backend/team_prediction/write_team_indexes.py new file mode 100644 index 0000000000..51ca13c27a --- /dev/null +++ b/metamon/backend/team_prediction/write_team_indexes.py @@ -0,0 +1,101 @@ +"""Write index.csv for team set directories under METAMON_CACHE_DIR/teams.""" + +from __future__ import annotations + +import argparse +import os +from pathlib import Path + +import metamon +from metamon.backend.team_prediction.team_index import ( + PUBLIC_TEAM_SETS, + iter_format_dirs, + refresh_team_index, + should_index_set_dir, +) + + +def write_indexes( + teams_root: Path, + set_names: list[str] | None = None, + formats: list[str] | None = None, + public_only: bool = True, +) -> list[tuple[str, str, int, Path]]: + written: list[tuple[str, str, int, Path]] = [] + if set_names is None and public_only: + set_names = sorted(PUBLIC_TEAM_SETS) + set_dirs = sorted( + p + for p in teams_root.iterdir() + if p.is_dir() + and should_index_set_dir(p.name, public_only=public_only) + and (set_names is None or p.name in set_names) + ) + + for set_dir in set_dirs: + for format_dir, battle_format in iter_format_dirs(set_dir): + if formats and battle_format not in formats: + continue + index_path, count = refresh_team_index(format_dir, battle_format) + written.append((set_dir.name, battle_format, count, index_path)) + print(f"Wrote {index_path} ({count:,} teams)") + return written + + +def main() -> None: + default_root = ( + Path(metamon.METAMON_CACHE_DIR) / "teams" if metamon.METAMON_CACHE_DIR else None + ) + parser = argparse.ArgumentParser( + description="Generate index.csv for team sets under METAMON_CACHE_DIR/teams." + ) + parser.add_argument( + "--teams-root", + type=Path, + default=default_root, + help="Root directory containing team set folders (default: $METAMON_CACHE_DIR/teams)", + ) + parser.add_argument( + "--set", + action="append", + dest="sets", + metavar="SET", + help="Only index this set (repeatable)", + ) + parser.add_argument( + "--format", + action="append", + dest="formats", + metavar="FORMAT", + help="Only index this format (repeatable)", + ) + parser.add_argument( + "--all-sets", + action="store_true", + help="Index every team dir (not just public HF sets)", + ) + args = parser.parse_args() + + if args.teams_root is None: + raise SystemExit( + "METAMON_CACHE_DIR is not set and --teams-root was not provided" + ) + + if not args.teams_root.is_dir(): + raise SystemExit(f"Teams root not found: {args.teams_root}") + + entries = write_indexes( + args.teams_root, + set_names=args.sets, + formats=args.formats, + public_only=not args.all_sets, + ) + if not entries: + print("No team directories indexed.") + else: + total = sum(count for _, _, count, _ in entries) + print(f"Indexed {len(entries)} format dirs ({total:,} teams total)") + + +if __name__ == "__main__": + main() diff --git a/metamon/backend/team_preview/preview.py b/metamon/backend/team_preview/preview.py index 91f6d307c7..cb9121a2ce 100644 --- a/metamon/backend/team_preview/preview.py +++ b/metamon/backend/team_preview/preview.py @@ -18,6 +18,7 @@ import wandb from tqdm import tqdm +from metamon.config import format_for_agent from metamon.interface import ( UniversalState, consistent_pokemon_order, @@ -404,6 +405,9 @@ def predict_lead( if device is None: device = next(self.parameters()).device + if battle_format is not None: + battle_format = format_for_agent(battle_format) + # sort teams consistently our_team_with_info = list( zip(our_team, our_team_moves, our_team_abilities, our_team_items) diff --git a/metamon/config.py b/metamon/config.py index 9b59d76293..e04fd3df1e 100644 --- a/metamon/config.py +++ b/metamon/config.py @@ -1,5 +1,9 @@ import os +# officially supported Showdown rulesets. +# gen1-4 nu/uu/ubers were supported by the original metamon paper, +# but their rules change often and their datasets are rarely updated, +# so our models would be very out-of-sync with current metagame. SUPPORTED_BATTLE_FORMATS = [ "gen1ou", "gen1uu", @@ -20,6 +24,8 @@ "gen9ou", ] + +# play format A without OOD inputs by telling the agent it is playing format B. FORMAT_ALIASES = { "gen1oulongtimer": "gen1ou", "gen9oulongtimer": "gen9ou", diff --git a/metamon/data/__init__.py b/metamon/data/__init__.py index 2901be227d..7e4ccb5ee8 100644 --- a/metamon/data/__init__.py +++ b/metamon/data/__init__.py @@ -2,5 +2,30 @@ DATA_PATH = os.path.dirname(__file__) -from .parsed_replay_dset import MetamonDataset, ParsedReplayDataset, SelfPlayDataset -from . import raw_replay_util +__all__ = [ + "DATA_PATH", + "MetamonDataset", + "ParsedReplayDataset", + "SelfPlayDataset", + "raw_replay_util", +] + + +def __getattr__(name: str): + if name == "MetamonDataset": + from .parsed_replay_dset import MetamonDataset + + return MetamonDataset + if name == "ParsedReplayDataset": + from .parsed_replay_dset import ParsedReplayDataset + + return ParsedReplayDataset + if name == "SelfPlayDataset": + from .parsed_replay_dset import SelfPlayDataset + + return SelfPlayDataset + if name == "raw_replay_util": + from . import raw_replay_util + + return raw_replay_util + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/metamon/data/download.py b/metamon/data/download.py index 400a316d0a..af80204829 100644 --- a/metamon/data/download.py +++ b/metamon/data/download.py @@ -10,7 +10,7 @@ import metamon from metamon.config import SUPPORTED_BATTLE_FORMATS, METAMON_CACHE_DIR -SELF_PLAY_SUBSETS = ["pac-base", "pac-exploratory"] +SELF_PLAY_SUBSETS = ["pac-base", "pac-exploratory", "pac-tauros"] SELF_PLAY_FORMATS = [ "gen1ou", "gen2ou", @@ -18,16 +18,69 @@ "gen4ou", "gen9ou", ] # OU formats available for self-play +SELF_PLAY_SUBSET_FORMATS = { + "pac-base": SELF_PLAY_FORMATS, + "pac-exploratory": SELF_PLAY_FORMATS, + "pac-tauros": ["gen1ou"], +} + +# Replay-derived team sets on HF are published for OU tiers only (gen1-4ou, gen9ou). +REPLAY_DERIVED_OU_ONLY_TEAM_SETS = frozenset( + { + "paper_replays", + "modern_replays", + "modern_replays_v2", + "gl_05_26", + "hl_05_26", + } +) + + +def get_self_play_formats(subset: str) -> list[str]: + """Formats published on HF for a self-play subset.""" + if subset not in SELF_PLAY_SUBSET_FORMATS: + raise ValueError( + f"Invalid subset: {subset}. Must be one of {list(SELF_PLAY_SUBSET_FORMATS)}" + ) + return SELF_PLAY_SUBSET_FORMATS[subset] + + +def iter_self_play_downloads( + subsets: list[str] | None = None, + formats: list[str] | None = None, +) -> list[tuple[str, str]]: + """Resolve (subset, format) pairs to download for self-play data.""" + selected_subsets = subsets or SELF_PLAY_SUBSETS + unknown = [subset for subset in selected_subsets if subset not in SELF_PLAY_SUBSETS] + if unknown: + raise ValueError( + f"Invalid subset(s): {unknown}. Must be one of {SELF_PLAY_SUBSETS}" + ) + + downloads: list[tuple[str, str]] = [] + for subset in selected_subsets: + available_formats = get_self_play_formats(subset) + requested_formats = formats or available_formats + for battle_format in requested_formats: + if battle_format not in available_formats: + print( + f"Skipping {subset}/{battle_format}: " + f"not published for this subset (available: {available_formats})" + ) + continue + downloads.append((subset, battle_format)) + return downloads + if METAMON_CACHE_DIR is not None: VERSION_REFERENCE_PATH = os.path.join(METAMON_CACHE_DIR, "version_reference.json") else: VERSION_REFERENCE_PATH = None -LATEST_RAW_REPLAY_REVISION = "v5" -LATEST_PARSED_REPLAY_REVISION = "v5" -LATEST_TEAMS_REVISION = "v4" -LATEST_USAGE_STATS_REVISION = "v3" +LATEST_RAW_REPLAY_REVISION = "v6" +LATEST_PARSED_REPLAY_REVISION = "v6" +LATEST_TEAMS_REVISION = "v5" +LATEST_USAGE_STATS_REVISION = "v5" def _update_version_reference(key: str, name: str, version: str): @@ -212,7 +265,7 @@ def download_self_play_data( """Download self-play data from the metamon-parsed-pile dataset. Args: - subset: The subset to download. Options: "pac-base", "pac-exploratory" + subset: The subset to download. Options: "pac-base", "pac-exploratory", "pac-tauros" battle_format: Showdown battle format (e.g. "gen1ou") version: Version/revision of the dataset to download. Defaults to "main". force_download: If True, download the dataset even if a previous version @@ -399,9 +452,12 @@ def print_version_tree(version_dict: dict, indent: int = 0): # Download (anonymized) Showdown replay logs (all formats) python -m metamon.data.download raw-replays - # Download self-play datasets (pac-base and pac-exploratory) + # Download self-play datasets (pac-base, pac-exploratory, pac-tauros) python -m metamon.data.download self-play --formats gen1ou gen9ou + # Download only pac-tauros (defaults to gen1ou for that subset) + python -m metamon.data.download self-play --subsets pac-tauros + Note: Requires METAMON_CACHE_DIR environment variable to be set. The cache directory is currently: {colored(METAMON_CACHE_DIR or 'NOT SET', 'red')} @@ -429,7 +485,7 @@ def print_version_tree(version_dict: dict, indent: int = 0): Dataset to download: raw-replays: Unprocessed Showdown replays (stripped of usernames/chat) parsed-replays: RL-compatible version of replays with reconstructed player actions - self-play: Self-play battle data (pac-base and pac-exploratory subsets) + self-play: Self-play battle data (pac-base, pac-exploratory, pac-tauros subsets) revealed-teams: Teams that were revealed during battles replay-stats: Statistics generated from revealed teams. Used to predict team sets. teams: Various team sets (competitive, paper_variety, paper_replays) @@ -443,10 +499,24 @@ def print_version_tree(version_dict: dict, indent: int = 0): help=""" Battle formats to download. Defaults depend on dataset type: - parsed-replays, teams, usage-stats: All Gen 1-4 formats (OU, UU, NU, Ubers) + Gen 9 OU - - self-play: gen1ou, gen2ou, gen3ou, gen4ou, gen9ou (only OU available) + - self-play: gen1ou, gen2ou, gen3ou, gen4ou, gen9ou (only OU available; defaults depend on subset) Examples: --formats gen1ou gen2ou # Only Gen 1-2 OU --formats gen3uu gen4uu # Only Gen 3-4 UU +""", + ) + parser.add_argument( + "--subsets", + nargs="+", + type=str, + choices=SELF_PLAY_SUBSETS, + default=None, + help=""" +Self-play subsets to download. Defaults to all subsets (pac-base, pac-exploratory, pac-tauros). +When --formats is omitted, each subset uses its published formats (e.g. pac-tauros -> gen1ou only). +Examples: + --subsets pac-tauros + --subsets pac-base pac-exploratory --formats gen1ou """, ) parser.add_argument( @@ -456,10 +526,10 @@ def print_version_tree(version_dict: dict, indent: int = 0): help=""" Specific version to download. Defaults to latest version. Available versions: - raw-replays: v1, v2, v3, v4 - parsed-replays: v0 (deprecated) v1, v2, v3-beta, v3, v4 - teams: v0, v1, v2, v3, v4 - usage-stats: v0, v1, v2 + raw-replays: v1, v2, v3, v4, v5, v6 + parsed-replays: v0 (deprecated) v1, v2, v3-beta, v3, v4, v5, v6 + teams: v0, v1, v2, v3, v4, v5 + usage-stats: v0, v1, v2, v3, v4, v5 The huggingface READMEs have changelogs. """, @@ -476,14 +546,20 @@ def print_version_tree(version_dict: dict, indent: int = 0): download_parsed_replays(format, version=version, force_download=True) elif args.dataset == "self-play": version = args.version or "main" - formats = args.formats or SELF_PLAY_FORMATS - print(f"Downloading self-play data for formats: {formats}") - for subset in SELF_PLAY_SUBSETS: - print(f"\nDownloading {subset}...") - for format in formats: - download_self_play_data( - subset, format, version=version, force_download=True - ) + downloads = iter_self_play_downloads( + subsets=args.subsets, + formats=args.formats, + ) + if not downloads: + raise ValueError( + "No self-play downloads matched the requested subsets/formats" + ) + print(f"Downloading self-play data: {downloads}") + for subset, battle_format in downloads: + print(f"\nDownloading {subset}/{battle_format}...") + download_self_play_data( + subset, battle_format, version=version, force_download=True + ) elif args.dataset == "revealed-teams": version = args.version or LATEST_PARSED_REPLAY_REVISION download_revealed_teams(version=version, force_download=True) @@ -502,10 +578,11 @@ def print_version_tree(version_dict: dict, indent: int = 0): set_names = ["competitive", "paper_variety", "paper_replays"] if version > "v0": set_names += ["modern_replays", "modern_replays_v2"] + if version > "v4": + set_names += ["gl_05_26", "hl_05_26"] for set_name in set_names: for format in formats: - if "ou" not in format and "replays" in set_name: - # only OU tiers have replay sets currently + if "ou" not in format and set_name in REPLAY_DERIVED_OU_ONLY_TEAM_SETS: continue if "gen9" in format and "paper" in set_name: # gen 9 was not supported diff --git a/metamon/data/parsed_replay_dset.py b/metamon/data/parsed_replay_dset.py index cdb784faf2..946c4d1ed9 100644 --- a/metamon/data/parsed_replay_dset.py +++ b/metamon/data/parsed_replay_dset.py @@ -39,6 +39,7 @@ download_self_play_data, SELF_PLAY_SUBSETS, SELF_PLAY_FORMATS, + get_self_play_formats, METAMON_CACHE_DIR, ) @@ -105,8 +106,16 @@ def __init__( verbose: bool = False, shuffle: bool = False, use_cached_filenames: bool = False, + split: Optional[str] = None, + test_fraction: float = 0.1, + split_seed: int = 42, ): assert os.path.exists(dset_root), f"Dataset root not found: {dset_root}" + assert split in ( + None, + "train", + "test", + ), f"split must be None, 'train', or 'test', got {split!r}" self.dset_root = dset_root self.observation_space = copy.deepcopy(observation_space) @@ -122,6 +131,9 @@ def __init__( self.verbose = verbose self.shuffle = shuffle self.use_cached_filenames = use_cached_filenames + self.split = split + self.test_fraction = test_fraction + self.split_seed = split_seed self.index_path = os.path.join(self.dset_root, "index.csv") @@ -316,6 +328,21 @@ def _parse_date(self, date_str: str) -> datetime: except ValueError: return datetime.strptime(date_str, "%m-%d-%Y-%H:%M:%S") + ########################## + ## Train / Test Split ## + ########################## + + def _apply_split(self): + """Partition filenames into train/test via deterministic seeded shuffle.""" + if self.split is None: + return + fnames = sorted(self.filenames) + random.Random(self.split_seed).shuffle(fnames) + cutoff = int(len(fnames) * (1 - self.test_fraction)) + self.filenames = fnames[:cutoff] if self.split == "train" else fnames[cutoff:] + if self.verbose: + print(f" Split '{self.split}': {len(self.filenames)} battles") + ################### ## File Indexing ## ################### @@ -343,6 +370,8 @@ def refresh_files(self): if self.verbose: print(f"Total: {len(self.filenames)} battles after filtering") + self._apply_split() + if self.shuffle: random.shuffle(self.filenames) @@ -524,6 +553,9 @@ def __init__( verbose: bool = False, shuffle: bool = False, use_cached_filenames: bool = False, + split: Optional[str] = None, + test_fraction: float = 0.1, + split_seed: int = 42, ): formats = formats or metamon.config.SUPPORTED_BATTLE_FORMATS @@ -547,6 +579,9 @@ def __init__( verbose=verbose, shuffle=shuffle, use_cached_filenames=use_cached_filenames, + split=split, + test_fraction=test_fraction, + split_seed=split_seed, ) @@ -558,8 +593,10 @@ class SelfPlayDataset(MetamonDataset): Args: subset: Which self-play subset to load: - "pac-base": 11M trajectories from PokéAgent Challenge training - - "pac-exploratory": 7M trajectories from higher-temperature sampling. - formats: Defaults to SELF_PLAY_FORMATS (gen1-4ou, gen9ou). + - "pac-exploratory": 7M trajectories from higher-temperature sampling + - "pac-tauros": ~4.9M gen1ou trajectories from Tauros self-play iterations + formats: Defaults to the formats published for this subset on HF + (all self-play OUs for pac-base/pac-exploratory; gen1ou only for pac-tauros). See MetamonDataset for remaining argument documentation. """ @@ -578,6 +615,9 @@ def __init__( verbose: bool = False, shuffle: bool = False, use_cached_filenames: bool = False, + split: Optional[str] = None, + test_fraction: float = 0.1, + split_seed: int = 42, ): if subset not in SELF_PLAY_SUBSETS: raise ValueError( @@ -585,7 +625,7 @@ def __init__( ) self.subset = subset - formats = formats or SELF_PLAY_FORMATS + formats = formats or get_self_play_formats(subset) # Download tar files (without extracting) for format_name in formats: @@ -605,10 +645,14 @@ def __init__( verbose=verbose, shuffle=shuffle, use_cached_filenames=use_cached_filenames, + split=split, + test_fraction=test_fraction, + split_seed=split_seed, ) if __name__ == "__main__": + import argparse from argparse import ArgumentParser from metamon.interface import ( DefaultShapedReward, @@ -617,25 +661,68 @@ def __init__( DefaultActionSpace, ) from metamon.tokenizer import get_tokenizer - - parser = ArgumentParser() - parser.add_argument("--dset_root", type=str, default=None) + from metamon.data.download import SELF_PLAY_SUBSETS + + parser = ArgumentParser( + description="Load and iterate parsed battle trajectories.", + epilog=""" +Examples: + # Human replays (default) + python -m metamon.data.parsed_replay_dset --formats gen1ou + + # Self-play + python -m metamon.data.parsed_replay_dset --subset pac-tauros --formats gen1ou + python -m metamon.data.parsed_replay_dset --subset pac-base --formats gen2ou --max-samples 100 +""", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument( + "--subset", + type=str, + default=None, + choices=SELF_PLAY_SUBSETS, + help=( + "Load self-play data from this HuggingFace subset " + f"({', '.join(SELF_PLAY_SUBSETS)}) instead of human parsed replays." + ), + ) + parser.add_argument( + "--dset_root", + type=str, + default=None, + help="Root directory for human replays (ignored when --subset is set).", + ) parser.add_argument("--formats", type=str, default=None, nargs="+") parser.add_argument("--obs_space", type=str, default="DefaultObservationSpace") + parser.add_argument( + "--max-samples", + type=int, + default=None, + help="Stop after this many trajectories (default: iterate the full dataset).", + ) args = parser.parse_args() - dset = ParsedReplayDataset( - dset_root=args.dset_root, - observation_space=TokenizedObservationSpace( - get_observation_space(args.obs_space), - tokenizer=get_tokenizer("DefaultObservationSpace-v1"), - ), + obs_space = TokenizedObservationSpace( + get_observation_space(args.obs_space), + tokenizer=get_tokenizer("DefaultObservationSpace-v1"), + ) + dset_kwargs = dict( + observation_space=obs_space, action_space=DefaultActionSpace(), reward_function=DefaultShapedReward(), formats=args.formats, verbose=True, shuffle=True, - use_cached_filenames=True, + use_cached_filenames=False, ) - for i in tqdm.tqdm(range(len(dset))): + + if args.subset: + dset = SelfPlayDataset(subset=args.subset, **dset_kwargs) + else: + dset = ParsedReplayDataset(dset_root=args.dset_root, **dset_kwargs) + + n_iter = len(dset) if args.max_samples is None else min(args.max_samples, len(dset)) + label = args.subset or "human parsed replays" + print(f"Loaded {len(dset):,} trajectories ({label}); iterating {n_iter:,}") + for i in tqdm.tqdm(range(n_iter)): obs, actions, rewards, dones = dset[i] diff --git a/metamon/env/__init__.py b/metamon/env/__init__.py index 7b8371104b..3ca3ae4cd2 100644 --- a/metamon/env/__init__.py +++ b/metamon/env/__init__.py @@ -4,6 +4,7 @@ PokeEnvWrapper, BattleAgainstBaseline, QueueOnLocalLadder, + ChallengeByUsername, METAMON_TEAM_SETS, PokeAgentServerConfiguration, PokeAgentLadder, diff --git a/metamon/env/__main__.py b/metamon/env/__main__.py index 1257d22b28..6ce0057bfc 100644 --- a/metamon/env/__main__.py +++ b/metamon/env/__main__.py @@ -12,7 +12,6 @@ from metamon.tokenizer import get_tokenizer from metamon.env.wrappers import get_metamon_teams, BattleAgainstBaseline - if __name__ == "__main__": parser = ArgumentParser() @@ -54,7 +53,6 @@ obs, reward, terminated, truncated, info = env.step( env.action_space.sample() ) - legal_actions = info["legal_actions"] return_ += reward done = terminated or truncated timesteps += 1 diff --git a/metamon/env/metamon_battle.py b/metamon/env/metamon_battle.py index b10ce22949..9c6e107dbe 100644 --- a/metamon/env/metamon_battle.py +++ b/metamon/env/metamon_battle.py @@ -264,9 +264,9 @@ def _update_turn_from_active_request( active_move.get("pp", known_active_moves[move_id].pp) ) move = known_active_moves[move_id] - elif move_id in {"recharge", "struggle"}: + elif move_id in {"recharge", "struggle", "fight"}: # these are handled as special cases in the action space, replay parser, - # and here. The agent does not see recharge or struggle as its moves. + # and here. The agent does not see recharge, struggle, or fight as its moves. move = Move(move_name, gen=self._gen) move.set_pp(active_move.get("pp", move.pp)) override_active_moves = False diff --git a/metamon/env/metamon_player.py b/metamon/env/metamon_player.py index cda92144ed..8a9db08178 100644 --- a/metamon/env/metamon_player.py +++ b/metamon/env/metamon_player.py @@ -6,6 +6,7 @@ from poke_env.environment import AbstractBattle from poke_env.exceptions import ShowdownException +from metamon.config import format_for_agent from metamon.env.metamon_battle import MetamonBackendBattle, PokeAgentBackendBattle from metamon.backend.showdown_dex import Dex from metamon.backend.replay_parser.str_parsing import pokemon_name, move_name @@ -208,11 +209,12 @@ def teampreview(self, battle: AbstractBattle) -> str: # fallback to random if no model provided return self.random_teampreview(battle) - # fallback to random for non-team-preview (or untrained teampreview) formats - battle_format = self._format.replace("-", "").lower() # e.g., "gen9ou" - if battle_format not in self.team_preview_model.trained_formats: + # Map Showdown variants (e.g. gen9oulongtimer) to the format the preview model knows. + agent_format = format_for_agent(self._format.replace("-", "").lower()) + if agent_format not in self.team_preview_model.trained_formats: self.logger.warning( - f"Battle format {battle_format} not in trained formats {self.team_preview_model.trained_formats}. " + f"Battle format {self._format} (agent: {agent_format}) not in trained formats " + f"{self.team_preview_model.trained_formats}. " f"Falling back to random." ) return self.random_teampreview(battle) @@ -245,7 +247,7 @@ def teampreview(self, battle: AbstractBattle) -> str: our_team_abilities=our_team_abilities, our_team_items=our_team_items, opponent_team=opponent_team_names, - battle_format=battle_format, + battle_format=agent_format, ) # format team preview prediction output to showdown command diff --git a/metamon/env/wrappers.py b/metamon/env/wrappers.py index 8c3c27a38b..ebff2f594c 100644 --- a/metamon/env/wrappers.py +++ b/metamon/env/wrappers.py @@ -30,8 +30,11 @@ ) from metamon.data import DATA_PATH from metamon.data.download import download_teams -from metamon.env.metamon_player import MetamonPlayer, PokeAgentPlayer - +from metamon.env.metamon_player import MetamonPlayer +from metamon.backend.team_prediction.team_index import ( + load_team_files, + resolve_format_dir, +) METAMON_TEAM_SETS = { "competitive", # human-made sample teams, usually from the Smogon forums @@ -39,6 +42,8 @@ "modern_replays_v2", # an updated/expanded version of modern_replays (September 2025) that was verified against the PokéAgent Challenge server's frozen teambuilder rules "paper_replays", # predicted teams from replays. backwards compatible with a team set mentioned in the paper "paper_variety", # procedurally generated teams with unrealistic OOD lead-off Pokémon. Movesets were generated by sampling from all-time usage stats. + "gl_05_26", # general ladder May 2026 — last-year revealed teams, NaiveUsagePredictor fill + "hl_05_26", # high ladder May 2026 — 1400+ (1600+ gen9) ∪ smogtours } @@ -61,17 +66,16 @@ def __init__(self, team_file_dir: str, battle_format: str): super().__init__() self.team_file_dir = team_file_dir self.battle_format = format_for_agent(battle_format) - self.team_files = self._find_team_files() + self.format_dir = str(resolve_format_dir(team_file_dir, self.battle_format)) + self.team_files, loaded_from_index = load_team_files( + team_file_dir, self.battle_format + ) + if loaded_from_index: + print(f"loaded {len(self.team_files):,} teams from TeamSet index") + else: + print(f"Found {len(self.team_files):,} teams in TeamSet") self._most_recent_team_file = None - def _find_team_files(self) -> List[str]: - team_files = [] - for root, _, files in os.walk(self.team_file_dir): - for file in files: - if file.endswith(f".{self.battle_format}_team"): - team_files.append(os.path.join(root, file)) - return team_files - def block_team(self, packed_team: str) -> bool: """ Reject True to reject the team and resample. @@ -131,7 +135,7 @@ def get_metamon_teams( Args: battle_format: The battle format of the team files (e.g. "gen1ou", "gen2ubers", etc.). - Showdown variants (e.g. "gen1oulongtimer") are normalized automatically. + Showdown variants (e.g. PokéAgent's "gen1oulongtimer") are normalized automatically. set_name: The name of the set of teams to download. See the README for options. If a custom name is provided, we will search the `METAMON_CACHE_DIR` for a custom team set with that name. set_type: The type of TeamSet to return. Defaults to TeamSet. @@ -204,9 +208,9 @@ class PokeEnvWrapper(OpenAIGymEnv): enforces a limit of 1000 regardless of this setting. save_trajectories_to: The directory to save the trajectories to. Data is saved in the same format as the parsed replay dataset. - save_team_results_to: Directory to write some basic stats about team choice and battle outcome - for use in tuning team selection. Data is saved in a CSV file with columns: - Player Username, Team File, Opponent Username, Result, Turn Count, Battle ID. + save_results_to: Directory to write per-battle result logs. Data is saved in + a CSV file with columns: Player Username, Team File, Opponent Username, + Result, Turn Count, Battle ID. battle_backend: The Showdown state parsing backend. Options are 'poke-env' or 'metamon'. team_preview_model: Optional TeamPreviewModel to use for predicting leads during @@ -234,7 +238,7 @@ def __init__( start_timer_on_battle_start: bool = False, turn_limit: int = 1000, save_trajectories_to: Optional[str] = None, - save_team_results_to: Optional[str] = None, + save_results_to: Optional[str] = None, battle_backend: str = "metamon", team_preview_model=None, ): @@ -259,19 +263,19 @@ def __init__( else: self.save_trajectories_to = None - if save_team_results_to is not None: - os.makedirs(save_team_results_to, exist_ok=True) - self.save_team_results_to = os.path.join( - save_team_results_to, + if save_results_to is not None: + os.makedirs(save_results_to, exist_ok=True) + self.save_results_to = os.path.join( + save_results_to, f"battle_log_{self.player_username}_{battle_format}.csv", ) - if not os.path.exists(self.save_team_results_to): - with open(self.save_team_results_to, "a") as f: + if not os.path.exists(self.save_results_to): + with open(self.save_results_to, "a") as f: f.write( "Player Username, Team File, Opponent Username, Result, Turn Count, Battle ID\n" ) else: - self.save_team_results_to = None + self.save_results_to = None if opponent_type is not None: self.metamon_opponent_name = opponent_type.__name__ @@ -415,7 +419,7 @@ def step(self, action): if ( self.save_trajectories_to is not None - or self.save_team_results_to is not None + or self.save_results_to is not None ): # build a long filename that matches the format of the parsed replay dataset result = "WIN" if info["won"] == 1 else "LOSS" @@ -441,8 +445,8 @@ def step(self, action): f.write(json.dumps(output_json).encode("utf-8")) os.rename(temp_path, path) - if self.save_team_results_to is not None: - with open(self.save_team_results_to, "a") as f: + if self.save_results_to is not None: + with open(self.save_results_to, "a") as f: f.write( f"{self.player_username},{self.metamon_team_set.most_recent_team_file},{opponent_name},{result},{self.turn_counter},{battle_id}\n" ) @@ -477,7 +481,7 @@ def __init__( opponent_type: Type[Player], turn_limit: int = 200, save_trajectories_to: Optional[str] = None, - save_team_results_to: Optional[str] = None, + save_results_to: Optional[str] = None, battle_backend: str = "poke-env", team_preview_model=None, ): @@ -492,7 +496,7 @@ def __init__( team_preview_model=team_preview_model, turn_limit=turn_limit, save_trajectories_to=save_trajectories_to, - save_team_results_to=save_team_results_to, + save_results_to=save_results_to, battle_backend=battle_backend, ) @@ -523,7 +527,7 @@ def __init__( player_avatar: Optional[str] = None, start_timer_on_battle_start: bool = True, save_trajectories_to: Optional[str] = None, - save_team_results_to: Optional[str] = None, + save_results_to: Optional[str] = None, player_password: Optional[str] = None, battle_backend: str = "poke-env", print_battle_bar: bool = True, @@ -543,7 +547,7 @@ def __init__( start_challenging=False, turn_limit=float("inf"), save_trajectories_to=save_trajectories_to, - save_team_results_to=save_team_results_to, + save_results_to=save_results_to, battle_backend=battle_backend, team_preview_model=team_preview_model, ) @@ -565,6 +569,122 @@ def step(self, action): return next_state, reward, terminated, truncated, info +class ChallengeByUsername(PokeEnvWrapper): + """ + Battle against a specific opponent by sending/accepting challenges by username. + + Unlike QueueOnLocalLadder (random matchmaking via the ladder), this creates + deterministic head-to-head matchups between two named players on the same server. + Multiple such matchups can run simultaneously on the same server. + + One side must be the "challenger" (sends challenges) and the other the "acceptor" + (accepts challenges). Both use the standard gymnasium env API for stepping through + battles. + + Args: + battle_format: The battle format (e.g. "gen1ou"). + num_battles: Number of battles to play. + observation_space: The observation space. + action_space: The action space. + reward_function: The reward function. + player_team_set: The team set for this player. + player_username: This player's username. + opponent_username: The opponent's username to challenge/accept from. + role: "challenger" (sends challenges) or "acceptor" (accepts challenges). + player_avatar: Optional avatar name. + start_timer_on_battle_start: Whether to start the battle timer. + save_trajectories_to: Directory to save trajectories. + save_results_to: Directory to save per-battle result logs. + battle_backend: Showdown state parsing backend. + print_battle_bar: Whether to print battle status. + team_preview_model: Optional team preview model. + """ + + _INIT_RETRIES = 1000 + + def __init__( + self, + battle_format: str, + num_battles: int, + observation_space: ObservationSpace, + action_space: ActionSpace, + reward_function: RewardFunction, + player_team_set: TeamSet, + player_username: str, + opponent_username: str, + role: str = "challenger", + player_avatar: Optional[str] = None, + start_timer_on_battle_start: bool = True, + save_trajectories_to: Optional[str] = None, + save_results_to: Optional[str] = None, + battle_backend: str = "metamon", + print_battle_bar: bool = True, + team_preview_model=None, + ): + assert role in ( + "challenger", + "acceptor", + ), f"role must be 'challenger' or 'acceptor', got '{role}'" + self._opponent_username = opponent_username + self._role = role + + super().__init__( + battle_format=battle_format, + observation_space=observation_space, + action_space=action_space, + reward_function=reward_function, + player_team_set=player_team_set, + player_username=player_username, + player_avatar=player_avatar, + start_timer_on_battle_start=start_timer_on_battle_start, + opponent_type=None, + start_challenging=False, + turn_limit=float("inf"), + save_trajectories_to=save_trajectories_to, + save_results_to=save_results_to, + battle_backend=battle_backend, + team_preview_model=team_preview_model, + ) + print( + f"Challenge mode ({role}): {player_username} vs {opponent_username} " + f"for {num_battles} battles" + ) + self.print_battle_bar = print_battle_bar + self.player_username = player_username + self.num_battles = num_battles + + import asyncio + from poke_env.concurrency import POKE_LOOP + + if role == "challenger": + self.start_challenging(n_challenges=num_battles) + else: + self._challenge_task = asyncio.run_coroutine_threadsafe( + self._accept_challenge_loop(num_battles), POKE_LOOP + ) + + def get_opponent(self): + """Return opponent username so _challenge_loop sends challenges to them.""" + return self._opponent_username + + async def _accept_challenge_loop(self, n_challenges: int): + """Accept loop that mirrors poke-env's _challenge_loop exactly. + + Accepts one challenge at a time and fully awaits the battle before + accepting the next. This keeps the gym-env observation/action queues + in the same cadence as the challenger's send_challenges(opponent, 1) + path, ensuring terminated/truncated signals propagate correctly. + """ + for _ in range(n_challenges): + await self.agent.accept_challenges(self._opponent_username, 1, None) + + def step(self, action): + next_state, reward, terminated, truncated, info = super().step(action) + if self.print_battle_bar: + self.render() + return next_state, reward, terminated, truncated, info + + PokeAgentServerConfiguration = ServerConfiguration( "wss://battling.pokeagentchallenge.com/showdown/websocket", "https://battling.pokeagentchallenge.com/action.php?", @@ -573,25 +693,10 @@ def step(self, action): class PokeAgentLadder(QueueOnLocalLadder): """ - Battle against the PokéAgent Challenge Ladder (http://pokeagentshowdown.com.insecure.psim.us) - - Note that you must register a real Showdown account by going to the website above and clicking - the Gear icon in the top right corner. `player_username` and `player_password` are required. - - Note: - Be careful about auto-resets and how you handle terminating the eval loop after `num_battles`. - Unlike a regular RL eval, creating an episode that you won't finish is a big problem because - it will lead to a loss and a drop in rating. Test with `QueueOnLocalLadder` first! + Battle against the PokéAgent Challenge Ladder (https://battling.pokeagentchallenge.com) """ # increases time to launch opponent envs before ladder loop times out ("Agent is not challenging"). - # may need to be especially long for PokéAgent because it takes some time for your Elo search radius - # to expand to active players... depending on who is online and what your Elo is. - _INIT_RETRIES = 3000 - - # increases time to launch opponent envs before ladder loop times out ("Agent is not challenging"). - # may need to be especially long for PokéAgent because it takes some time for your Elo search radius - # to expand to active players... depending on who is online and what your Elo is. _INIT_RETRIES = 3000 @property diff --git a/metamon/il/model.py b/metamon/il/model.py index 303f9a0f5b..7454c0f04a 100644 --- a/metamon/il/model.py +++ b/metamon/il/model.py @@ -93,7 +93,7 @@ def forward( ) -> torch.Tensor: text_emb = F.leaky_relu(self.dropout(self.text_emb(text_emb))) num_emb = F.leaky_relu(self.dropout(self.num_emb(numerical_features))) - num_emb = rearrange(num_emb, "b l (l2 d) -> b l l2 d", l2=self.numerical_tokens) + num_emb = num_emb.unflatten(-1, (self.numerical_tokens, -1)) seq = torch.cat((text_emb, num_emb), dim=-2) return seq @@ -206,13 +206,15 @@ def __init__( ) self.output_dim = latent_tokens * d_model - def forward(self, x: torch.Tensor) -> torch.Tensor: + def forward(self, x: torch.Tensor, flatten: bool = True) -> torch.Tensor: B, L, D = x.shape latents = self.latents.unsqueeze(0).expand(B, -1, -1) for cross, self_attn in zip(self.cross_blocks, self.self_blocks): latents = cross(latents, x) latents = self_attn(latents) - return rearrange(latents, "b n d -> b 1 (n d)") + if flatten: + return rearrange(latents, "b n d -> b 1 (n d)") + return latents # (B, latent_tokens, d_model) class TimestepTransformer(nn.Module): diff --git a/metamon/interface.py b/metamon/interface.py index 2f66f23d54..93a13ef49e 100644 --- a/metamon/interface.py +++ b/metamon/interface.py @@ -38,7 +38,6 @@ move_name, ) - ALL_OBSERVATION_SPACES = {} ALL_ACTION_SPACES = {} ALL_REWARD_FUNCTIONS = {} @@ -675,7 +674,7 @@ def from_ReplayAction( elif action.is_noop: assert action.name == "Recharge" action_idx = 0 - elif action.name == "Struggle": + elif action.name in {"Struggle", "Fight"}: action_idx = 0 elif action.is_switch or action.is_revival: for switch_idx, available_switch in enumerate( @@ -732,6 +731,12 @@ def action_idx_to_BattleOrder( # note that the replay version sets every Struggle in the dataset to index 0, so this # is giving a little room for error. move_options = [battle.available_moves[0]] * 4 + elif "fight" in valid_moves: + # new in ~march 2026: a "fight" button in gen1, which tells you your only + # options are to "fight" or potentially switch. Similar to struggle, the agent + # will see its regular 4 moves and switches (if applicable) but all of the moves + # will map to clicking "fight". + move_options = [battle.available_moves[0]] * 4 else: # standard: pick from the active pokemon's moves move_options = consistent_move_order( @@ -1322,6 +1327,224 @@ def _get_opponent_pokemon_string_features( return base + moves +@register_observation_space() +class GroupedObservationSpace(ObservationSpace): + """ + Groups observations by entity for use with a shared Pokemon encoder. + + Unlike DefaultObservationSpace which concatenates all features into single + "text" and "numbers" arrays, this space outputs separate arrays for each + Pokemon and a misc array for global state. + """ + + POKEMON_TEXT_LEN = 12 # name, item, ability, tera, types×2, effect, status, moves×4 + POKEMON_NUM_LEN = 31 # hp, lvl, stats×6, boosts×7, (bp, acc, pri, pp)×4 + MISC_TEXT_LEN = ( + 20 # format, switch, weather, field, conds×2, prev×2, revealed×6, preview×6 + ) + MISC_NUM_LEN = 4 # opp_remaining, sleep, freeze, can_tera + NUM_SWITCHES = 5 + + def reset(self): + self.any_opponent_asleep = False + self.any_opponent_frozen = False + self.revealed_opponents = set() + + @property + def tokenizable(self) -> dict[str, int]: + return { + "text_active_pokemon": self.POKEMON_TEXT_LEN, + "text_switch_0": self.POKEMON_TEXT_LEN, + "text_switch_1": self.POKEMON_TEXT_LEN, + "text_switch_2": self.POKEMON_TEXT_LEN, + "text_switch_3": self.POKEMON_TEXT_LEN, + "text_switch_4": self.POKEMON_TEXT_LEN, + "text_opponent_active_pokemon": self.POKEMON_TEXT_LEN, + "text_misc": self.MISC_TEXT_LEN, + } + + @property + def gym_space(self) -> gym.spaces.Dict: + spaces = {} + for key in self.tokenizable: + spaces[key] = gym.spaces.Text(max_length=500, min_length=0) + spaces["numbers_active_pokemon"] = gym.spaces.Box( + low=-10.0, high=10.0, shape=(self.POKEMON_NUM_LEN,), dtype=np.float32 + ) + for i in range(self.NUM_SWITCHES): + spaces[f"numbers_switch_{i}"] = gym.spaces.Box( + low=-10.0, high=10.0, shape=(self.POKEMON_NUM_LEN,), dtype=np.float32 + ) + spaces["numbers_opponent_active_pokemon"] = gym.spaces.Box( + low=-10.0, high=10.0, shape=(self.POKEMON_NUM_LEN,), dtype=np.float32 + ) + spaces["numbers_misc"] = gym.spaces.Box( + low=-10.0, high=10.0, shape=(self.MISC_NUM_LEN,), dtype=np.float32 + ) + return gym.spaces.Dict(spaces) + + def _get_universal_pokemon_text( + self, pokemon: UniversalPokemon, is_active: bool = True + ) -> list[str]: + out = [ + pokemon.name, + pokemon.item, + pokemon.ability, + pokemon.tera_type, + ] + type_parts = pokemon.types.split() + out.extend(type_parts[:2] + ["notype"] * (2 - len(type_parts))) + out.append(pokemon.effect) + out.append(pokemon.status) + # (sorted order, padded to 4) + for move in self._pad_moves(pokemon.moves): + out.append(clean_name(move.name) if move else "") + + return out + + def _get_universal_pokemon_numbers( + self, pokemon: UniversalPokemon, is_active: bool = True + ) -> list[float]: + out = [pokemon.hp_pct, pokemon.lvl / 100.0] + for stat in ("atk", "spa", "def", "spd", "spe", "hp"): + out.append(getattr(pokemon, f"base_{stat}") / 255.0) + if is_active: + for boost in ("atk", "spa", "def", "spd", "spe", "accuracy", "evasion"): + out.append(getattr(pokemon, f"{boost}_boost") / 6.0) + else: + out.extend([0.0] * 7) + # (sorted order, padded to 4) + for move in self._pad_moves(pokemon.moves): + if move: + pp_ratio = move.current_pp / move.max_pp if move.max_pp > 0 else 0.0 + pp_warning = (pp_ratio >= 0.5) + (pp_ratio >= 0.25) + (pp_ratio > 0) + out.extend( + [ + move.base_power / 200.0, + move.accuracy, + move.priority / 5.0, + float(pp_warning), + ] + ) + else: + out.extend([-2.0] * 4) + + return out + + def _pad_moves( + self, moves: list[UniversalMove], n: int = 4 + ) -> list[Optional[UniversalMove]]: + sorted_moves = consistent_move_order(moves)[:n] + return sorted_moves + [None] * (n - len(sorted_moves)) + + def _get_blank_pokemon_text(self) -> list[str]: + # padding for empty switch slots.""" + return [""] * self.POKEMON_TEXT_LEN + + def _get_blank_pokemon_numbers(self) -> list[float]: + # padding for empty switch slots.""" + return [-2.0] * self.POKEMON_NUM_LEN + + def _get_misc_text(self, state: UniversalState) -> list[str]: + # global text features: format, conditions, prev moves, opponent team + # note: "nofield" isn't in the tokenizer, so we map it to "" + battle_field = ( + state.battle_field if state.battle_field != "nofield" else "" + ) + out = [ + f"<{state.agent_format}>", + "" if state.forced_switch else "", + state.weather, + battle_field, + state.player_conditions, + state.opponent_conditions, + clean_name(state.player_prev_move.name), + clean_name(state.opponent_prev_move.name), + ] + revealed = sorted(self.revealed_opponents)[:6] + out.extend(revealed + [""] * (6 - len(revealed))) + teampreview = sorted(state.opponent_teampreview)[:6] + out.extend(teampreview + [""] * (6 - len(teampreview))) + return out + + def _get_misc_numbers(self, state: UniversalState) -> list[float]: + # global numeric features + return [ + state.opponents_remaining / 6.0, + float(self.any_opponent_asleep), + float(self.any_opponent_frozen), + float(state.can_tera), + ] + + def state_to_obs(self, state: UniversalState) -> dict[str, np.ndarray]: + obs = {} + + # update history-dependent state tracking + opponent = state.opponent_active_pokemon + self.any_opponent_asleep |= opponent.status == "slp" + self.any_opponent_frozen |= opponent.status == "frz" + self.revealed_opponents.add(opponent.base_species) + + # player active + obs["text_active_pokemon"] = self._get_universal_pokemon_text( + state.player_active_pokemon, is_active=True + ) + obs["numbers_active_pokemon"] = self._get_universal_pokemon_numbers( + state.player_active_pokemon, is_active=True + ) + + # reserve team (sorted order, padded to NUM_SWITCHES) + switches = consistent_pokemon_order(state.available_switches) + for i in range(self.NUM_SWITCHES): + if i < len(switches): + obs[f"text_switch_{i}"] = self._get_universal_pokemon_text( + switches[i], is_active=False + ) + obs[f"numbers_switch_{i}"] = self._get_universal_pokemon_numbers( + switches[i], is_active=False + ) + else: + obs[f"text_switch_{i}"] = self._get_blank_pokemon_text() + obs[f"numbers_switch_{i}"] = self._get_blank_pokemon_numbers() + + # opponent active + obs["text_opponent_active_pokemon"] = self._get_universal_pokemon_text( + state.opponent_active_pokemon, is_active=True + ) + obs["numbers_opponent_active_pokemon"] = self._get_universal_pokemon_numbers( + state.opponent_active_pokemon, is_active=True + ) + + # misc (global state) + obs["text_misc"] = self._get_misc_text(state) + obs["numbers_misc"] = self._get_misc_numbers(state) + + # temporary assert checks to verify lengths + for key in obs: + if key.startswith("text"): + expected = ( + self.MISC_TEXT_LEN if key == "text_misc" else self.POKEMON_TEXT_LEN + ) + assert ( + len(obs[key]) == expected + ), f"{key}: expected {expected}, got {len(obs[key])}" + else: + expected = ( + self.MISC_NUM_LEN if key == "numbers_misc" else self.POKEMON_NUM_LEN + ) + assert ( + len(obs[key]) == expected + ), f"{key}: expected {expected}, got {len(obs[key])}" + + for key in obs: + if key.startswith("text"): + obs[key] = np.array(" ".join(obs[key]), dtype=np.str_) + else: + obs[key] = np.array(obs[key], dtype=np.float32) + + return obs + + @register_observation_space() class PatchPokeAgentTeraBug(ObservationSpace): """ diff --git a/metamon/rl/__init__.py b/metamon/rl/__init__.py index e95e0b7749..18feb693e6 100644 --- a/metamon/rl/__init__.py +++ b/metamon/rl/__init__.py @@ -13,3 +13,4 @@ MODEL_CONFIG_DIR = os.path.join(os.path.dirname(__file__), "configs", "models") TRAINING_CONFIG_DIR = os.path.join(os.path.dirname(__file__), "configs", "training") +DATASET_CONFIG_DIR = os.path.join(os.path.dirname(__file__), "configs", "datasets") diff --git a/metamon/rl/configs/__init__.py b/metamon/rl/configs/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/metamon/rl/configs/datasets/__init__.py b/metamon/rl/configs/datasets/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/metamon/rl/configs/datasets/self_play_dset.yaml b/metamon/rl/configs/datasets/self_play_dset.yaml new file mode 100644 index 0000000000..9978c3e435 --- /dev/null +++ b/metamon/rl/configs/datasets/self_play_dset.yaml @@ -0,0 +1,8 @@ +# Baseline training mix: parsed human replays + public self-play (pac-base / pac-exploratory). +# pac-tauros (~4.9M gen1ou) is also available as a self_play subset when referenced explicitly. +# Omit `formats` to include all metamon battle formats (gen1–4 OU/UU/NU/Ubers + gen9ou). +# Self-play subsets only cover gen1–4ou and gen9ou; other formats use human replays only. +replay_weight: 0.05 +self_play: + pac-base: 0.6 + pac-exploratory: 0.35 diff --git a/metamon/rl/configs/models/__init__.py b/metamon/rl/configs/models/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/metamon/rl/configs/models/alakazam.gin b/metamon/rl/configs/models/alakazam.gin deleted file mode 100644 index c1d1c79776..0000000000 --- a/metamon/rl/configs/models/alakazam.gin +++ /dev/null @@ -1,57 +0,0 @@ -import amago.nets.actor_critic -import amago.nets.traj_encoders -import amago.nets.transformer -import amago.agent -import amago.experiment - -MetamonAMAGOExperiment.agent_type = @agent.MultiTaskAgent -MetamonAMAGOExperiment.tstep_encoder_type = @MetamonTstepEncoder -MetamonAMAGOExperiment.traj_encoder_type = @traj_encoders.TformerTrajEncoder -MetamonAMAGOExperiment.max_seq_len = 200 - -# actor -MultiTaskAgent.actor_type = @MetamonMaskedResidualActor -MultiTaskAgent.pass_obs_keys_to_actor = ["illegal_actions"] -MetamonMaskedActor.activation = "leaky_relu" -MetamonMaskedActor.n_layers = 2 -MetamonMaskedActor.d_hidden = 400 - -# critic -MultiTaskAgent.critic_type = @actor_critic.NCriticsTwoHot -actor_critic.NCriticsTwoHot.activation = "leaky_relu" -actor_critic.NCriticsTwoHot.n_layers = 2 -actor_critic.NCriticsTwoHot.d_hidden = 512 -MultiTaskAgent.popart = True -MultiTaskAgent.num_critics = 6 -actor_critic.NCriticsTwoHot.output_bins = 96 -actor_critic.NCriticsTwoHot.min_return = -100 -actor_critic.NCriticsTwoHot.max_return = 2100 -actor_critic.NCriticsTwoHot.use_symlog = False - - -# local metamon architectures -MetamonTstepEncoder.extra_emb_dim = 18 -MetamonTstepEncoder.d_model = 108 -MetamonTstepEncoder.n_layers = 4 -MetamonTstepEncoder.n_heads = 6 -MetamonTstepEncoder.scratch_tokens = 6 -MetamonTstepEncoder.numerical_tokens = 6 -MetamonTstepEncoder.token_mask_aug = False -MetamonTstepEncoder.dropout = .05 - - - -# amago transformer -traj_encoders.TformerTrajEncoder.n_layers = 6 -traj_encoders.TformerTrajEncoder.n_heads = 12 -traj_encoders.TformerTrajEncoder.d_ff = 3072 -traj_encoders.TformerTrajEncoder.d_model = 768 -traj_encoders.TformerTrajEncoder.normformer_norms = True -traj_encoders.TformerTrajEncoder.sigma_reparam = False -traj_encoders.TformerTrajEncoder.norm = "layer" -traj_encoders.TformerTrajEncoder.head_scaling = True -traj_encoders.TformerTrajEncoder.activation = "leaky_relu" -traj_encoders.TformerTrajEncoder.attention_type = @transformer.ClippedSlidingSinkAttention -transformer.ClippedSlidingSinkAttention.window_size = 96 -transformer.ClippedSlidingSinkAttention.logit_clip = 50 -transformer.ClippedSlidingSinkAttention.sink_size = 5 \ No newline at end of file diff --git a/metamon/rl/configs/models/grouped_v2_50m.gin b/metamon/rl/configs/models/grouped_v2_50m.gin new file mode 100644 index 0000000000..78afbe6b51 --- /dev/null +++ b/metamon/rl/configs/models/grouped_v2_50m.gin @@ -0,0 +1,76 @@ +import amago.nets.actor_critic +import amago.nets.traj_encoders +import amago.nets.transformer +import amago.agent +import amago.experiment + +agent.Multigammas.discrete = [.97] + +MetamonAMAGOExperiment.agent_type = @agent.MultiTaskAgent +MetamonAMAGOExperiment.tstep_encoder_type = @MetamonGroupedTstepEncoderV2 +MetamonAMAGOExperiment.traj_encoder_type = @traj_encoders.TformerTrajEncoder +MetamonAMAGOExperiment.max_seq_len = 128 + +# actor +MultiTaskAgent.actor_type = @MetamonMaskedActor +MultiTaskAgent.pass_obs_keys_to_actor = ["illegal_actions"] +MetamonMaskedActor.activation = "leaky_relu" +MetamonMaskedActor.d_hidden = 768 +MetamonMaskedActor.n_layers = 2 + +# critic +MultiTaskAgent.critic_type = @actor_critic.NCriticsTwoHot +actor_critic.NCriticsTwoHot.activation = "leaky_relu" +actor_critic.NCriticsTwoHot.n_layers = 2 +actor_critic.NCriticsTwoHot.d_hidden = 768 +MultiTaskAgent.popart = True +MultiTaskAgent.num_critics = 3 +actor_critic.NCriticsTwoHot.output_bins = 128 +actor_critic.NCriticsTwoHot.min_return = -100 +actor_critic.NCriticsTwoHot.max_return = 2100 +actor_critic.NCriticsTwoHot.use_symlog = False + + +# MetamonGroupedTstepEncoderV2: Pokemon encoder (shared for 7 entities) +MetamonGroupedTstepEncoderV2.d_pokemon = 304 +MetamonGroupedTstepEncoderV2.n_heads_pokemon = 8 +MetamonGroupedTstepEncoderV2.n_layers_pokemon = 4 +MetamonGroupedTstepEncoderV2.latent_tokens_pokemon = 4 +MetamonGroupedTstepEncoderV2.numerical_tokens_pokemon = 6 +MetamonGroupedTstepEncoderV2.pokemon_out_norm = "layer" + +# MetamonGroupedTstepEncoderV2: Global encoder (misc/aggregate features) +MetamonGroupedTstepEncoderV2.d_global = 208 +MetamonGroupedTstepEncoderV2.n_heads_global = 8 +MetamonGroupedTstepEncoderV2.n_layers_global = 2 +MetamonGroupedTstepEncoderV2.latent_tokens_global = 4 +MetamonGroupedTstepEncoderV2.numerical_tokens_global = 4 +MetamonGroupedTstepEncoderV2.global_out_norm = "layer" + +# MetamonGroupedTstepEncoderV2: Fusion encoder (combines 8 entity embeddings) +MetamonGroupedTstepEncoderV2.d_fusion = 448 +MetamonGroupedTstepEncoderV2.n_heads_fusion = 8 +MetamonGroupedTstepEncoderV2.n_layers_fusion = 3 +MetamonGroupedTstepEncoderV2.latent_tokens_fusion = 3 +MetamonGroupedTstepEncoderV2.fusion_out_norm = "layer" + +# MetamonGroupedTstepEncoderV2: General +MetamonGroupedTstepEncoderV2.extra_emb_dim = 24 +MetamonGroupedTstepEncoderV2.dropout = 0.05 +MetamonGroupedTstepEncoderV2.use_flex_attention = False +MetamonGroupedTstepEncoderV2.normformer_norms = True +MetamonGroupedTstepEncoderV2.ff_mult = 3 +MetamonGroupedTstepEncoderV2.pokemon_role_emb = True + + +traj_encoders.TformerTrajEncoder.n_layers = 4 +traj_encoders.TformerTrajEncoder.n_heads = 8 +traj_encoders.TformerTrajEncoder.d_ff = 2816 +traj_encoders.TformerTrajEncoder.d_model = 704 +traj_encoders.TformerTrajEncoder.normformer_norms = True +traj_encoders.TformerTrajEncoder.sigma_reparam = True +traj_encoders.TformerTrajEncoder.norm = "layer" +traj_encoders.TformerTrajEncoder.head_scaling = True +traj_encoders.TformerTrajEncoder.activation = "leaky_relu" +traj_encoders.TformerTrajEncoder.attention_type = @transformer.FlashAttention +transformer.FlashAttention.window_size = (32, 0) diff --git a/metamon/rl/configs/models/small_rnn.gin b/metamon/rl/configs/models/small_rnn.gin deleted file mode 100644 index de028429ca..0000000000 --- a/metamon/rl/configs/models/small_rnn.gin +++ /dev/null @@ -1,40 +0,0 @@ -import amago.nets.actor_critic -import amago.nets.traj_encoders -import amago.agent -import amago.experiment - -MetamonAMAGOExperiment.agent_type = @agent.Agent -MetamonAMAGOExperiment.tstep_encoder_type = @MetamonTstepEncoder -MetamonAMAGOExperiment.traj_encoder_type = @traj_encoders.GRUTrajEncoder -MetamonAMAGOExperiment.max_seq_len = 64 - -# actor -Agent.actor_type = @MetamonMaskedActor -Agent.pass_obs_keys_to_actor = ["illegal_actions"] -MetamonMaskedActor.activation = "leaky_relu" -MetamonMaskedActor.n_layers = 2 -MetamonMaskedActor.d_hidden = 256 - -# critic -Agent.critic_type = @actor_critic.NCritics -actor_critic.NCritics.activation = "leaky_relu" -actor_critic.NCritics.n_layers = 2 -actor_critic.NCritics.d_hidden = 256 -Agent.popart = True -Agent.num_critics = 4 - -# local metamon architectures (slightly downsized from small transformer) -MetamonTstepEncoder.extra_emb_dim = 12 -MetamonTstepEncoder.d_model = 64 -MetamonTstepEncoder.n_layers = 2 -MetamonTstepEncoder.n_heads = 4 -MetamonTstepEncoder.scratch_tokens = 3 -MetamonTstepEncoder.numerical_tokens = 3 -MetamonTstepEncoder.token_mask_aug = False -MetamonTstepEncoder.dropout = .05 - -# rnn -traj_encoders.GRUTrajEncoder.n_layers = 2 -traj_encoders.GRUTrajEncoder.d_hidden = 400 -traj_encoders.GRUTrajEncoder.d_output = 300 -traj_encoders.GRUTrajEncoder.norm = "layer" \ No newline at end of file diff --git a/metamon/rl/configs/models/smaller_multitaskagent.gin b/metamon/rl/configs/models/smaller_multitaskagent.gin new file mode 100644 index 0000000000..3033e4196e --- /dev/null +++ b/metamon/rl/configs/models/smaller_multitaskagent.gin @@ -0,0 +1,56 @@ +import amago.nets.actor_critic +import amago.nets.traj_encoders +import amago.nets.transformer +import amago.agent +import amago.experiment + +MetamonAMAGOExperiment.agent_type = @agent.MultiTaskAgent +MetamonAMAGOExperiment.tstep_encoder_type = @MetamonPerceiverTstepEncoder +MetamonAMAGOExperiment.traj_encoder_type = @traj_encoders.TformerTrajEncoder +MetamonAMAGOExperiment.max_seq_len = 128 + +# actor +MultiTaskAgent.actor_type = @MetamonMaskedResidualActor +MultiTaskAgent.pass_obs_keys_to_actor = ["illegal_actions"] +MetamonMaskedResidualActor.activation = "leaky_relu" +MetamonMaskedResidualActor.feature_dim = 300 +MetamonMaskedResidualActor.residual_ff_dim = 400 +MetamonMaskedResidualActor.residual_blocks = 2 + +# critic +MultiTaskAgent.critic_type = @actor_critic.NCriticsTwoHot +actor_critic.NCriticsTwoHot.activation = "leaky_relu" +actor_critic.NCriticsTwoHot.n_layers = 2 +actor_critic.NCriticsTwoHot.d_hidden = 512 +MultiTaskAgent.popart = True +MultiTaskAgent.num_critics = 4 +actor_critic.NCriticsTwoHot.output_bins = 64 +actor_critic.NCriticsTwoHot.min_return = -100 +actor_critic.NCriticsTwoHot.max_return = 2100 +actor_critic.NCriticsTwoHot.use_symlog = False + + +# Perceiver variant (optional: switch tstep_encoder_type below) +MetamonPerceiverTstepEncoder.extra_emb_dim = 18 +MetamonPerceiverTstepEncoder.d_model = 84 +MetamonPerceiverTstepEncoder.n_layers = 4 +MetamonPerceiverTstepEncoder.n_heads = 4 +MetamonPerceiverTstepEncoder.latent_tokens = 5 +MetamonPerceiverTstepEncoder.numerical_tokens = 4 +MetamonPerceiverTstepEncoder.token_mask_aug = False +MetamonPerceiverTstepEncoder.dropout = .05 +MetamonPerceiverTstepEncoder.max_tokens_per_turn = 128 + + +# amago transformer +traj_encoders.TformerTrajEncoder.n_layers = 3 +traj_encoders.TformerTrajEncoder.n_heads = 8 +traj_encoders.TformerTrajEncoder.d_ff = 1600 +traj_encoders.TformerTrajEncoder.d_model = 400 +traj_encoders.TformerTrajEncoder.normformer_norms = True +traj_encoders.TformerTrajEncoder.sigma_reparam = True +traj_encoders.TformerTrajEncoder.norm = "layer" +traj_encoders.TformerTrajEncoder.head_scaling = True +traj_encoders.TformerTrajEncoder.activation = "leaky_relu" +traj_encoders.TformerTrajEncoder.attention_type = @transformer.FlashAttention +transformer.FlashAttention.window_size = (32, 0) diff --git a/metamon/rl/configs/models/smaller_multitaskagent_grouped_v2.gin b/metamon/rl/configs/models/smaller_multitaskagent_grouped_v2.gin new file mode 100644 index 0000000000..b0553e2862 --- /dev/null +++ b/metamon/rl/configs/models/smaller_multitaskagent_grouped_v2.gin @@ -0,0 +1,72 @@ +import amago.nets.actor_critic +import amago.nets.traj_encoders +import amago.nets.transformer +import amago.agent +import amago.experiment + +MetamonAMAGOExperiment.agent_type = @agent.MultiTaskAgent +MetamonAMAGOExperiment.tstep_encoder_type = @MetamonGroupedTstepEncoderV2 +MetamonAMAGOExperiment.traj_encoder_type = @traj_encoders.TformerTrajEncoder +MetamonAMAGOExperiment.max_seq_len = 128 + +# actor +MultiTaskAgent.actor_type = @MetamonMaskedResidualActor +MultiTaskAgent.pass_obs_keys_to_actor = ["illegal_actions"] +MetamonMaskedResidualActor.activation = "leaky_relu" +MetamonMaskedResidualActor.feature_dim = 300 +MetamonMaskedResidualActor.residual_ff_dim = 400 +MetamonMaskedResidualActor.residual_blocks = 2 + +# critic +MultiTaskAgent.critic_type = @actor_critic.NCriticsTwoHot +actor_critic.NCriticsTwoHot.activation = "leaky_relu" +actor_critic.NCriticsTwoHot.n_layers = 2 +actor_critic.NCriticsTwoHot.d_hidden = 512 +MultiTaskAgent.popart = True +MultiTaskAgent.num_critics = 4 +actor_critic.NCriticsTwoHot.output_bins = 64 +actor_critic.NCriticsTwoHot.min_return = -100 +actor_critic.NCriticsTwoHot.max_return = 2100 +actor_critic.NCriticsTwoHot.use_symlog = False + + +# MetamonGroupedTstepEncoderV2: Pokemon encoder (shared for 7 entities) +MetamonGroupedTstepEncoderV2.d_pokemon = 64 +MetamonGroupedTstepEncoderV2.n_heads_pokemon = 4 +MetamonGroupedTstepEncoderV2.n_layers_pokemon = 3 +MetamonGroupedTstepEncoderV2.latent_tokens_pokemon = 4 +MetamonGroupedTstepEncoderV2.numerical_tokens_pokemon = 4 +MetamonGroupedTstepEncoderV2.pokemon_out_norm = "layer" + +# MetamonGroupedTstepEncoderV2: Global encoder (misc/aggregate features) +MetamonGroupedTstepEncoderV2.d_global = 48 +MetamonGroupedTstepEncoderV2.n_heads_global = 4 +MetamonGroupedTstepEncoderV2.n_layers_global = 1 +MetamonGroupedTstepEncoderV2.latent_tokens_global = 3 +MetamonGroupedTstepEncoderV2.numerical_tokens_global = 2 +MetamonGroupedTstepEncoderV2.global_out_norm = "layer" + +# MetamonGroupedTstepEncoderV2: Fusion encoder (combines 8 entity embeddings) +MetamonGroupedTstepEncoderV2.d_fusion = 84 +MetamonGroupedTstepEncoderV2.n_heads_fusion = 4 +MetamonGroupedTstepEncoderV2.n_layers_fusion = 3 +MetamonGroupedTstepEncoderV2.latent_tokens_fusion = 5 +MetamonGroupedTstepEncoderV2.fusion_out_norm = "layer" + +# MetamonGroupedTstepEncoderV2: General +MetamonGroupedTstepEncoderV2.extra_emb_dim = 18 +MetamonGroupedTstepEncoderV2.dropout = 0.05 +MetamonGroupedTstepEncoderV2.use_flex_attention = False + + +traj_encoders.TformerTrajEncoder.n_layers = 3 +traj_encoders.TformerTrajEncoder.n_heads = 8 +traj_encoders.TformerTrajEncoder.d_ff = 1600 +traj_encoders.TformerTrajEncoder.d_model = 400 +traj_encoders.TformerTrajEncoder.normformer_norms = True +traj_encoders.TformerTrajEncoder.sigma_reparam = True +traj_encoders.TformerTrajEncoder.norm = "layer" +traj_encoders.TformerTrajEncoder.head_scaling = True +traj_encoders.TformerTrajEncoder.activation = "leaky_relu" +traj_encoders.TformerTrajEncoder.attention_type = @transformer.FlashAttention +transformer.FlashAttention.window_size = (32, 0) diff --git a/metamon/rl/configs/models/smaller_multitaskagent_grouped_v2_arch.gin b/metamon/rl/configs/models/smaller_multitaskagent_grouped_v2_arch.gin new file mode 100644 index 0000000000..63f9018219 --- /dev/null +++ b/metamon/rl/configs/models/smaller_multitaskagent_grouped_v2_arch.gin @@ -0,0 +1,75 @@ +import amago.nets.actor_critic +import amago.nets.traj_encoders +import amago.nets.transformer +import amago.agent +import amago.experiment + +MetamonAMAGOExperiment.agent_type = @agent.MultiTaskAgent +MetamonAMAGOExperiment.tstep_encoder_type = @MetamonGroupedTstepEncoderV2 +MetamonAMAGOExperiment.traj_encoder_type = @traj_encoders.TformerTrajEncoder +MetamonAMAGOExperiment.max_seq_len = 128 + +# actor +MultiTaskAgent.actor_type = @MetamonMaskedResidualActor +MultiTaskAgent.pass_obs_keys_to_actor = ["illegal_actions"] +MetamonMaskedResidualActor.activation = "leaky_relu" +MetamonMaskedResidualActor.feature_dim = 300 +MetamonMaskedResidualActor.residual_ff_dim = 400 +MetamonMaskedResidualActor.residual_blocks = 2 + +# critic +MultiTaskAgent.critic_type = @actor_critic.NCriticsTwoHot +actor_critic.NCriticsTwoHot.activation = "leaky_relu" +actor_critic.NCriticsTwoHot.n_layers = 2 +actor_critic.NCriticsTwoHot.d_hidden = 512 +MultiTaskAgent.popart = True +MultiTaskAgent.num_critics = 4 +actor_critic.NCriticsTwoHot.output_bins = 64 +actor_critic.NCriticsTwoHot.min_return = -100 +actor_critic.NCriticsTwoHot.max_return = 2100 +actor_critic.NCriticsTwoHot.use_symlog = False + + +# MetamonGroupedTstepEncoderV2: Pokemon encoder (shared for 7 entities) +MetamonGroupedTstepEncoderV2.d_pokemon = 64 +MetamonGroupedTstepEncoderV2.n_heads_pokemon = 4 +MetamonGroupedTstepEncoderV2.n_layers_pokemon = 3 +MetamonGroupedTstepEncoderV2.latent_tokens_pokemon = 4 +MetamonGroupedTstepEncoderV2.numerical_tokens_pokemon = 4 +MetamonGroupedTstepEncoderV2.pokemon_out_norm = "layer" + +# MetamonGroupedTstepEncoderV2: Global encoder (misc/aggregate features) +MetamonGroupedTstepEncoderV2.d_global = 48 +MetamonGroupedTstepEncoderV2.n_heads_global = 4 +MetamonGroupedTstepEncoderV2.n_layers_global = 1 +MetamonGroupedTstepEncoderV2.latent_tokens_global = 3 +MetamonGroupedTstepEncoderV2.numerical_tokens_global = 2 +MetamonGroupedTstepEncoderV2.global_out_norm = "layer" + +# MetamonGroupedTstepEncoderV2: Fusion encoder (combines 8 entity embeddings) +MetamonGroupedTstepEncoderV2.d_fusion = 84 +MetamonGroupedTstepEncoderV2.n_heads_fusion = 4 +MetamonGroupedTstepEncoderV2.n_layers_fusion = 3 +MetamonGroupedTstepEncoderV2.latent_tokens_fusion = 5 +MetamonGroupedTstepEncoderV2.fusion_out_norm = "layer" + +# MetamonGroupedTstepEncoderV2: General +MetamonGroupedTstepEncoderV2.extra_emb_dim = 18 +MetamonGroupedTstepEncoderV2.dropout = 0.05 +MetamonGroupedTstepEncoderV2.use_flex_attention = False +MetamonGroupedTstepEncoderV2.normformer_norms = True +MetamonGroupedTstepEncoderV2.ff_mult = 3 +MetamonGroupedTstepEncoderV2.pokemon_role_emb = True + + +traj_encoders.TformerTrajEncoder.n_layers = 3 +traj_encoders.TformerTrajEncoder.n_heads = 8 +traj_encoders.TformerTrajEncoder.d_ff = 1600 +traj_encoders.TformerTrajEncoder.d_model = 400 +traj_encoders.TformerTrajEncoder.normformer_norms = True +traj_encoders.TformerTrajEncoder.sigma_reparam = True +traj_encoders.TformerTrajEncoder.norm = "layer" +traj_encoders.TformerTrajEncoder.head_scaling = True +traj_encoders.TformerTrajEncoder.activation = "leaky_relu" +traj_encoders.TformerTrajEncoder.attention_type = @transformer.FlashAttention +transformer.FlashAttention.window_size = (32, 0) diff --git a/metamon/rl/configs/training/__init__.py b/metamon/rl/configs/training/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/metamon/rl/configs/training/alakazam2.gin b/metamon/rl/configs/training/alakazam2.gin index 96be2ce8cd..4d8eef675f 100644 --- a/metamon/rl/configs/training/alakazam2.gin +++ b/metamon/rl/configs/training/alakazam2.gin @@ -27,5 +27,6 @@ agent.leaky_relu_filter.clip_weights_high = 15. MetamonAMAGOExperiment.l2_coeff = 1e-4 MetamonAMAGOExperiment.learning_rate = 1.25e-4 MetamonAMAGOExperiment.grad_clip = 1.5 -MetamonAMAGOExperiment.critic_loss_weight = 13.5 +agent.Agent.critic_loss_weight = 13.5 +agent.MultiTaskAgent.critic_loss_weight = 13.5 MetamonAMAGOExperiment.lr_warmup_steps = 1500 diff --git a/metamon/rl/configs/training/alakazam3.gin b/metamon/rl/configs/training/alakazam3.gin index 11839afd2d..c458ba0e21 100644 --- a/metamon/rl/configs/training/alakazam3.gin +++ b/metamon/rl/configs/training/alakazam3.gin @@ -27,5 +27,6 @@ agent.leaky_relu_filter.clip_weights_high = 15. MetamonAMAGOExperiment.l2_coeff = 1e-4 MetamonAMAGOExperiment.learning_rate = 1.25e-4 MetamonAMAGOExperiment.grad_clip = 1.5 -MetamonAMAGOExperiment.critic_loss_weight = 13.5 +agent.Agent.critic_loss_weight = 13.5 +agent.MultiTaskAgent.critic_loss_weight = 13.5 MetamonAMAGOExperiment.lr_warmup_steps = 2000 diff --git a/metamon/rl/configs/training/alakazam3_isfilter.gin b/metamon/rl/configs/training/alakazam3_isfilter.gin new file mode 100644 index 0000000000..ad800478a4 --- /dev/null +++ b/metamon/rl/configs/training/alakazam3_isfilter.gin @@ -0,0 +1,44 @@ +import amago.agent +import metamon.rl.custom_agent + +# Identical to alakazam3.gin but replaces leaky_relu_filter with +# ISAdvantageFilter (batch-normalized exponential weighting + optional +# sequence-level filtering). No MetamonFinetuneAgent, no IS correction, +# no base-model copies — pure advantage-based sample weighting on a +# vanilla MultiTaskAgent, suitable for training from scratch. + +agent.Agent.reward_multiplier = 10. +agent.MultiTaskAgent.reward_multiplier = 10. + +agent.Agent.tau = .008 +agent.MultiTaskAgent.tau = .008 + +agent.Agent.num_actions_for_value_in_critic_loss = 1 +agent.MultiTaskAgent.num_actions_for_value_in_critic_loss = 4 + +agent.Agent.num_actions_for_value_in_actor_loss = 1 +agent.MultiTaskAgent.num_actions_for_value_in_actor_loss = 4 + +agent.Agent.online_coeff = 0.0 +agent.MultiTaskAgent.online_coeff = 0.0 +agent.Agent.offline_coeff = 1.0 +agent.MultiTaskAgent.offline_coeff = 1.0 + +agent.Agent.fbc_filter_func = @custom_agent.ISAdvantageFilter() +agent.MultiTaskAgent.fbc_filter_func = @custom_agent.ISAdvantageFilter() +custom_agent.ISAdvantageFilter.beta = 3.0 +custom_agent.ISAdvantageFilter.clip_weights_low = 1e-6 +custom_agent.ISAdvantageFilter.clip_weights_high = 100. +custom_agent.ISAdvantageFilter.seq_p_low = 0.2 +custom_agent.ISAdvantageFilter.seq_p_full = 0.7 +custom_agent.ISAdvantageFilter.seq_floor = 0.1 +custom_agent.ISAdvantageFilter.seq_floor_warmup_steps = 100_000 +custom_agent.ISAdvantageFilter.seq_buffer_size = 10000 +custom_agent.ISAdvantageFilter.seq_warmup = 200 + +MetamonAMAGOExperiment.l2_coeff = 1e-4 +MetamonAMAGOExperiment.learning_rate = 1.25e-4 +MetamonAMAGOExperiment.grad_clip = 1.5 +agent.Agent.critic_loss_weight = 13.5 +agent.MultiTaskAgent.critic_loss_weight = 13.5 +MetamonAMAGOExperiment.lr_warmup_steps = 2000 diff --git a/metamon/rl/configs/training/alakazam.gin b/metamon/rl/configs/training/alakazam_beta0.1.gin similarity index 55% rename from metamon/rl/configs/training/alakazam.gin rename to metamon/rl/configs/training/alakazam_beta0.1.gin index 929a28b883..1c948469db 100644 --- a/metamon/rl/configs/training/alakazam.gin +++ b/metamon/rl/configs/training/alakazam_beta0.1.gin @@ -3,8 +3,8 @@ import amago.agent agent.Agent.reward_multiplier = 10. agent.MultiTaskAgent.reward_multiplier = 10. -agent.Agent.tau = .004 -agent.MultiTaskAgent.tau = .004 +agent.Agent.tau = .008 +agent.MultiTaskAgent.tau = .008 agent.Agent.num_actions_for_value_in_critic_loss = 1 agent.MultiTaskAgent.num_actions_for_value_in_critic_loss = 4 @@ -16,16 +16,15 @@ agent.Agent.online_coeff = 0.0 agent.MultiTaskAgent.online_coeff = 0.0 agent.Agent.offline_coeff = 1.0 agent.MultiTaskAgent.offline_coeff = 1.0 -agent.Agent.fbc_filter_func = @agent.leaky_relu_filter -agent.MultiTaskAgent.fbc_filter_func = @agent.leaky_relu_filter -agent.leaky_relu_filter.beta = .5 -agent.leaky_relu_filter.tau = 1e-2 -agent.leaky_relu_filter.neg_slope = .05 -agent.leaky_relu_filter.clip_weights_low = 1e-3 -agent.leaky_relu_filter.clip_weights_high = 15. +agent.Agent.fbc_filter_func = @agent.exp_filter +agent.MultiTaskAgent.fbc_filter_func = @agent.exp_filter +agent.exp_filter.beta = 0.1 +agent.exp_filter.clip_weights_low = 1e-6 +agent.exp_filter.clip_weights_high = 100. MetamonAMAGOExperiment.l2_coeff = 1e-4 MetamonAMAGOExperiment.learning_rate = 1.25e-4 MetamonAMAGOExperiment.grad_clip = 1.5 -MetamonAMAGOExperiment.critic_loss_weight = 12.5 -MetamonAMAGOExperiment.lr_warmup_steps = 1250 +agent.Agent.critic_loss_weight = 13.5 +agent.MultiTaskAgent.critic_loss_weight = 13.5 +MetamonAMAGOExperiment.lr_warmup_steps = 2000 diff --git a/metamon/rl/configs/training/alakazam_beta1.gin b/metamon/rl/configs/training/alakazam_beta1.gin new file mode 100644 index 0000000000..a5bf284961 --- /dev/null +++ b/metamon/rl/configs/training/alakazam_beta1.gin @@ -0,0 +1,30 @@ +import amago.agent + +agent.Agent.reward_multiplier = 10. +agent.MultiTaskAgent.reward_multiplier = 10. + +agent.Agent.tau = .008 +agent.MultiTaskAgent.tau = .008 + +agent.Agent.num_actions_for_value_in_critic_loss = 1 +agent.MultiTaskAgent.num_actions_for_value_in_critic_loss = 4 + +agent.Agent.num_actions_for_value_in_actor_loss = 1 +agent.MultiTaskAgent.num_actions_for_value_in_actor_loss = 4 + +agent.Agent.online_coeff = 0.0 +agent.MultiTaskAgent.online_coeff = 0.0 +agent.Agent.offline_coeff = 1.0 +agent.MultiTaskAgent.offline_coeff = 1.0 +agent.Agent.fbc_filter_func = @agent.exp_filter +agent.MultiTaskAgent.fbc_filter_func = @agent.exp_filter +agent.exp_filter.beta = 1.0 +agent.exp_filter.clip_weights_low = 1e-6 +agent.exp_filter.clip_weights_high = 100. + +MetamonAMAGOExperiment.l2_coeff = 1e-4 +MetamonAMAGOExperiment.learning_rate = 1.25e-4 +MetamonAMAGOExperiment.grad_clip = 1.5 +agent.Agent.critic_loss_weight = 13.5 +agent.MultiTaskAgent.critic_loss_weight = 13.5 +MetamonAMAGOExperiment.lr_warmup_steps = 2000 diff --git a/metamon/rl/configs/training/alakazam_beta10.gin b/metamon/rl/configs/training/alakazam_beta10.gin new file mode 100644 index 0000000000..ef9b1368f2 --- /dev/null +++ b/metamon/rl/configs/training/alakazam_beta10.gin @@ -0,0 +1,30 @@ +import amago.agent + +agent.Agent.reward_multiplier = 10. +agent.MultiTaskAgent.reward_multiplier = 10. + +agent.Agent.tau = .008 +agent.MultiTaskAgent.tau = .008 + +agent.Agent.num_actions_for_value_in_critic_loss = 1 +agent.MultiTaskAgent.num_actions_for_value_in_critic_loss = 4 + +agent.Agent.num_actions_for_value_in_actor_loss = 1 +agent.MultiTaskAgent.num_actions_for_value_in_actor_loss = 4 + +agent.Agent.online_coeff = 0.0 +agent.MultiTaskAgent.online_coeff = 0.0 +agent.Agent.offline_coeff = 1.0 +agent.MultiTaskAgent.offline_coeff = 1.0 +agent.Agent.fbc_filter_func = @agent.exp_filter +agent.MultiTaskAgent.fbc_filter_func = @agent.exp_filter +agent.exp_filter.beta = 10.0 +agent.exp_filter.clip_weights_low = 1e-6 +agent.exp_filter.clip_weights_high = 100. + +MetamonAMAGOExperiment.l2_coeff = 1e-4 +MetamonAMAGOExperiment.learning_rate = 1.25e-4 +MetamonAMAGOExperiment.grad_clip = 1.5 +agent.Agent.critic_loss_weight = 13.5 +agent.MultiTaskAgent.critic_loss_weight = 13.5 +MetamonAMAGOExperiment.lr_warmup_steps = 2000 diff --git a/metamon/rl/configs/training/alakazam_beta3.gin b/metamon/rl/configs/training/alakazam_beta3.gin new file mode 100644 index 0000000000..8a37f0aa42 --- /dev/null +++ b/metamon/rl/configs/training/alakazam_beta3.gin @@ -0,0 +1,30 @@ +import amago.agent + +agent.Agent.reward_multiplier = 10. +agent.MultiTaskAgent.reward_multiplier = 10. + +agent.Agent.tau = .008 +agent.MultiTaskAgent.tau = .008 + +agent.Agent.num_actions_for_value_in_critic_loss = 1 +agent.MultiTaskAgent.num_actions_for_value_in_critic_loss = 4 + +agent.Agent.num_actions_for_value_in_actor_loss = 1 +agent.MultiTaskAgent.num_actions_for_value_in_actor_loss = 4 + +agent.Agent.online_coeff = 0.0 +agent.MultiTaskAgent.online_coeff = 0.0 +agent.Agent.offline_coeff = 1.0 +agent.MultiTaskAgent.offline_coeff = 1.0 +agent.Agent.fbc_filter_func = @agent.exp_filter +agent.MultiTaskAgent.fbc_filter_func = @agent.exp_filter +agent.exp_filter.beta = 3.0 +agent.exp_filter.clip_weights_low = 1e-6 +agent.exp_filter.clip_weights_high = 100. + +MetamonAMAGOExperiment.l2_coeff = 1e-4 +MetamonAMAGOExperiment.learning_rate = 1.25e-4 +MetamonAMAGOExperiment.grad_clip = 1.5 +agent.Agent.critic_loss_weight = 13.5 +agent.MultiTaskAgent.critic_loss_weight = 13.5 +MetamonAMAGOExperiment.lr_warmup_steps = 2000 diff --git a/metamon/rl/configs/training/alakazam_bnorm_beta3.gin b/metamon/rl/configs/training/alakazam_bnorm_beta3.gin new file mode 100644 index 0000000000..5c1d6cc41d --- /dev/null +++ b/metamon/rl/configs/training/alakazam_bnorm_beta3.gin @@ -0,0 +1,31 @@ +import amago.agent +import metamon.rl.metamon_to_amago + +agent.Agent.reward_multiplier = 10. +agent.MultiTaskAgent.reward_multiplier = 10. + +agent.Agent.tau = .008 +agent.MultiTaskAgent.tau = .008 + +agent.Agent.num_actions_for_value_in_critic_loss = 1 +agent.MultiTaskAgent.num_actions_for_value_in_critic_loss = 4 + +agent.Agent.num_actions_for_value_in_actor_loss = 1 +agent.MultiTaskAgent.num_actions_for_value_in_actor_loss = 4 + +agent.Agent.online_coeff = 0.0 +agent.MultiTaskAgent.online_coeff = 0.0 +agent.Agent.offline_coeff = 1.0 +agent.MultiTaskAgent.offline_coeff = 1.0 +agent.Agent.fbc_filter_func = @metamon_to_amago.BatchNormalizedExpFilter() +agent.MultiTaskAgent.fbc_filter_func = @metamon_to_amago.BatchNormalizedExpFilter() +metamon_to_amago.BatchNormalizedExpFilter.beta = 3.0 +metamon_to_amago.BatchNormalizedExpFilter.clip_weights_low = 1e-6 +metamon_to_amago.BatchNormalizedExpFilter.clip_weights_high = 100. + +MetamonAMAGOExperiment.l2_coeff = 1e-4 +MetamonAMAGOExperiment.learning_rate = 1.25e-4 +MetamonAMAGOExperiment.grad_clip = 1.5 +agent.Agent.critic_loss_weight = 13.5 +agent.MultiTaskAgent.critic_loss_weight = 13.5 +MetamonAMAGOExperiment.lr_warmup_steps = 2000 diff --git a/metamon/rl/configs/training/alakazam_bnorm_beta3_hlgauss.gin b/metamon/rl/configs/training/alakazam_bnorm_beta3_hlgauss.gin new file mode 100644 index 0000000000..96df316ef7 --- /dev/null +++ b/metamon/rl/configs/training/alakazam_bnorm_beta3_hlgauss.gin @@ -0,0 +1,40 @@ +import amago.agent +import amago.nets.actor_critic +import metamon.rl.metamon_to_amago + +agent.Agent.reward_multiplier = 10. +agent.MultiTaskAgent.reward_multiplier = 10. + +agent.Agent.tau = .008 +agent.MultiTaskAgent.tau = .008 + +agent.Agent.num_actions_for_value_in_critic_loss = 1 +agent.MultiTaskAgent.num_actions_for_value_in_critic_loss = 4 + +agent.Agent.num_actions_for_value_in_actor_loss = 1 +agent.MultiTaskAgent.num_actions_for_value_in_actor_loss = 4 + +agent.Agent.online_coeff = 0.0 +agent.MultiTaskAgent.online_coeff = 0.0 +agent.Agent.offline_coeff = 1.0 +agent.MultiTaskAgent.offline_coeff = 1.0 +agent.Agent.fbc_filter_func = @metamon_to_amago.BatchNormalizedExpFilter() +agent.MultiTaskAgent.fbc_filter_func = @metamon_to_amago.BatchNormalizedExpFilter() +metamon_to_amago.BatchNormalizedExpFilter.beta = 3.0 +metamon_to_amago.BatchNormalizedExpFilter.clip_weights_low = 1e-6 +metamon_to_amago.BatchNormalizedExpFilter.clip_weights_high = 100. + +agent.Agent.use_multigamma = False +agent.MultiTaskAgent.use_multigamma = False +agent.Agent.gamma = 0.992 +agent.MultiTaskAgent.gamma = 0.992 + +actor_critic.NCriticsTwoHot.output_bins = 256 +actor_critic.NCriticsTwoHot.label_type = "hlgauss" + +MetamonAMAGOExperiment.l2_coeff = 1e-4 +MetamonAMAGOExperiment.learning_rate = 1.25e-4 +MetamonAMAGOExperiment.grad_clip = 1.5 +agent.Agent.critic_loss_weight = 13.5 +agent.MultiTaskAgent.critic_loss_weight = 13.5 +MetamonAMAGOExperiment.lr_warmup_steps = 2000 diff --git a/metamon/rl/configs/training/alakazam_bnorm_beta3_hlgauss_vanilla.gin b/metamon/rl/configs/training/alakazam_bnorm_beta3_hlgauss_vanilla.gin new file mode 100644 index 0000000000..6e00ded94f --- /dev/null +++ b/metamon/rl/configs/training/alakazam_bnorm_beta3_hlgauss_vanilla.gin @@ -0,0 +1,46 @@ +import amago.agent +import amago.nets.actor_critic +import amago.nets.traj_encoders +import metamon.rl.metamon_to_amago + +agent.Agent.reward_multiplier = 10. +agent.MultiTaskAgent.reward_multiplier = 10. + +agent.Agent.tau = .008 +agent.MultiTaskAgent.tau = .008 + +agent.Agent.num_actions_for_value_in_critic_loss = 1 +agent.MultiTaskAgent.num_actions_for_value_in_critic_loss = 4 + +agent.Agent.num_actions_for_value_in_actor_loss = 1 +agent.MultiTaskAgent.num_actions_for_value_in_actor_loss = 4 + +agent.Agent.online_coeff = 0.0 +agent.MultiTaskAgent.online_coeff = 0.0 +agent.Agent.offline_coeff = 1.0 +agent.MultiTaskAgent.offline_coeff = 1.0 +agent.Agent.fbc_filter_func = @metamon_to_amago.BatchNormalizedExpFilter() +agent.MultiTaskAgent.fbc_filter_func = @metamon_to_amago.BatchNormalizedExpFilter() +metamon_to_amago.BatchNormalizedExpFilter.beta = 3.0 +metamon_to_amago.BatchNormalizedExpFilter.clip_weights_low = 1e-6 +metamon_to_amago.BatchNormalizedExpFilter.clip_weights_high = 100. + +agent.Agent.use_multigamma = False +agent.MultiTaskAgent.use_multigamma = False +agent.Agent.gamma = 0.992 +agent.MultiTaskAgent.gamma = 0.992 + +actor_critic.NCriticsTwoHot.output_bins = 256 +actor_critic.NCriticsTwoHot.label_type = "hlgauss" + +# Vanilla transformer: no NormFormer, no sigma-reparam, no head scaling +traj_encoders.TformerTrajEncoder.normformer_norms = False +traj_encoders.TformerTrajEncoder.sigma_reparam = False +traj_encoders.TformerTrajEncoder.head_scaling = False + +MetamonAMAGOExperiment.l2_coeff = 1e-4 +MetamonAMAGOExperiment.learning_rate = 1.25e-4 +MetamonAMAGOExperiment.grad_clip = 1.5 +agent.Agent.critic_loss_weight = 13.5 +agent.MultiTaskAgent.critic_loss_weight = 13.5 +MetamonAMAGOExperiment.lr_warmup_steps = 2000 diff --git a/metamon/rl/configs/training/binary_maxq_rl.gin b/metamon/rl/configs/training/binary_maxq_rl.gin index f9b07ffb26..1108a220fe 100644 --- a/metamon/rl/configs/training/binary_maxq_rl.gin +++ b/metamon/rl/configs/training/binary_maxq_rl.gin @@ -20,5 +20,6 @@ agent.MultiTaskAgent.num_actions_for_value_in_actor_loss = 3 MetamonAMAGOExperiment.l2_coeff = 1e-4 MetamonAMAGOExperiment.learning_rate = 1.5e-4 MetamonAMAGOExperiment.grad_clip = 1.5 -MetamonAMAGOExperiment.critic_loss_weight = 10. +agent.Agent.critic_loss_weight = 10. +agent.MultiTaskAgent.critic_loss_weight = 10. MetamonAMAGOExperiment.lr_warmup_steps = 1000 \ No newline at end of file diff --git a/metamon/rl/configs/training/binary_rl.gin b/metamon/rl/configs/training/binary_rl.gin index f8ae37e3ce..14a45509a0 100644 --- a/metamon/rl/configs/training/binary_rl.gin +++ b/metamon/rl/configs/training/binary_rl.gin @@ -22,5 +22,6 @@ agent.MultiTaskAgent.fbc_filter_func = @agent.binary_filter MetamonAMAGOExperiment.l2_coeff = 1e-4 MetamonAMAGOExperiment.learning_rate = 1.5e-4 MetamonAMAGOExperiment.grad_clip = 1.5 -MetamonAMAGOExperiment.critic_loss_weight = 10. +agent.Agent.critic_loss_weight = 10. +agent.MultiTaskAgent.critic_loss_weight = 10. MetamonAMAGOExperiment.lr_warmup_steps = 1000 \ No newline at end of file diff --git a/metamon/rl/configs/training/exp_rl.gin b/metamon/rl/configs/training/exp_rl.gin index 1b35fdf974..598c72aee9 100644 --- a/metamon/rl/configs/training/exp_rl.gin +++ b/metamon/rl/configs/training/exp_rl.gin @@ -24,5 +24,6 @@ agent.MultiTaskAgent.fbc_filter_func = @agent.exp_filter MetamonAMAGOExperiment.l2_coeff = 1e-4 MetamonAMAGOExperiment.learning_rate = 1.5e-4 MetamonAMAGOExperiment.grad_clip = 1.5 -MetamonAMAGOExperiment.critic_loss_weight = 10. +agent.Agent.critic_loss_weight = 10. +agent.MultiTaskAgent.critic_loss_weight = 10. MetamonAMAGOExperiment.lr_warmup_steps = 1000 \ No newline at end of file diff --git a/metamon/rl/configs/training/finetune.gin b/metamon/rl/configs/training/finetune.gin new file mode 100644 index 0000000000..78a75b6693 --- /dev/null +++ b/metamon/rl/configs/training/finetune.gin @@ -0,0 +1,45 @@ +import amago.agent +import metamon.rl.custom_agent + +MetamonAMAGOExperiment.agent_type = @custom_agent.MetamonFinetuneAgent + +agent.Agent.reward_multiplier = 10. +agent.MultiTaskAgent.reward_multiplier = 10. + +agent.Agent.tau = .008 +agent.MultiTaskAgent.tau = .008 + +agent.Agent.num_actions_for_value_in_critic_loss = 1 +agent.MultiTaskAgent.num_actions_for_value_in_critic_loss = 4 + +agent.Agent.num_actions_for_value_in_actor_loss = 1 +agent.MultiTaskAgent.num_actions_for_value_in_actor_loss = 4 + +agent.Agent.online_coeff = 0.0 +agent.MultiTaskAgent.online_coeff = 0.0 +agent.Agent.offline_coeff = 1.0 +agent.MultiTaskAgent.offline_coeff = 1.0 + +agent.Agent.fbc_filter_func = @custom_agent.ISAdvantageFilter() +agent.MultiTaskAgent.fbc_filter_func = @custom_agent.ISAdvantageFilter() +custom_agent.ISAdvantageFilter.beta = 3.0 +custom_agent.ISAdvantageFilter.clip_weights_low = 1e-6 +custom_agent.ISAdvantageFilter.clip_weights_high = 100. +custom_agent.ISAdvantageFilter.seq_p_low = 0.4 +custom_agent.ISAdvantageFilter.seq_p_full = 0.8 +custom_agent.ISAdvantageFilter.seq_floor = 0.05 +custom_agent.ISAdvantageFilter.seq_floor_warmup_steps = 4000 +custom_agent.ISAdvantageFilter.seq_buffer_size = 10000 +custom_agent.ISAdvantageFilter.seq_warmup = 200 + +custom_agent.MetamonFinetuneAgent.bc_coeff = 1.0 +custom_agent.MetamonFinetuneAgent.tortoise_tau = 0.005 +custom_agent.MetamonFinetuneAgent.use_tortoise_for_inference = False +custom_agent.MetamonFinetuneAgent.use_is_correction = False + +MetamonAMAGOExperiment.l2_coeff = 1e-4 +MetamonAMAGOExperiment.learning_rate = 8e-5 +MetamonAMAGOExperiment.grad_clip = 1.5 +agent.Agent.critic_loss_weight = 13.5 +agent.MultiTaskAgent.critic_loss_weight = 13.5 +MetamonAMAGOExperiment.lr_warmup_steps = 5000 diff --git a/metamon/rl/configs/training/grouped_v2_large_isfilter.gin b/metamon/rl/configs/training/grouped_v2_large_isfilter.gin new file mode 100644 index 0000000000..40066a086c --- /dev/null +++ b/metamon/rl/configs/training/grouped_v2_large_isfilter.gin @@ -0,0 +1,43 @@ +import amago.agent +import metamon.rl.custom_agent + +# Based on alakazam3_isfilter.gin but with tuning for the larger model: +# - Lower learning rate (1e-4) and longer warmup for stability at scale +# - Slightly lower grad clip +# - Same ISAdvantageFilter with sequence-level filtering + +agent.Agent.reward_multiplier = 10. +agent.MultiTaskAgent.reward_multiplier = 10. + +agent.Agent.tau = .008 +agent.MultiTaskAgent.tau = .008 + +agent.Agent.num_actions_for_value_in_critic_loss = 1 +agent.MultiTaskAgent.num_actions_for_value_in_critic_loss = 4 + +agent.Agent.num_actions_for_value_in_actor_loss = 1 +agent.MultiTaskAgent.num_actions_for_value_in_actor_loss = 4 + +agent.Agent.online_coeff = 0.0 +agent.MultiTaskAgent.online_coeff = 0.0 +agent.Agent.offline_coeff = 1.0 +agent.MultiTaskAgent.offline_coeff = 1.0 + +agent.Agent.fbc_filter_func = @custom_agent.ISAdvantageFilter() +agent.MultiTaskAgent.fbc_filter_func = @custom_agent.ISAdvantageFilter() +custom_agent.ISAdvantageFilter.beta = 3.0 +custom_agent.ISAdvantageFilter.clip_weights_low = 1e-6 +custom_agent.ISAdvantageFilter.clip_weights_high = 100. +custom_agent.ISAdvantageFilter.seq_p_low = 0.2 +custom_agent.ISAdvantageFilter.seq_p_full = 0.7 +custom_agent.ISAdvantageFilter.seq_floor = 0.1 +custom_agent.ISAdvantageFilter.seq_floor_warmup_steps = 100_000 +custom_agent.ISAdvantageFilter.seq_buffer_size = 10000 +custom_agent.ISAdvantageFilter.seq_warmup = 200 + +MetamonAMAGOExperiment.l2_coeff = 1e-4 +MetamonAMAGOExperiment.learning_rate = 1e-4 +MetamonAMAGOExperiment.grad_clip = 1.0 +agent.Agent.critic_loss_weight = 13.5 +agent.MultiTaskAgent.critic_loss_weight = 13.5 +MetamonAMAGOExperiment.lr_warmup_steps = 5000 diff --git a/metamon/rl/configs/training/il.gin b/metamon/rl/configs/training/il.gin index d19a075676..b4f674b894 100644 --- a/metamon/rl/configs/training/il.gin +++ b/metamon/rl/configs/training/il.gin @@ -12,7 +12,8 @@ amago.agent.MultiTaskAgent.use_multigamma = False amago.agent.MultiTaskAgent.fake_filter = True MetamonAMAGOExperiment.l2_coeff = 1e-4 -MetamonAMAGOExperiment.learning_rate = 1.5e-4 +MetamonAMAGOExperiment.learning_rate = 1.25e-4 MetamonAMAGOExperiment.grad_clip = 1.5 -MetamonAMAGOExperiment.critic_loss_weight = 10. -MetamonAMAGOExperiment.lr_warmup_steps = 1000 \ No newline at end of file +agent.Agent.critic_loss_weight = 10. +agent.MultiTaskAgent.critic_loss_weight = 10. +MetamonAMAGOExperiment.lr_warmup_steps = 2000 \ No newline at end of file diff --git a/metamon/rl/configs/training/kakuna.gin b/metamon/rl/configs/training/kakuna.gin index 8e1419789e..0e8e4d6b3b 100644 --- a/metamon/rl/configs/training/kakuna.gin +++ b/metamon/rl/configs/training/kakuna.gin @@ -27,5 +27,6 @@ agent.leaky_relu_filter.clip_weights_high = 15. MetamonAMAGOExperiment.l2_coeff = 1e-4 MetamonAMAGOExperiment.learning_rate = 1e-4 MetamonAMAGOExperiment.grad_clip = 1.0 -MetamonAMAGOExperiment.critic_loss_weight = 12.5 +agent.Agent.critic_loss_weight = 12.5 +agent.MultiTaskAgent.critic_loss_weight = 12.5 MetamonAMAGOExperiment.lr_warmup_steps = 10000 diff --git a/metamon/rl/custom_agent.py b/metamon/rl/custom_agent.py new file mode 100644 index 0000000000..fc5bf5d0a3 --- /dev/null +++ b/metamon/rl/custom_agent.py @@ -0,0 +1,527 @@ +"""Finetuning agent with a slow-EMA tortoise shadow and optional IS correction. + +Implements ``MetamonFinetuneAgent``, a ``MultiTaskAgent`` subclass that +maintains a slow exponential-moving-average (tortoise) copy of the full +online (hare) actor-critic. Inference can be switched between hare and +tortoise via a flag, and iterative finetuning is supported by initialising +each new round's hare from the previous round's tortoise weights. + +Optionally, an ``ISAdvantageFilter`` applies log-space clipped importance- +sampling corrections using a frozen base-policy snapshot and a trainable +behavioral-cloning head that estimates the data distribution. +""" + +from __future__ import annotations + +import bisect +import copy +import itertools +import math +from collections import deque +from typing import Any, Optional, Tuple + +import gin +import torch +import torch.nn.functional as F +from einops import repeat + +import amago +from amago.agent import MultiTaskAgent +from amago.loading import Batch +from amago.nets.policy_dists import DiscreteLikeContinuous + + +@gin.configurable +class ISAdvantageFilter: + """Batch-normalized exponential weighting with optional IS and sequence-level filtering. + + Per-timestep: ``w(s,a) = exp(beta * A_norm + delta_log_clipped)`` + + Optionally multiplied by a per-sequence sigmoid weight based on the + online percentile rank of each sequence's mean advantage. Enabled when + ``seq_p_low`` is not None. The sequence sigmoid maps: + + * percentile ≈ ``seq_p_low`` → weight ≈ ``seq_floor`` + * percentile ≈ ``seq_p_full`` → weight ≈ 1.0 + + Steepness is derived: ``k = 2·ln(99) / (seq_p_full − seq_p_low)``. + The floor ramps from 1.0 (off) to ``seq_floor`` over + ``seq_floor_warmup_steps`` calls. + + Injected tensors (``delta_log``, ``seq_mask``) are set externally + before each ``__call__`` and cleared after use. + + Args: + beta: Temperature for normalized advantages. + clip_delta: Symmetric log-space clip bound for the IS ratio. + eps: Numerical stability constant for std normalization. + clip_weights_low: Floor for final per-timestep weights. + clip_weights_high: Ceiling for final per-timestep weights. + seq_p_low: Percentile where sequence weight ≈ floor (None = disabled). + seq_p_full: Percentile where sequence weight ≈ 1.0. + seq_floor: Minimum sequence weight. + seq_floor_warmup_steps: Ramp floor from 1.0 to ``seq_floor`` over + this many calls. Match to LR warmup. + seq_buffer_size: Circular buffer capacity for percentile estimation. + seq_warmup: Min buffer entries before non-uniform sequence weights. + """ + + def __init__( + self, + beta: float = 2.0, + clip_delta: float = 2.0, + eps: float = 1e-8, + clip_weights_low: Optional[float] = 1e-7, + clip_weights_high: Optional[float] = 100.0, + seq_p_low: Optional[float] = None, + seq_p_full: Optional[float] = None, + seq_floor: float = 0.1, + seq_floor_warmup_steps: int = 2000, + seq_buffer_size: int = 10_000, + seq_warmup: int = 200, + ): + self.beta = beta + self.clip_delta = clip_delta + self.eps = eps + self.clip_weights_low = clip_weights_low + self.clip_weights_high = clip_weights_high + self._mask: Optional[torch.Tensor] = None + self._delta_log: Optional[torch.Tensor] = None + + # Sequence-level filter state + self.seq_enabled = seq_p_low is not None + self._seq_mask: Optional[torch.Tensor] = None + self._seq_last_weights: Optional[torch.Tensor] = None + self._seq_last_percentiles: Optional[torch.Tensor] = None + self._seq_last_eff_floor: float = 1.0 + if self.seq_enabled: + assert seq_p_full is not None, "seq_p_full required when seq_p_low is set" + assert seq_p_full > seq_p_low, "seq_p_full must be greater than seq_p_low" + self._seq_p_low = seq_p_low + self._seq_p_full = seq_p_full + self._seq_floor = seq_floor + self._seq_warmup_steps = seq_floor_warmup_steps + self._seq_warmup = seq_warmup + self._seq_center = (seq_p_low + seq_p_full) / 2.0 + self._seq_k = 2.0 * math.log(99.0) / max(seq_p_full - seq_p_low, 1e-6) + self._seq_buffer: deque[float] = deque(maxlen=seq_buffer_size) + self._seq_sorted_cache: Optional[list[float]] = None + self._seq_cache_dirty = True + self._seq_step = 0 + + def set_mask(self, mask: Optional[torch.Tensor]): + """Inject boolean mask for BN statistics; cleared after use.""" + self._mask = mask + + def set_delta_log(self, delta_log: Optional[torch.Tensor]): + """Inject IS correction tensor; cleared after use.""" + self._delta_log = delta_log + + def set_seq_mask(self, mask: Optional[torch.Tensor]): + """Inject state-validity mask for sequence-level mean; cleared after use.""" + self._seq_mask = mask + + def _seq_sorted_buf(self) -> list[float]: + if self._seq_cache_dirty: + self._seq_sorted_cache = sorted(self._seq_buffer) + self._seq_cache_dirty = False + return self._seq_sorted_cache # type: ignore[return-value] + + def _compute_seq_weights(self, adv: torch.Tensor) -> torch.Tensor: + """Per-sequence sigmoid weights from per-timestep advantages.""" + self._seq_step += 1 + seq_mask = self._seq_mask + self._seq_mask = None + B, L, G, _ = adv.shape + + adv_f = adv.detach().float() + if seq_mask is not None: + m = seq_mask[:, :L, :].expand(B, L, G).unsqueeze(-1).bool() + counts = m.float().sum(dim=(1, 2, 3)).clamp(min=1) + mean_adv = (adv_f * m.float()).sum(dim=(1, 2, 3)) / counts + else: + mean_adv = adv_f.mean(dim=(1, 2, 3)) + + mean_adv_list = mean_adv.cpu().tolist() + self._seq_buffer.extend(mean_adv_list) + self._seq_cache_dirty = True + + if len(self._seq_buffer) < self._seq_warmup: + self._seq_last_weights = None + self._seq_last_percentiles = None + self._seq_last_eff_floor = 1.0 + return torch.ones(B, 1, 1, 1, device=adv.device) + + ramp = min(self._seq_step / max(self._seq_warmup_steps, 1), 1.0) + eff_floor = 1.0 - (1.0 - self._seq_floor) * ramp + self._seq_last_eff_floor = eff_floor + + sorted_buf = self._seq_sorted_buf() + n = len(sorted_buf) + pcts = [bisect.bisect_left(sorted_buf, v) / n for v in mean_adv_list] + percentiles = torch.tensor(pcts, device=adv.device, dtype=adv.dtype) + + sigmoid_in = self._seq_k * (percentiles - self._seq_center) + weights = eff_floor + (1.0 - eff_floor) * torch.sigmoid(sigmoid_in) + + self._seq_last_weights = weights + self._seq_last_percentiles = percentiles + return weights.view(B, 1, 1, 1) + + def __call__(self, adv: torch.Tensor) -> torch.Tensor: + mask = self._mask + self._mask = None + delta_log = self._delta_log + self._delta_log = None + + if mask is not None: + mask = mask[:, : adv.shape[1], ...] + while mask.ndim < adv.ndim: + mask = mask.unsqueeze(-1) + mask = mask.expand_as(adv) + valid = adv[mask] + mu = valid.mean() + sigma = valid.std() + self.eps + else: + mu = adv.mean() + sigma = adv.std() + self.eps + + adv_norm = (adv - mu) / sigma + exponent = self.beta * adv_norm + + if delta_log is not None: + delta_log = delta_log[:, : adv.shape[1], ...] + exponent = exponent + torch.clamp( + delta_log, -self.clip_delta, self.clip_delta + ) + + weights = torch.exp(exponent) + if self.clip_weights_low is not None or self.clip_weights_high is not None: + weights = torch.clamp( + weights, min=self.clip_weights_low, max=self.clip_weights_high + ) + + if self.seq_enabled: + weights = weights * self._compute_seq_weights(adv) + + return weights + + +_TORTOISE_PREFIX = "_tortoise_" +_BASE_PREFIX = "_base_" + +_TORTOISE_MODULES = { + "_tortoise_tstep_encoder": "tstep_encoder", + "_tortoise_traj_encoder": "traj_encoder", + "_tortoise_actor": "actor", + "_tortoise_critics": "critics", +} +_BASE_MODULES = { + "_base_tstep_encoder": "tstep_encoder", + "_base_traj_encoder": "traj_encoder", + "_base_actor": "actor", +} + + +@gin.configurable +class MetamonFinetuneAgent(MultiTaskAgent): + """MultiTaskAgent with a slow-EMA tortoise shadow and optional IS correction. + + On top of the standard hare (online) and target networks, this agent + maintains: + + * **Tortoise** — slow EMA of the full hare (encoder + actor + critics), + usable for inference and as the starting point for iterative finetuning. + * **Base model** — static frozen snapshot of the encoder + actor at + training start, used for computing ``log pi_base(a|s)``. + * **BC actor** — trainable actor head that estimates the data distribution + ``pi_data`` on the frozen base representation. + + When ``fbc_filter_func`` is an :class:`ISAdvantageFilter`, the filter + receives a per-sample IS correction + ``delta_log = log pi_base - log pi_data`` before each training step. + Sequence-level filtering is controlled via ``ISAdvantageFilter.seq_p_low`` + (set in gin); when enabled, the filter also multiplies per-timestep + weights by a per-sequence sigmoid weight. + + Args: + bc_coeff: Weight for the auxiliary BC loss on ``_bc_actor``. + tortoise_tau: EMA rate for the tortoise update (smaller = slower). + use_tortoise_for_inference: If True, ``get_actions`` runs the + tortoise encoder + actor instead of the hare. + use_is_correction: If False, skips the base-model / BC-actor IS + correction and trains with plain batch-normalized advantages + (while still maintaining the tortoise EMA shadow). + """ + + def __init__( + self, + *args, + bc_coeff: float = 1.0, + tortoise_tau: float = 0.001, + use_tortoise_for_inference: bool = False, + use_is_correction: bool = True, + **kwargs, + ): + super().__init__(*args, **kwargs) + self.bc_coeff = bc_coeff + self.tortoise_tau = tortoise_tau + self.use_tortoise_for_inference = use_tortoise_for_inference + self.use_is_correction = use_is_correction + self._checkpoint_loaded = False + self._loaded_from_tortoise_agent = False + + # Tortoise (slow EMA of entire hare, frozen during training) + self._tortoise_tstep_encoder = copy.deepcopy(self.tstep_encoder) + self._tortoise_traj_encoder = copy.deepcopy(self.traj_encoder) + self._tortoise_actor = copy.deepcopy(self.actor) + self._tortoise_critics = copy.deepcopy(self.critics) + for m in ( + self._tortoise_tstep_encoder, + self._tortoise_traj_encoder, + self._tortoise_actor, + self._tortoise_critics, + ): + m.requires_grad_(False) + + # Base model (static frozen snapshot for IS correction) + self._base_tstep_encoder = copy.deepcopy(self.tstep_encoder) + self._base_traj_encoder = copy.deepcopy(self.traj_encoder) + self._base_actor = copy.deepcopy(self.actor) + for m in ( + self._base_tstep_encoder, + self._base_traj_encoder, + self._base_actor, + ): + m.requires_grad_(False) + + # BC actor (trainable, estimates pi_data on frozen base representation) + self._bc_actor = copy.deepcopy(self.actor) + + def load_state_dict(self, state_dict, strict=True, **kwargs): + has_tortoise = any(k.startswith(_TORTOISE_PREFIX) for k in state_dict) + self._loaded_from_tortoise_agent = has_tortoise + + if not has_tortoise: + # Loading a standard (non-MetamonFinetuneAgent) checkpoint. + # Fill tortoise, base, and bc_actor keys from the hare weights. + extra = {} + for tort_attr, hare_attr in _TORTOISE_MODULES.items(): + prefix = hare_attr + "." + for k, v in state_dict.items(): + if k.startswith(prefix): + extra[tort_attr + k[len(hare_attr) :]] = v.clone() + for base_attr, hare_attr in _BASE_MODULES.items(): + prefix = hare_attr + "." + for k, v in state_dict.items(): + if k.startswith(prefix): + extra[base_attr + k[len(hare_attr) :]] = v.clone() + # BC actor gets actor weights + actor_prefix = "actor." + for k, v in state_dict.items(): + if k.startswith(actor_prefix): + extra["_bc_actor" + k[len("actor") :]] = v.clone() + state_dict = {**state_dict, **extra} + + super().load_state_dict(state_dict, strict=strict, **kwargs) + + def on_checkpoint_loaded(self, is_resume: bool = False): + if is_resume: + self._checkpoint_loaded = True + return + + if self._loaded_from_tortoise_agent: + # Iterative finetuning: initialise hare from the previous + # round's tortoise (the stable, generalised weights). + for tort_attr, hare_attr in _TORTOISE_MODULES.items(): + self._full_copy(getattr(self, hare_attr), getattr(self, tort_attr)) + # Base model = snapshot of the new hare (== previous tortoise) + for base_attr, hare_attr in _BASE_MODULES.items(): + self._full_copy(getattr(self, base_attr), getattr(self, hare_attr)) + # BC actor restarts from the new hare's actor + self._full_copy(self._bc_actor, self.actor) + # Target networks must match the new hare + self.hard_sync_targets() + # Reset tortoise to the new hare (fresh EMA accumulation) + for tort_attr, hare_attr in _TORTOISE_MODULES.items(): + self._full_copy(getattr(self, tort_attr), getattr(self, hare_attr)) + + # Freeze tortoise and base + for tort_attr in _TORTOISE_MODULES: + getattr(self, tort_attr).requires_grad_(False) + for base_attr in _BASE_MODULES: + getattr(self, base_attr).requires_grad_(False) + + self._checkpoint_loaded = True + + def soft_sync_targets(self): + super().soft_sync_targets() + if self._checkpoint_loaded: + for tort_attr, hare_attr in _TORTOISE_MODULES.items(): + self._ema_copy( + getattr(self, tort_attr), + getattr(self, hare_attr), + tau=self.tortoise_tau, + ) + + def hard_sync_targets(self): + super().hard_sync_targets() + if hasattr(self, "_tortoise_tstep_encoder"): + for tort_attr, hare_attr in _TORTOISE_MODULES.items(): + self._full_copy(getattr(self, tort_attr), getattr(self, hare_attr)) + + @property + def trainable_params(self): + return itertools.chain(super().trainable_params, self._bc_actor.parameters()) + + def get_grad_norms(self) -> dict[str, float]: + norms = super().get_grad_norms() + norms["BC Actor Grad Norm"] = amago.utils.get_grad_norm(self._bc_actor) + return norms + + def get_actions( + self, + obs: dict[str, torch.Tensor], + rl2s: torch.Tensor, + time_idxs: torch.Tensor, + hidden_state: Optional[Any] = None, + sample: bool = True, + ) -> Tuple[torch.Tensor, Any]: + if not self.use_tortoise_for_inference: + return super().get_actions( + obs, rl2s, time_idxs, hidden_state=hidden_state, sample=sample + ) + + with torch.no_grad(): + o = self._tortoise_tstep_encoder(obs=obs, rl2s=rl2s) + s_rep, hidden_state = self._tortoise_traj_encoder( + o, time_idxs=time_idxs, hidden_state=hidden_state + ) + action_dists = self._tortoise_actor( + s_rep, + straight_from_obs={k: obs[k] for k in self.pass_obs_keys_to_actor}, + ) + if sample: + actions = action_dists.sample() + else: + if self.discrete: + actions = torch.argmax(action_dists.probs, dim=-1, keepdim=True) + else: + actions = action_dists.mean + actions = actions[..., -1, :] + dtype = ( + torch.uint8 if (self.discrete or self.multibinary) else torch.float32 + ) + return actions.to(dtype=dtype), hidden_state + + def _compute_log_probs(self, actor_head, s_rep, a_buffer, obs=None): + """Run an actor head and return log pi(a|s) with shape (B, L, G, 1).""" + straight_from_obs = ( + {k: obs[k] for k in self.pass_obs_keys_to_actor} + if obs is not None + else None + ) + a_dist = actor_head(s_rep, straight_from_obs=straight_from_obs) + if self.discrete: + a_dist = DiscreteLikeContinuous(a_dist) + if self.discrete: + logp = a_dist.log_prob(a_buffer).unsqueeze(-1) + elif self.multibinary: + logp = a_dist.log_prob(a_buffer).mean(-1, keepdim=True) + else: + logp = a_dist.log_prob(a_buffer).sum(-1, keepdim=True) + return logp + + def forward(self, batch: Batch, log_step: bool) -> torch.Tensor: + if not self._checkpoint_loaded: + return super().forward(batch, log_step) + + # --- Prepare action buffer (mirrors parent) --- + a = batch.actions + a = a.clamp(0, 1.0) if self.discrete else a.clamp(-1.0, 1.0) + G = len(self.gammas) + a_buffer = F.pad(a, (0, 0, 0, 1), "replicate") + a_buffer = repeat(a_buffer, f"b l a -> b l {G} a") + + # --- Frozen base model forward (pi_base) --- + with torch.no_grad(): + o_base = self._base_tstep_encoder(obs=batch.obs, rl2s=batch.rl2s) + s_rep_base, _ = self._base_traj_encoder( + seq=o_base, time_idxs=batch.time_idxs, hidden_state=None + ) + logp_base = self._compute_log_probs( + self._base_actor, s_rep_base, a_buffer, obs=batch.obs + ) + + # --- BC actor forward (pi_data, trainable head on frozen repr) --- + logp_data = self._compute_log_probs( + self._bc_actor, s_rep_base.detach(), a_buffer, obs=batch.obs + ) + + # --- IS correction (optional) --- + delta_log = None + if self.use_is_correction: + delta_log = logp_base - logp_data.detach() + self.fbc_filter_func.set_delta_log(delta_log) + + # --- Sequence-level filter mask --- + if self.fbc_filter_func.seq_enabled: + seq_mask = (~(batch.rl2s == self.pad_val).all(-1, keepdim=True)).bool() + self.fbc_filter_func.set_seq_mask(seq_mask) + + # --- Standard MultiTaskAgent forward --- + total_loss = super().forward(batch, log_step) + + # --- Auxiliary BC loss for _bc_actor (1:1 with parent masking) --- + bc_loss_elems = -logp_data[:, :-1, ...] + state_mask = (~(batch.rl2s == self.pad_val).all(-1, keepdim=True)).bool()[ + :, 1:, ... + ] + bc_mask = repeat(state_mask, f"b l 1 -> b l {G} 1") + bc_mask = self.edit_actor_mask(batch, bc_loss_elems, bc_mask) + bc_loss = amago.utils.masked_avg(bc_loss_elems, bc_mask) + + total_loss = total_loss + self.bc_coeff * bc_loss + + if log_step: + self.update_info["BC Loss"] = bc_loss.detach() + self.update_info["Log Pi Base (mean)"] = logp_base.mean().detach() + self.update_info["Log Pi Data (mean)"] = logp_data.mean().detach() + f = self.fbc_filter_func + if f.seq_enabled and f._seq_last_weights is not None: + sw = f._seq_last_weights + sp = f._seq_last_percentiles + self.update_info["Seq Filter Weight (mean)"] = sw.mean() + self.update_info["Seq Filter Weight (min)"] = sw.min() + self.update_info["Seq Filter Weight (std)"] = sw.std() + self.update_info["Seq Filter Percentile (mean)"] = sp.mean() + self.update_info["Seq Filter Effective Floor"] = f._seq_last_eff_floor + self.update_info["Seq Filter Buffer Size"] = float(len(f._seq_buffer)) + buf = f._seq_sorted_buf() + n = len(buf) + if n > 1: + self.update_info["Seq Filter Adv @ p_low"] = buf[ + min(int(n * f._seq_p_low), n - 1) + ] + self.update_info["Seq Filter Adv @ p_full"] = buf[ + min(int(n * f._seq_p_full), n - 1) + ] + if delta_log is not None: + dl = delta_log.detach() + dl_clipped = torch.clamp( + dl, + -self.fbc_filter_func.clip_delta, + self.fbc_filter_func.clip_delta, + ) + self.update_info["IS Delta Log (mean)"] = dl.mean() + self.update_info["IS Delta Log (std)"] = dl.std() + self.update_info["IS Delta Log Clipped (mean)"] = dl_clipped.mean() + self.update_info["IS Delta Log Clipped (std)"] = dl_clipped.std() + pct_clipped = ( + (dl.abs() > self.fbc_filter_func.clip_delta).float().mean() + ) + self.update_info["IS Pct Clipped"] = pct_clipped + self.update_info["IS Ratio (mean)"] = torch.exp(dl_clipped).mean() + self.update_info["IS Ratio (std)"] = torch.exp(dl_clipped).std() + + return total_loss diff --git a/metamon/rl/dataset_config.py b/metamon/rl/dataset_config.py new file mode 100644 index 0000000000..d6315381f7 --- /dev/null +++ b/metamon/rl/dataset_config.py @@ -0,0 +1,532 @@ +"""YAML-based dataset configuration for metamon RL training. + +A DatasetConfig captures the full composition of a training dataset: +replay weights, self-play subsets, and custom replay directories. + +For iterative finetuning, configs can reference a previous iteration's +config via ``prev_dataset``, automatically flattening the chain and +computing annealing schedules for smooth data transitions. + +Example base config (training from scratch):: + + # self_play_dset.yaml + replay_weight: 0.05 + self_play: + pac-base: 0.6 + pac-exploratory: 0.35 + # pac-tauros: 0.25 # gen1ou only; add when finetuning Tauros line + # formats omitted → all metamon battle formats + +Example iterative config (finetuning to a new iteration):: + + # my_iter1.yaml + replay_weight: 0.05 + prev_dataset: self_play_dset.yaml + prev_weight: 0.55 + custom_replays: + - dir: /path/to/new_pile + weight: 0.40 + anneal_epochs: 5 +""" + +import collections +import os +from dataclasses import dataclass +from typing import Optional + +import yaml + +import amago +from amago.loading import _DatasetStatus + +import metamon +from metamon.data import ParsedReplayDataset, SelfPlayDataset, MetamonDataset +from metamon.interface import TokenizedObservationSpace, ActionSpace, RewardFunction +from metamon.rl.metamon_to_amago import MetamonAMAGODataset + +DATASET_CONFIG_DIR = os.path.join(os.path.dirname(__file__), "configs", "datasets") + + +@dataclass +class CustomReplaySource: + dir: str + weight: float + + +@dataclass +class DatasetConfig: + """Declarative specification of a training dataset.""" + + replay_weight: float = 0.05 + self_play: Optional[dict[str, float]] = None + custom_replays: Optional[list[CustomReplaySource]] = None + prev_dataset: Optional[str] = None + prev_weight: Optional[float] = None + anneal_epochs: Optional[int] = None + formats: Optional[list[str]] = None + + +@dataclass +class _ResolvedEntry: + """Single dataset source after flattening prev_dataset references.""" + + dataset_type: str # "self_play" or "custom_replay" + identifier: str # subset name or directory path + weight: float + is_new: bool # True = new data for this iteration (annealed from 0) + + +@dataclass +class ResolvedDatasetConfig: + """Fully resolved (flattened) dataset specification.""" + + replay_weight: float + entries: list[_ResolvedEntry] + anneal_epochs: Optional[int] + formats: Optional[list[str]] + + +def _resolve_config_path(path: str) -> str: + """Resolve a config path: absolute paths pass through, relative paths + are looked up in the ``configs/datasets/`` directory.""" + if os.path.isabs(path): + return path + candidate = os.path.join(DATASET_CONFIG_DIR, path) + if os.path.exists(candidate): + return candidate + if os.path.exists(path): + return os.path.abspath(path) + return candidate + + +def load_dataset_config(path: str) -> DatasetConfig: + """Load a DatasetConfig from a YAML file.""" + resolved_path = _resolve_config_path(path) + with open(resolved_path) as f: + raw = yaml.safe_load(f) + + custom_replays = None + if "custom_replays" in raw and raw["custom_replays"]: + custom_replays = [CustomReplaySource(**cr) for cr in raw["custom_replays"]] + + return DatasetConfig( + replay_weight=raw.get("replay_weight", 0.05), + self_play=raw.get("self_play"), + custom_replays=custom_replays, + prev_dataset=raw.get("prev_dataset"), + prev_weight=raw.get("prev_weight"), + anneal_epochs=raw.get("anneal_epochs"), + formats=raw.get("formats"), + ) + + +def save_dataset_config(config: DatasetConfig, path: str) -> None: + """Write a DatasetConfig to a YAML file.""" + data: dict = {"replay_weight": config.replay_weight} + if config.self_play: + data["self_play"] = config.self_play + if config.custom_replays: + data["custom_replays"] = [ + {"dir": cr.dir, "weight": cr.weight} for cr in config.custom_replays + ] + if config.prev_dataset is not None: + data["prev_dataset"] = config.prev_dataset + if config.prev_weight is not None: + data["prev_weight"] = config.prev_weight + if config.anneal_epochs is not None: + data["anneal_epochs"] = config.anneal_epochs + if config.formats is not None: + data["formats"] = config.formats + + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w") as f: + yaml.dump(data, f, default_flow_style=False, sort_keys=False) + + +def resolve_dataset_config(config: DatasetConfig) -> ResolvedDatasetConfig: + """Recursively flatten ``prev_dataset`` references into a flat entry list. + + When ``prev_dataset`` is set: + - Load and resolve the referenced config. + - Scale all of its entries by ``prev_weight``, preserving relative + proportions within the previous iteration's non-replay data. + - Tag them as ``is_new=False`` (existing data). + - ``custom_replays`` on the current config become new data + (``is_new=True``). + + When ``prev_dataset`` is *not* set (base config): + - ``self_play`` and ``custom_replays`` are direct entries, all + tagged ``is_new=False``. + """ + entries: list[_ResolvedEntry] = [] + resolved_formats = config.formats + + if config.prev_dataset is not None: + assert ( + config.prev_weight is not None + ), "prev_weight is required when prev_dataset is set" + prev_config = load_dataset_config(config.prev_dataset) + prev_resolved = resolve_dataset_config(prev_config) + + if resolved_formats is None: + resolved_formats = prev_resolved.formats + + prev_entries = prev_resolved.entries + total_prev = sum(e.weight for e in prev_entries) + + if total_prev > 0: + for e in prev_entries: + entries.append( + _ResolvedEntry( + dataset_type=e.dataset_type, + identifier=e.identifier, + weight=config.prev_weight * (e.weight / total_prev), + is_new=False, + ) + ) + + if config.custom_replays: + for cr in config.custom_replays: + entries.append( + _ResolvedEntry( + dataset_type="custom_replay", + identifier=cr.dir, + weight=cr.weight, + is_new=True, + ) + ) + else: + if config.self_play: + for subset, weight in config.self_play.items(): + entries.append( + _ResolvedEntry( + dataset_type="self_play", + identifier=subset, + weight=weight, + is_new=False, + ) + ) + if config.custom_replays: + for cr in config.custom_replays: + entries.append( + _ResolvedEntry( + dataset_type="custom_replay", + identifier=cr.dir, + weight=cr.weight, + is_new=False, + ) + ) + + return ResolvedDatasetConfig( + replay_weight=config.replay_weight, + entries=entries, + anneal_epochs=config.anneal_epochs, + formats=resolved_formats, + ) + + +def flatten_config(config: DatasetConfig) -> DatasetConfig: + """Resolve a config and convert it back to a flat DatasetConfig. + + The returned config has no ``prev_dataset`` / ``prev_weight`` / + ``anneal_epochs`` -- all entries are inlined with their final effective + weights. Saving this to the checkpoint directory means later iterations + can reference it without recursive resolution. + """ + resolved = resolve_dataset_config(config) + self_play: dict[str, float] = {} + custom_replays: list[CustomReplaySource] = [] + + for entry in resolved.entries: + if entry.dataset_type == "self_play": + self_play[entry.identifier] = entry.weight + elif entry.dataset_type == "custom_replay": + custom_replays.append( + CustomReplaySource(dir=entry.identifier, weight=entry.weight) + ) + + return DatasetConfig( + replay_weight=resolved.replay_weight, + self_play=self_play or None, + custom_replays=custom_replays or None, + formats=resolved.formats, + ) + + +class TransitionMixtureOfDatasets(amago.loading.MixtureOfDatasets): + """MixtureOfDatasets with per-dataset initial/final weight control. + + Used for iterative finetuning: old datasets start at inflated weights + (filling the budget vacated by new datasets at weight 0) and linearly + decrease to their target. New datasets ramp from 0 to their target. + + For base configs (no annealing) this behaves identically to the + standard ``MixtureOfDatasets``. + """ + + def __init__( + self, + datasets: list, + initial_weights: list[float], + final_weights: list[float], + anneal_epochs: int, + dset_name: Optional[str] = None, + ): + super().__init__( + datasets=datasets, + sampling_weights=final_weights, + smooth_sudden_starts=anneal_epochs, + dset_name=dset_name, + ) + self._initial_weights = initial_weights + self._final_weights = final_weights + + def configure_from_experiment(self, experiment): + amago.loading.RLDataset.configure_from_experiment(self, experiment) + for d in self.all_datasets: + d.configure_from_experiment(experiment) + + self._dsets_status = [] + for d, iw, fw in zip( + self.all_datasets, self._initial_weights, self._final_weights + ): + self._dsets_status.append( + _DatasetStatus( + dataset=d, + initial_weight=iw, + final_weight=fw, + epoch_ready=0, + ) + ) + self.update_dset_weights(0) + self._sampling_metrics = collections.defaultdict(int) + + def update_dset_weights(self, epoch: int): + """Bidirectional linear anneal that clamps correctly for both + increasing (new data: 0 -> target) and decreasing (old data: + inflated -> target) weight schedules.""" + self.check_configured() + + self._available_datasets = [] + for status in self._dsets_status: + if self.smooth_sudden_starts is None: + current_weight = status.final_weight + else: + m = ( + status.final_weight - status.initial_weight + ) / self.smooth_sudden_starts + x = epoch - status.epoch_ready + 1 + raw = m * x + status.initial_weight + lo = min(status.initial_weight, status.final_weight) + hi = max(status.initial_weight, status.final_weight) + current_weight = max(lo, min(hi, raw)) + self._available_datasets.append((status.dataset, current_weight)) + + +def config_from_args( + replay_weight: float = 1.0, + self_play_subsets: Optional[list[str]] = None, + self_play_weights: Optional[list[float]] = None, + custom_replay_dir: Optional[str] = None, + custom_replay_weight: float = 0.25, + formats: Optional[list[str]] = None, +) -> DatasetConfig: + """Build a DatasetConfig from individual keyword arguments (backward compat).""" + self_play = None + if self_play_subsets is not None: + if self_play_weights is None: + self_play_weights = [1.0] * len(self_play_subsets) + elif len(self_play_weights) != len(self_play_subsets): + raise ValueError( + f"self_play_weights ({len(self_play_weights)}) must match " + f"self_play_subsets ({len(self_play_subsets)})" + ) + self_play = dict(zip(self_play_subsets, self_play_weights)) + + custom_replays = None + if custom_replay_dir is not None and custom_replay_weight > 0: + custom_replays = [ + CustomReplaySource(dir=custom_replay_dir, weight=custom_replay_weight) + ] + + return DatasetConfig( + replay_weight=replay_weight, + self_play=self_play, + custom_replays=custom_replays, + formats=formats, + ) + + +def build_dataset( + config: DatasetConfig, + obs_space: TokenizedObservationSpace, + action_space: ActionSpace, + reward_function: RewardFunction, + verbose: bool = True, + use_cached_filenames: bool = True, + parsed_replay_dir: Optional[str] = None, + split: Optional[str] = None, + test_fraction: float = 0.1, + split_seed: int = 42, +) -> amago.loading.RLDataset: + """Construct an AMAGO dataset from a :class:`DatasetConfig`. + + Resolves the config (flattening ``prev_dataset`` references), creates + the individual PyTorch datasets, and wraps them in a + ``MixtureOfDatasets`` (or ``TransitionMixtureOfDatasets`` when + annealing is requested). + """ + resolved = resolve_dataset_config(config) + formats = resolved.formats or metamon.config.SUPPORTED_BATTLE_FORMATS + + dset_kwargs = { + "observation_space": obs_space, + "action_space": action_space, + "reward_function": reward_function, + "max_seq_len": None, + "formats": formats, + "verbose": verbose, + "use_cached_filenames": use_cached_filenames, + "split": split, + "test_fraction": test_fraction, + "split_seed": split_seed, + } + + datasets = [] + final_weights = [] + is_new_flags = [] + dataset_info = [] + + # 1. Parsed Replays (human battles) + if resolved.replay_weight > 0: + parsed_dset = ParsedReplayDataset(dset_root=parsed_replay_dir, **dset_kwargs) + datasets.append( + MetamonAMAGODataset( + dset_name="Parsed Replays (Human)", + parsed_replay_dset=parsed_dset, + ) + ) + final_weights.append(resolved.replay_weight) + is_new_flags.append(False) + dataset_info.append( + ("Parsed Replays (Human)", len(parsed_dset), resolved.replay_weight) + ) + + # 2. Resolved entries (self-play + custom replays, possibly from prev configs) + from metamon.data.download import SELF_PLAY_FORMATS, get_self_play_formats + + selfplay_formats = [f for f in formats if f in SELF_PLAY_FORMATS] + selfplay_dset_kwargs = {**dset_kwargs, "formats": selfplay_formats} + + for entry in resolved.entries: + if entry.dataset_type == "self_play": + subset_formats = [ + f + for f in selfplay_formats + if f in get_self_play_formats(entry.identifier) + ] + if not subset_formats: + if verbose: + print( + f"Skipping self-play subset {entry.identifier!r}: " + f"no formats overlap with {selfplay_formats}" + ) + continue + sp_dset = SelfPlayDataset( + subset=entry.identifier, + **{**selfplay_dset_kwargs, "formats": subset_formats}, + ) + name = f"Self-Play ({entry.identifier})" + datasets.append( + MetamonAMAGODataset(dset_name=name, parsed_replay_dset=sp_dset) + ) + final_weights.append(entry.weight) + is_new_flags.append(entry.is_new) + dataset_info.append((name, len(sp_dset), entry.weight)) + + elif entry.dataset_type == "custom_replay": + cr_dset = MetamonDataset(dset_root=entry.identifier, **dset_kwargs) + label = os.path.basename(entry.identifier.rstrip("/")) + name = f"Custom Replays ({label})" + datasets.append( + MetamonAMAGODataset(dset_name=name, parsed_replay_dset=cr_dset) + ) + final_weights.append(entry.weight) + is_new_flags.append(entry.is_new) + dataset_info.append((name, len(cr_dset), entry.weight)) + + if not datasets: + raise ValueError("No datasets configured! Check your dataset config YAML.") + + # Renormalize + total_weight = sum(final_weights) + norm_weights = [w / total_weight for w in final_weights] + + if verbose: + print("\n" + "=" * 70) + print("TRAINING DATASET SUMMARY") + print("=" * 70) + print(f"{'Dataset':<40} {'Files':>10} {'Weight':>8} {'Norm':>8}") + print("-" * 70) + total_files = 0 + for (name, num_files, raw_weight), nw in zip(dataset_info, norm_weights): + total_files += num_files + print(f"{name:<40} {num_files:>10,} {raw_weight:>8.3f} {nw:>7.1%}") + print("-" * 70) + print(f"{'TOTAL':<40} {total_files:>10,} {total_weight:>8.3f} {'100.0%':>8}") + print("=" * 70 + "\n") + + # Build the mixture + if len(datasets) == 1: + return datasets[0] + + has_new = any(is_new_flags) + replay_idx = 0 if resolved.replay_weight > 0 else None + if has_new and resolved.anneal_epochs is not None: + # Full-transition annealing: + # - Replay weight stays constant + # - Old datasets start at inflated weights (filling budget vacated by new=0) + # - New datasets ramp from 0 to target + replay_norm = norm_weights[replay_idx] if replay_idx is not None else 0.0 + old_nonreplay_total = sum( + w + for i, (w, is_new) in enumerate(zip(norm_weights, is_new_flags)) + if not is_new and i != replay_idx + ) + nonreplay_budget = 1.0 - replay_norm + + initial_weights = [] + for i, (nw, is_new) in enumerate(zip(norm_weights, is_new_flags)): + if is_new: + initial_weights.append(0.0) + elif i == replay_idx: + initial_weights.append(replay_norm) + else: + initial_weights.append( + nonreplay_budget * (nw / old_nonreplay_total) + if old_nonreplay_total > 0 + else nw + ) + + if verbose: + print( + f" Annealing over {resolved.anneal_epochs} epochs: " + f"old data {sum(iw for iw, f in zip(initial_weights, is_new_flags) if not f):.1%}" + f" → {sum(nw for nw, f in zip(norm_weights, is_new_flags) if not f):.1%}" + ) + print( + f" New data 0.0%" + f" → {sum(nw for nw, f in zip(norm_weights, is_new_flags) if f):.1%}\n" + ) + + return TransitionMixtureOfDatasets( + datasets=datasets, + initial_weights=initial_weights, + final_weights=norm_weights, + anneal_epochs=resolved.anneal_epochs, + ) + else: + return amago.loading.MixtureOfDatasets( + datasets=datasets, + sampling_weights=norm_weights, + ) diff --git a/metamon/rl/evaluate/README.md b/metamon/rl/evaluate/README.md new file mode 100644 index 0000000000..55be927d62 --- /dev/null +++ b/metamon/rl/evaluate/README.md @@ -0,0 +1,295 @@ +# Evaluation + +`metamon.rl.evaluate` runs pretrained models against various opponents, and provides launchers to automatically manage large-scale evaluations across many models and GPUs. + +## Evaluate (`python -m metamon.rl.evaluate`) + +The main evaluation script. Runs one pretrained model against a chosen opponent type. + +### Eval Types + +#### `heuristic` — Built-in Baselines + +Play against the 6 heuristic baselines from the paper (RandomBaseline, Gen1BossAI, Grunt, GymLeader, PokeEnvHeuristic, EmeraldKaizo): + +```bash +python -m metamon.rl.evaluate \ + --eval_type heuristic \ + --agent Kakuna \ + --gens 1 \ + --formats ou \ + --total_battles 100 +``` + +#### `il` — IL Baseline + +Play against the BaseRNN imitation learning policy: + +```bash +python -m metamon.rl.evaluate --eval_type il --agent Kakuna --gens 1 --formats ou --total_battles 50 +``` + +#### `ladder` — Local Showdown Ladder + +Queue for battles on your local Showdown server against any other online agents or humans: + +```bash +python -m metamon.rl.evaluate \ + --eval_type ladder \ + --agent Kakuna \ + --gens 1 \ + --formats ou \ + --total_battles 50 \ + --username MyUsername \ + --team_set competitive +``` + +#### `pokeagent` — PokéAgent Challenge Ladder + +Submit to the PokéAgent Challenge practice ladder (requires a registered username and password): + +```bash +python -m metamon.rl.evaluate \ + --eval_type pokeagent \ + --agent Kakuna \ + --gens 9 \ + --formats ou \ + --total_battles 50 \ + --username RegisteredName \ + --password MyPassword +``` + +#### `challenge` — Head-to-Head by Username + +Send or accept challenges to a specific opponent. Launch two instances with opposite `--role` and matching usernames: + +```bash +# Terminal 1 (acceptor — start first): +python -m metamon.rl.evaluate --eval_type challenge --agent Kakuna \ + --username PlayerA --opponent_username PlayerB --role acceptor \ + --gens 1 --formats ou --total_battles 50 + +# Terminal 2 (challenger — start second): +python -m metamon.rl.evaluate --eval_type challenge --agent SyntheticRLV2 \ + --username PlayerB --opponent_username PlayerA --role challenger \ + --gens 1 --formats ou --total_battles 50 +``` + +The acceptor must be online before the challenger starts sending challenges. + +### Common Flags + +| Flag | Default | Description | +|------|---------|-------------| +| `--agent` | (required) | Pretrained model name (see `metamon/rl/pretrained.py`) | +| `--gens` | `1` | Pokémon generation(s) | +| `--formats` | `ou` | Battle tier(s) (ou, uu, nu, ubers) | +| `--total_battles` | `10` | Number of battles | +| `--checkpoints` | model default | Checkpoint epoch(s) to evaluate | +| `--temperature` | `1.0` | Action sampling temperature | +| `--team_set` | `competitive` | Team set name | +| `--battle_backend` | model default | `metamon`, `pokeagent`, or `poke-env` | +| `--save_trajectories_to` | off | Save replays in parsed format | +| `--save_results_to` | off | Save per-battle result logs | +| `--team_preview_checkpoint` | off | Team preview model for Gen 9 | + +### Custom Models + +To eval a custom agent trained from scratch (`rl.train`), create a `LocalPretrainedModel`. `LocalFinetunedModel` provides quick setup for models finetuned with `rl.finetune`. See [`examples/evaluate_custom_models.py`](../../../examples/evaluate_custom_models.py) for examples. + +--- + +## Auto-Launchers + +Utilities to automatically launch and manage evaluations across multiple models and GPUs. Define policies and matchups in a YAML config, then let the launcher handle subprocess orchestration, GPU assignment, and crash recovery. + +All modes support `--dry_run` to preview what will be launched without actually running anything. + +### Head-to-Head (`h2h`) + +Play every pair of policies against each other. Produces a win matrix. + +```bash +python -m metamon.rl.evaluate.h2h \ + --config metamon/rl/evaluate/h2h/example_config.yaml \ + --gpus 0 1 2 3 \ + --output_dir ./h2h_results \ + --dry_run +``` + +#### Config + +See `metamon/rl/evaluate/h2h/example_config.yaml` (paper + PokéAgent policies). Minimal shape: + +```yaml +battle_format: gen1ou +battles_per_matchup: 50 + +defaults: + team_set: competitive + battle_backend: metamon + checkpoint: null + temperature: 1.0 + +policies: + SmallRL: {} + SyntheticRLV2: + checkpoint: 40 + Kadabra: {} + Alakazam: + variants: + - { checkpoint: null, team_set: competitive } + - { checkpoint: 8, team_set: modern_replays_v2 } +``` + +Generates all unordered pairs (N choose 2). Results are saved to `matchup_results.jsonl` (crash recovery) and `win_matrix.csv`. + +#### Flags + +| Flag | Default | Description | +|------|---------|-------------| +| `--max_concurrent` | #GPUs | Max matchups running in parallel | +| `--timeout` | 3600 | Seconds before killing a matchup | +| `--acceptor_startup_delay` | 10 | Seconds to wait for acceptor before starting challenger | +| `--save_trajectories` | off | Save trajectory files per matchup | +| `--verbose` | off | Stream subprocess output in real-time | + +--- + +### Sweep + +Evaluate one policy across a parameter grid against a fixed opponent. Useful for checkpoint sweeps, temperature tuning, etc. + +```bash +python -m metamon.rl.evaluate.sweep \ + --config metamon/rl/evaluate/sweep/example_config.yaml \ + --gpus 0 1 \ + --output_dir ./sweep_results \ + --dry_run +``` + +Configs may use `${var}` / `${var:default}` placeholders; undeclared variables become extra CLI flags (see `sweep/temperature_sweep_self_play.yaml`). + +#### Config + +See `metamon/rl/evaluate/sweep/example_config.yaml`. Minimal shape: + +```yaml +battle_format: gen1ou +battles_per_matchup: 50 + +defaults: + team_set: competitive + battle_backend: metamon + +opponent: + model_name: Kadabra + checkpoint: null + temperature: 1.0 + +sweep: + model_name: SyntheticRLV2 + checkpoints: "range(40, 50, 2)" + temperatures: [1.0, 1.5, 2.0] +``` + +`checkpoints` and `temperatures` accept an explicit list or a shorthand string: +- **`"range(start, stop, step)"`** — Python-style, stop exclusive. Best for integer checkpoints. +- **`"linspace(start, stop, n)"`** — N evenly-spaced points, both endpoints inclusive. Best for float temperatures. + +The launcher generates a cartesian product of `checkpoints × temperatures`, each played against the fixed opponent. Same flags as h2h. + +--- + +### Ladder Self-Play + +Put multiple agents on the local Showdown ladder. They battle whoever they match with via random matchmaking. Runs continuously (restart on crash) until interrupted. This is what we used to run the PokéAgent Challenge and generate self-play data in batches. + +```bash +python -m metamon.rl.evaluate.ladder_self_play \ + --config metamon/rl/evaluate/ladder_self_play/example_config.yaml \ + --format gen1ou \ + --gpus 0 1 2 3 \ + --save_trajectories_to ./trajectories \ + --dry_run +``` + +#### Config + +See `metamon/rl/evaluate/ladder_self_play/example_config.yaml`. Ladder configs also support `"range(...)"`, `"linspace(...)"`, and `{weighted: {...}}` for checkpoints, temperatures, and team sets (via `evaluate/common.py`). + +```yaml +defaults: + team_set: competitive + battle_backend: metamon + checkpoints: [null] + temperatures: [1.0, 1.25, 1.5, 2.0] + num_agents: 1 + +agents: + SynRLV2: + model_name: SyntheticRLV2 + num_agents: 2 + + Kadabra: + model_name: Kadabra + num_agents: 2 +``` + +Each agent instance randomly samples a checkpoint and temperature from the list on each launch, which is a way to add variety without increasing the number of parallel agents. + +#### Flags + +| Flag | Default | Description | +|------|---------|-------------| +| `--n_challenges` | 50 | Battles per agent before restart | +| `--restart_delay` | 80 | Seconds between restarts | +| `--timeout` | 2700 | Seconds before killing a run | +| `--verbose` | off | Stream subprocess output | + +--- + +## Config Syntax Reference + +### Policy specification (h2h / sweep) + +The outer key is used as `model_name` by default: + +```yaml +policies: + Kadabra: {} # model_name = "Kadabra", all defaults + SyntheticRLV2: # model_name = "SyntheticRLV2" + checkpoint: 40 # override checkpoint + MyAlias: # display name in win matrix + model_name: Alakazam2 # actual model to load + checkpoint: 48 +``` + +### Variants + +Expand one model into multiple configs: + +```yaml +policies: + Alakazam2: + variants: + - { team_set: modern_replays_v2 } + - { team_set: competitive } + - { team_set: competitive, checkpoint: 20 } +``` + +Generates `Alakazam2-1`, `Alakazam2-2`, `Alakazam2-3` — all loading `Alakazam2` with different settings. + +### Defaults + +Any field in `defaults:` is inherited by all policies unless overridden: + +```yaml +defaults: + team_set: modern_replays_v2 + battle_backend: metamon + checkpoint: null + temperature: 1.0 +``` + +This is all kinda confusing and a work in progress, so make sure to check `--dry_run` on your command to see if the results will match what you expect. \ No newline at end of file diff --git a/metamon/rl/evaluate/__init__.py b/metamon/rl/evaluate/__init__.py new file mode 100644 index 0000000000..44ebbc2843 --- /dev/null +++ b/metamon/rl/evaluate/__init__.py @@ -0,0 +1,6 @@ +from metamon.rl.evaluate.__main__ import ( + pretrained_vs_baselines, + pretrained_vs_local_ladder, + pretrained_vs_pokeagent_ladder, + pretrained_vs_challenge, +) diff --git a/metamon/rl/evaluate.py b/metamon/rl/evaluate/__main__.py similarity index 80% rename from metamon/rl/evaluate.py rename to metamon/rl/evaluate/__main__.py index 307eb51672..6710b9ed69 100644 --- a/metamon/rl/evaluate.py +++ b/metamon/rl/evaluate/__main__.py @@ -15,9 +15,9 @@ make_baseline_env, make_local_ladder_env, make_pokeagent_ladder_env, + make_challenge_env, ) - HEURISTIC_COMPOSITE_BASELINES = [ "PokeEnvHeuristic", "Gen1BossAI", @@ -40,7 +40,7 @@ def pretrained_vs_baselines( battle_backend: str = "metamon", log_to_wandb: bool = False, save_trajectories_to: Optional[str] = None, - save_team_results_to: Optional[str] = None, + save_results_to: Optional[str] = None, baselines: Optional[List[str]] = None, team_preview_model: Optional[TeamPreviewModel] = None, ) -> Dict[str, Any]: @@ -63,7 +63,7 @@ def pretrained_vs_baselines( action_space=pretrained_model.action_space, reward_function=pretrained_model.reward_function, save_trajectories_to=save_trajectories_to, - save_team_results_to=save_team_results_to, + save_results_to=save_results_to, battle_backend=battle_backend, team_set=team_set, opponent_type=get_baseline(opponent), @@ -131,7 +131,7 @@ def pretrained_vs_local_ladder( battle_backend: str = "metamon", action_temperature: float = 1.0, save_trajectories_to: Optional[str] = None, - save_team_results_to: Optional[str] = None, + save_results_to: Optional[str] = None, log_to_wandb: bool = False, team_preview_model: Optional[TeamPreviewModel] = None, ) -> Dict[str, Any]: @@ -161,7 +161,7 @@ def pretrained_vs_local_ladder( battle_backend=battle_backend, battle_format=battle_format, save_trajectories_to=save_trajectories_to, - save_team_results_to=save_team_results_to, + save_results_to=save_results_to, ) @@ -177,7 +177,7 @@ def pretrained_vs_pokeagent_ladder( battle_backend: str = "metamon", action_temperature: float = 1.0, save_trajectories_to: Optional[str] = None, - save_team_results_to: Optional[str] = None, + save_results_to: Optional[str] = None, log_to_wandb: bool = False, team_preview_model: Optional[TeamPreviewModel] = None, ) -> Dict[str, Any]: @@ -189,8 +189,8 @@ def pretrained_vs_pokeagent_ladder( that are logged into the PokéAgent Challenge ladder. Once eval begins, you can watch battles in real time by visiting - http://pokeagentshowdown.com.insecure.psim.us and clicking "Watch a Battle". - Visit http://pokeagentshowdown.com.insecure.psim.us/ladder to see the live + https://battling.pokeagentchallenge.com and clicking "Watch a Battle". + Visit https://battling.pokeagentchallenge.com/ladder to see the live leaderboard. """ return _pretrained_on_ladder( @@ -208,7 +208,60 @@ def pretrained_vs_pokeagent_ladder( battle_backend=battle_backend, battle_format=battle_format, save_trajectories_to=save_trajectories_to, - save_team_results_to=save_team_results_to, + save_results_to=save_results_to, + ) + + +def pretrained_vs_challenge( + pretrained_model: PretrainedModel, + username: str, + opponent_username: str, + role: str, + battle_format: str, + team_set: metamon.env.TeamSet, + total_battles: int, + avatar: Optional[str] = None, + checkpoint: Optional[int] = None, + battle_backend: str = "metamon", + action_temperature: float = 1.0, + save_trajectories_to: Optional[str] = None, + save_results_to: Optional[str] = None, + log_to_wandb: bool = False, + team_preview_model: Optional[TeamPreviewModel] = None, +) -> Dict[str, Any]: + """Evaluate a pretrained model by challenging a specific opponent by username. + + This creates a deterministic head-to-head matchup between two agents. + One side must be the "challenger" (sends challenges) and the other the + "acceptor" (accepts challenges). Launch two instances of this command + with opposite roles and matching usernames. + + Example (two terminals): + Terminal 1 (challenger): + python -m metamon.rl.evaluate --eval_type challenge --agent ModelA \\ + --username PlayerA --opponent_username PlayerB --role challenger + + Terminal 2 (acceptor): + python -m metamon.rl.evaluate --eval_type challenge --agent ModelB \\ + --username PlayerB --opponent_username PlayerA --role acceptor + """ + return _pretrained_on_ladder( + pretrained_model=pretrained_model, + make_ladder=make_challenge_env, + total_battles=total_battles, + checkpoint=checkpoint, + log_to_wandb=log_to_wandb, + action_temperature=action_temperature, + team_preview_model=team_preview_model, + player_username=username, + opponent_username=opponent_username, + role=role, + player_avatar=avatar, + player_team_set=team_set, + battle_backend=battle_backend, + battle_format=battle_format, + save_trajectories_to=save_trajectories_to, + save_results_to=save_results_to, ) @@ -249,6 +302,16 @@ def _get_default_eval(args, base_eval_kwargs): } ) return pretrained_vs_pokeagent_ladder + elif args.eval_type == "challenge": + base_eval_kwargs.update( + { + "username": args.username, + "opponent_username": args.opponent_username, + "role": args.role, + "avatar": args.avatar, + } + ) + return pretrained_vs_challenge else: raise ValueError(f"Invalid evaluation type: {args.eval_type}") @@ -304,7 +367,7 @@ def _run_default_evaluation(args) -> Dict[str, List[Dict[str, Any]]]: "battle_backend": backend, "save_trajectories_to": args.save_trajectories_to, "action_temperature": args.temperature, - "save_team_results_to": args.save_team_results_to, + "save_results_to": args.save_results_to, "log_to_wandb": args.log_to_wandb, "team_preview_model": team_preview_model, } @@ -325,12 +388,14 @@ def add_cli(parser): parser.add_argument( "--eval_type", required=True, - choices=["heuristic", "il", "ladder", "pokeagent"], + choices=["heuristic", "il", "ladder", "pokeagent", "challenge"], help=( "Type of evaluation to perform. 'heuristic' will run against 6 " "heuristic baselines, 'il' will run against a BCRNN baseline, " "'ladder' will queue the agent for battles on your self-hosted Showdown ladder, " - "'pokeagent' will submit the agent to the NeurIPS 2025 PokéAgent Challenge ladder!" + "'pokeagent' will submit the agent to the NeurIPS 2025 PokéAgent Challenge ladder, " + "'challenge' will send/accept challenges to a specific opponent by username " + "(launch two instances with opposite --role for head-to-head)." ), ) parser.add_argument( @@ -373,6 +438,24 @@ def add_cli(parser): default=None, help="Password for the Showdown server.", ) + parser.add_argument( + "--opponent_username", + default=None, + help=( + "Username of the opponent to challenge (only for --eval_type challenge). " + "Launch two instances with opposite --role and matching usernames." + ), + ) + parser.add_argument( + "--role", + default="challenger", + choices=["challenger", "acceptor"], + help=( + "Role in a challenge matchup (only for --eval_type challenge). " + "'challenger' sends challenges to --opponent_username, " + "'acceptor' waits for and accepts challenges from --opponent_username." + ), + ) parser.add_argument( "--avatar", default="red-gen1main", @@ -410,9 +493,9 @@ def add_cli(parser): help="Save replays (in the parsed replay format) to a directory.", ) parser.add_argument( - "--save_team_results_to", + "--save_results_to", default=None, - help="Save records of team selection, opponent, and outcome.", + help="Directory to save per-battle result logs.", ) parser.add_argument( "--log_to_wandb", diff --git a/metamon/rl/evaluate/common.py b/metamon/rl/evaluate/common.py new file mode 100644 index 0000000000..3520a23b0c --- /dev/null +++ b/metamon/rl/evaluate/common.py @@ -0,0 +1,550 @@ +""" +Shared utilities for auto-evaluation launchers (h2h, sweep, ladder_self_play). + +Provides: + - PolicySpec / MatchupSpec: data classes for describing policies and matchups + - GPU distribution + - Subprocess management for running matchup workers + - Config parsing helpers +""" + +import gc +import hashlib +import itertools +import os +import random +import re +import subprocess +import time +import yaml +from argparse import ArgumentParser +from dataclasses import dataclass, field, asdict +from typing import Dict, List, Optional, Tuple + + +@dataclass +class PolicySpec: + """A fully-specified policy configuration for evaluation. + + Attributes: + name: Display name (used in win matrix labels, usernames, etc.) + model_name: Pretrained model identifier (must match a key in pretrained.py). + checkpoint: Checkpoint epoch to load (None = model default). + temperature: Action sampling temperature. + team_set: Team set name. + battle_backend: Showdown state-parsing backend. + """ + + name: str + model_name: str + checkpoint: Optional[int] + temperature: float + team_set: str + battle_backend: str + + @property + def short_label(self) -> str: + """Compact label for display (e.g. matrix headers).""" + parts = [self.name] + if self.checkpoint is not None: + parts.append(f"ckpt{self.checkpoint}") + if self.temperature != 1.0: + parts.append(f"t{self.temperature}") + if self.team_set: + parts.append(self.team_set) + return "-".join(parts) + + @property + def unique_key(self) -> str: + """Deterministic key that uniquely identifies this policy configuration.""" + return f"{self.model_name}_ckpt{self.checkpoint}_t{self.temperature}_{self.team_set}_{self.battle_backend}" + + +@dataclass +class MatchupSpec: + """A single head-to-head matchup to run. + + Attributes: + policy_a: The first policy (will be the challenger). + policy_b: The second policy (will be the acceptor). + n_battles: Number of battles to play. + battle_format: Pokemon Showdown battle format (e.g. "gen1ou"). + matchup_id: Deterministic unique identifier for crash recovery. + """ + + policy_a: PolicySpec + policy_b: PolicySpec + n_battles: int + battle_format: str + matchup_id: str = "" + + def __post_init__(self): + if not self.matchup_id: + self.matchup_id = ( + f"{self.policy_a.unique_key}__vs__{self.policy_b.unique_key}" + ) + + +# --------------------------------------------------------------------------- +# Config templating — ${var} and ${var:default} placeholders +# --------------------------------------------------------------------------- + +_TEMPLATE_RE = re.compile(r"\$\{(\w+)(?::([^}]*))?\}") + + +def discover_template_vars(config_path: str) -> Dict[str, Optional[str]]: + """Scan a config file for ``${var}`` and ``${var:default}`` placeholders. + + Only non-comment lines are inspected (lines whose first non-whitespace + character is ``#`` are skipped). + + Returns: + Ordered dict mapping variable names to their default value + (``None`` if no default was specified → the variable is required). + """ + with open(config_path, "r") as f: + lines = f.readlines() + found: Dict[str, Optional[str]] = {} + for line in lines: + stripped = line.lstrip() + if stripped.startswith("#"): + continue + # also ignore inline comments (everything after ' #') + code_part = line.split(" #")[0] + for m in _TEMPLATE_RE.finditer(code_part): + name, default = m.group(1), m.group(2) + if name not in found: + found[name] = default + return found + + +def resolve_templates(text: str, values: Dict[str, str]) -> str: + """Replace ``${var}`` / ``${var:default}`` in *text* with provided values. + + YAML comment lines (starting with ``#``) are left untouched. + """ + + def _replacer(m: re.Match) -> str: + name, default = m.group(1), m.group(2) + if name in values: + return str(values[name]) + if default is not None: + return default + raise ValueError(f"Template variable ${{{name}}} has no value and no default") + + resolved_lines = [] + for line in text.splitlines(keepends=True): + stripped = line.lstrip() + if stripped.startswith("#"): + resolved_lines.append(line) + else: + # resolve only the code portion (before inline comment) + resolved_lines.append(_TEMPLATE_RE.sub(_replacer, line)) + return "".join(resolved_lines) + + +def add_template_args( + parser: ArgumentParser, config_path: Optional[str] +) -> Dict[str, Optional[str]]: + """Discover template variables in *config_path* and add them to *parser*. + + Call this **after** adding all standard arguments but **before** + ``parser.parse_args()``. Each ``${var}`` becomes ``--var`` (required); + each ``${var:default}`` becomes ``--var`` with the given default. + + Returns: + The discovered variables dict (name → default-or-None). + """ + if not config_path or not os.path.exists(config_path): + return {} + tvars = discover_template_vars(config_path) + for name, default in tvars.items(): + parser.add_argument( + f"--{name}", + required=(default is None), + default=default, + help=f"Template variable (from config). " + + ("Required." if default is None else f"Default: {default}"), + ) + return tvars + + +def get_template_values( + args, template_vars: Dict[str, Optional[str]] +) -> Dict[str, str]: + """Extract resolved template variable values from parsed args.""" + return {name: str(getattr(args, name)) for name in template_vars} + + +# --------------------------------------------------------------------------- +# Unified value-list expansion and weighted random choice +# --------------------------------------------------------------------------- + +_RANGE_RE = re.compile( + r"^range\(\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*(?:,\s*(-?[\d.]+))?\s*\)$" +) +_LINSPACE_RE = re.compile( + r"^linspace\(\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*,\s*(\d+)\s*\)$" +) + + +def expand_value_list(value) -> list: + """Expand a config value into a flat Python list. + + Supports the following forms in YAML configs: + + * ``"range(start, stop[, step])"`` — integer or float range, stop exclusive. + * ``"linspace(start, stop, n)"`` — n evenly-spaced floats, endpoints inclusive. + * ``[1, 2, 3]`` — returned as-is. + * ``42`` / ``null`` — wrapped in a single-element list. + + The string forms are the canonical shorthand used by sweep, h2h, and + ladder-self-play configs. Plain lists are always accepted as a fall-through + so that hand-enumerated values never need special escaping. + """ + if isinstance(value, str): + stripped = value.strip() + + m = _RANGE_RE.match(stripped) + if m: + start_s, stop_s, step_s = m.group(1), m.group(2), m.group(3) + if any("." in s for s in (start_s, stop_s, step_s or "0")): + start, stop = float(start_s), float(stop_s) + step = float(step_s) if step_s else 1.0 + result, v = [], start + while v < stop: + result.append(round(v, 6)) + v += step + return result + else: + return list( + range(int(start_s), int(stop_s), int(step_s) if step_s else 1) + ) + + m = _LINSPACE_RE.match(stripped) + if m: + start, stop, n = float(m.group(1)), float(m.group(2)), int(m.group(3)) + if n < 1: + raise ValueError(f"linspace n must be >= 1, got {n}") + if n == 1: + return [round(start, 6)] + step = (stop - start) / (n - 1) + return [round(start + i * step, 6) for i in range(n)] + + # Plain string (e.g. a team-set name) — treat as a scalar. + return [value] + + if isinstance(value, list): + return value + return [value] + + +def random_choice(value): + """Draw a single random element from a config value. + + Accepts all forms understood by :func:`expand_value_list`, plus one + additional form for non-uniform sampling: + + * ``{weighted: {option_a: w1, option_b: w2, ...}}`` + — draws from the keys with probability proportional to the weights. + + This lets configs express e.g.:: + + team_set: + weighted: + elite_sets_filled: 4 + competitive: 1 + + instead of repeating ``elite_sets_filled`` four times in a plain list. + """ + if isinstance(value, dict) and "weighted" in value: + mapping = value["weighted"] + population = list(mapping.keys()) + weights = [float(mapping[k]) for k in population] + return random.choices(population, weights=weights, k=1)[0] + return random.choice(expand_value_list(value)) + + +def _format_value_for_display(value) -> str: + """Human-readable summary of a raw config value (for preview tables).""" + if isinstance(value, dict) and "weighted" in value: + parts = ", ".join(f"{k}({v})" for k, v in value["weighted"].items()) + return f"weighted({parts})" + lst = expand_value_list(value) + if len(lst) == 1: + return str(lst[0]) + return f"[{lst[0]}…{lst[-1]}] ({len(lst)})" + + +def load_config( + config_path: str, template_vars: Optional[Dict[str, str]] = None +) -> dict: + """Load and validate a YAML config file. + + If *template_vars* is provided, ``${var}`` / ``${var:default}`` + placeholders in the raw YAML text are resolved before parsing. + """ + with open(config_path, "r") as f: + text = f.read() + if template_vars: + text = resolve_templates(text, template_vars) + raw = yaml.safe_load(text) + if not isinstance(raw, dict): + raise ValueError(f"Config file must be a YAML mapping, got {type(raw)}") + return raw + + +def merge_defaults(defaults: dict, overrides: dict) -> dict: + """Merge per-policy overrides on top of defaults.""" + merged = {**defaults} + merged.update({k: v for k, v in overrides.items() if v is not None}) + return merged + + +def build_policy_spec(name: str, config: dict, defaults: dict) -> PolicySpec: + """Build a PolicySpec from a per-policy config dict + defaults. + + The policy name is used as model_name unless model_name is explicitly set. + """ + merged = merge_defaults(defaults, config) + return PolicySpec( + name=name, + model_name=merged.get("model_name", name), + checkpoint=merged.get("checkpoint", None), + temperature=float(merged.get("temperature", 1.0)), + team_set=merged.get("team_set", "competitive"), + battle_backend=merged.get("battle_backend", "metamon"), + ) + + +def expand_variants(name: str, config: dict, defaults: dict) -> List[PolicySpec]: + """Expand a policy entry that may have a 'variants' list. + + Without variants: returns a single PolicySpec. + With variants: returns one PolicySpec per variant, named {name}-1, {name}-2, ... + Each variant dict is merged on top of the base config (which is merged on top of defaults). + """ + variants = config.get("variants", None) + if variants is None: + return [build_policy_spec(name, config, defaults)] + + base = {k: v for k, v in config.items() if k != "variants"} + if "model_name" not in base: + base["model_name"] = name + policies = [] + for i, variant in enumerate(variants, 1): + variant_config = {**base, **variant} + variant_name = f"{name}-{i}" + policies.append(build_policy_spec(variant_name, variant_config, defaults)) + return policies + + +# --------------------------------------------------------------------------- +# GPU distribution +# --------------------------------------------------------------------------- + + +def distribute_across_gpus(items: List, gpus: List[int]) -> Dict[int, List]: + """Round-robin distribute items across GPUs.""" + assignments = {gpu: [] for gpu in gpus} + for i, item in enumerate(items): + gpu_id = gpus[i % len(gpus)] + assignments[gpu_id].append(item) + return assignments + + +# --------------------------------------------------------------------------- +# Subprocess management +# --------------------------------------------------------------------------- + + +def run_subprocess( + cmd: List[str], + gpu_id: int, + timeout: int = 3600, + verbose: bool = False, + cwd: Optional[str] = None, +) -> subprocess.CompletedProcess: + """Run a subprocess on a specific GPU with timeout handling. + + Args: + cmd: Command and arguments. + gpu_id: GPU to assign via CUDA_VISIBLE_DEVICES. + timeout: Seconds before killing the process. + verbose: If True, stream stdout/stderr in real-time. + cwd: Working directory for the subprocess. + + Returns: + CompletedProcess with returncode (and captured output if not verbose). + """ + env = os.environ.copy() + env["CUDA_VISIBLE_DEVICES"] = str(gpu_id) + + kwargs = dict(env=env, cwd=cwd, text=True) + if not verbose: + kwargs.update(stdout=subprocess.PIPE, stderr=subprocess.PIPE, bufsize=1) + + process = None + try: + process = subprocess.Popen(cmd, **kwargs) + process.wait(timeout=timeout) + return subprocess.CompletedProcess( + cmd, + process.returncode, + stdout=process.stdout.read() if not verbose and process.stdout else "", + stderr=process.stderr.read() if not verbose and process.stderr else "", + ) + except subprocess.TimeoutExpired: + if process: + process.kill() + process.wait() + return subprocess.CompletedProcess( + cmd, returncode=-1, stdout="", stderr="TIMEOUT" + ) + except Exception as e: + if process and process.poll() is None: + process.kill() + process.wait() + return subprocess.CompletedProcess(cmd, returncode=-1, stdout="", stderr=str(e)) + finally: + if process: + for stream in (process.stdout, process.stderr): + if stream: + try: + stream.close() + except Exception: + pass + del process + gc.collect() + + +@dataclass +class MatchupPairResult: + """Return value from run_matchup_pair.""" + + challenger_proc: subprocess.CompletedProcess + acceptor_proc: subprocess.CompletedProcess + matchup_dir: str + challenger_username: str + + +def run_matchup_pair( + matchup: MatchupSpec, + gpu_a: int, + gpu_b: int, + output_dir: str, + timeout: int = 3600, + acceptor_startup_delay: float = 5.0, + verbose: bool = False, + save_trajectories: bool = False, +) -> MatchupPairResult: + """Run both sides of a matchup as coordinated subprocesses. + + Launches acceptor first, waits for it to come online, then launches + challenger. Both sides write per-battle CSV logs to a shared + ``results/`` directory inside the matchup directory (handled by + ``PokeEnvWrapper``). + """ + # Generate unique usernames for this matchup. + # Showdown caps usernames at 18 chars. Use a hash of the matchup_id to + # guarantee uniqueness even when many matchups share a long common prefix. + short_hash = hashlib.md5(matchup.matchup_id.encode()).hexdigest()[:8] + username_a = f"h2h-A-{short_hash}" # 14 chars + username_b = f"h2h-B-{short_hash}" # 14 chars + + matchup_dir = os.path.join(output_dir, matchup.matchup_id) + os.makedirs(matchup_dir, exist_ok=True) + results_dir = os.path.join(matchup_dir, "results") + + serve_script = os.path.join( + os.path.dirname(os.path.abspath(__file__)), "serve_matchup.py" + ) + + def _build_cmd( + policy: PolicySpec, username: str, opponent_username: str, role: str + ): + cmd = [ + "python", + serve_script, + "--model_name", + policy.model_name, + "--username", + username, + "--opponent_username", + opponent_username, + "--role", + role, + "--format", + matchup.battle_format, + "--n_battles", + str(matchup.n_battles), + "--team_set", + policy.team_set, + "--battle_backend", + policy.battle_backend, + "--temperature", + str(policy.temperature), + "--save_results_to", + results_dir, + ] + if policy.checkpoint is not None: + cmd.extend(["--checkpoint", str(policy.checkpoint)]) + if save_trajectories: + traj_dir = os.path.join(matchup_dir, "trajectories") + os.makedirs(traj_dir, exist_ok=True) + cmd.extend(["--save_trajectories_to", traj_dir]) + return cmd + + # Acceptor (policy_b) launches first + acceptor_cmd = _build_cmd(matchup.policy_b, username_b, username_a, "acceptor") + env_acceptor = os.environ.copy() + env_acceptor["CUDA_VISIBLE_DEVICES"] = str(gpu_b) + + kwargs_acceptor = dict(env=env_acceptor, text=True) + if not verbose: + kwargs_acceptor.update( + stdout=subprocess.PIPE, stderr=subprocess.PIPE, bufsize=1 + ) + + acceptor_proc = subprocess.Popen(acceptor_cmd, **kwargs_acceptor) + + # Wait for acceptor to connect + time.sleep(acceptor_startup_delay) + + # Challenger (policy_a) launches second + challenger_cmd = _build_cmd(matchup.policy_a, username_a, username_b, "challenger") + challenger_result = run_subprocess( + challenger_cmd, gpu_a, timeout=timeout, verbose=verbose + ) + + # Wait for acceptor to finish too + try: + acceptor_proc.wait(timeout=60) + except subprocess.TimeoutExpired: + acceptor_proc.kill() + acceptor_proc.wait() + + acceptor_result = subprocess.CompletedProcess( + acceptor_cmd, + acceptor_proc.returncode, + stdout=( + acceptor_proc.stdout.read() if not verbose and acceptor_proc.stdout else "" + ), + stderr=( + acceptor_proc.stderr.read() if not verbose and acceptor_proc.stderr else "" + ), + ) + + for stream in (acceptor_proc.stdout, acceptor_proc.stderr): + if stream: + try: + stream.close() + except Exception: + pass + + return MatchupPairResult( + challenger_proc=challenger_result, + acceptor_proc=acceptor_result, + matchup_dir=matchup_dir, + challenger_username=username_a, + ) diff --git a/metamon/rl/evaluate/h2h/__init__.py b/metamon/rl/evaluate/h2h/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/metamon/rl/evaluate/h2h/__main__.py b/metamon/rl/evaluate/h2h/__main__.py new file mode 100644 index 0000000000..a008bbfa94 --- /dev/null +++ b/metamon/rl/evaluate/h2h/__main__.py @@ -0,0 +1,100 @@ +""" +Head-to-head evaluation launcher. + +Usage: + python -m metamon.rl.evaluate.h2h \\ + --config h2h_config.yaml \\ + --gpus 0 1 2 3 \\ + --output_dir ./h2h_results + +Add --dry_run to preview all matchups without running them. + +Config files may contain ``${var}`` template placeholders that become +extra CLI arguments automatically. See ``metamon.rl.evaluate.common`` +for details. +""" + +import os +import sys +from argparse import ArgumentParser + +from metamon.rl.evaluate.common import add_template_args, get_template_values +from metamon.rl.evaluate.h2h.config import parse_h2h_config +from metamon.rl.evaluate.launch import run_all_matchups +from metamon.rl.evaluate.preview import preview_matchups + + +def main(): + # --- first pass: discover --config so we can scan for template vars --- + pre_parser = ArgumentParser(add_help=False) + pre_parser.add_argument("--config") + pre_args, _ = pre_parser.parse_known_args() + + # --- full parser --- + parser = ArgumentParser(description="Run head-to-head evaluation matrix.") + parser.add_argument("--config", required=True, help="Path to h2h YAML config.") + parser.add_argument( + "--gpus", nargs="+", type=int, required=True, help="GPU IDs to use." + ) + parser.add_argument( + "--output_dir", required=True, help="Directory for results and logs." + ) + parser.add_argument( + "--max_concurrent", + type=int, + default=None, + help="Max concurrent matchups (default = number of GPUs).", + ) + parser.add_argument( + "--timeout", + type=int, + default=3600, + help="Timeout per matchup in seconds (default: 3600 = 1 hour).", + ) + parser.add_argument( + "--acceptor_startup_delay", + type=float, + default=10.0, + help="Seconds to wait for acceptor before launching challenger.", + ) + parser.add_argument( + "--save_trajectories", + action="store_true", + help="Save trajectory files for each matchup.", + ) + parser.add_argument( + "--verbose", action="store_true", help="Stream subprocess output." + ) + parser.add_argument( + "--dry_run", + action="store_true", + help="Preview matchups without running them.", + ) + + # auto-discover template variables and add them as CLI args + template_vars = add_template_args(parser, pre_args.config) + + args = parser.parse_args() + + config_path = os.path.abspath(args.config) + tpl_values = get_template_values(args, template_vars) if template_vars else None + matchups = parse_h2h_config(config_path, template_vars=tpl_values) + + if args.dry_run: + preview_matchups(matchups, mode="h2h", template_values=tpl_values) + return + + run_all_matchups( + matchups=matchups, + gpus=args.gpus, + output_dir=args.output_dir, + max_concurrent=args.max_concurrent, + timeout=args.timeout, + acceptor_startup_delay=args.acceptor_startup_delay, + verbose=args.verbose, + save_trajectories=args.save_trajectories, + ) + + +if __name__ == "__main__": + main() diff --git a/metamon/rl/evaluate/h2h/config.py b/metamon/rl/evaluate/h2h/config.py new file mode 100644 index 0000000000..2d1404f3ec --- /dev/null +++ b/metamon/rl/evaluate/h2h/config.py @@ -0,0 +1,84 @@ +""" +Config parsing for head-to-head evaluation. + +Example config: + + battle_format: gen1ou + battles_per_matchup: 50 + + defaults: + team_set: modern_replays_v2 + battle_backend: metamon + checkpoint: null + temperature: 1.0 + + policies: + # Simple: outer key = model_name + Kadabra: {} + SyntheticRLV2: + checkpoint: 40 + + # Override model_name if display name differs + MyAlias: + model_name: Alakazam2 + checkpoint: 48 + + # Variants: expand one model into multiple configs + Kadabra: + variants: + - team_set: modern_replays_v2 + - team_set: competitive + - { team_set: competitive, checkpoint: 20 } +""" + +from itertools import combinations +from typing import List, Optional + +from metamon.rl.evaluate.common import ( + PolicySpec, + MatchupSpec, + load_config, + expand_variants, +) + + +def parse_h2h_config( + config_path: str, template_vars: Optional[dict] = None +) -> List[MatchupSpec]: + """Parse a head-to-head YAML config into a list of MatchupSpecs. + + Generates all unordered pairs of policies. + """ + raw = load_config(config_path, template_vars=template_vars) + defaults = raw.get("defaults", {}) + battle_format = raw["battle_format"] + n_battles = raw.get("battles_per_matchup", 50) + + if "policies" not in raw: + raise ValueError("h2h config must have a 'policies' section") + + # Expand all policies (including variants) + all_policies: List[PolicySpec] = [] + for name, policy_config in raw["policies"].items(): + if policy_config is None: + policy_config = {} + all_policies.extend(expand_variants(name, policy_config, defaults)) + + if len(all_policies) < 2: + raise ValueError( + f"h2h config must define at least 2 policies, got {len(all_policies)}" + ) + + # Generate all unordered pairs + matchups = [] + for a, b in combinations(all_policies, 2): + matchups.append( + MatchupSpec( + policy_a=a, + policy_b=b, + n_battles=n_battles, + battle_format=battle_format, + ) + ) + + return matchups diff --git a/metamon/rl/evaluate/h2h/example_config.yaml b/metamon/rl/evaluate/h2h/example_config.yaml new file mode 100644 index 0000000000..8d5baa97d6 --- /dev/null +++ b/metamon/rl/evaluate/h2h/example_config.yaml @@ -0,0 +1,22 @@ +# Head-to-head example: paper policies (SmallRL, SyntheticRLV2) vs PokéAgent line (Kadabra, Alakazam). +battle_format: gen1ou +battles_per_matchup: 50 + +defaults: + team_set: competitive + battle_backend: metamon + checkpoint: null + temperature: 1.0 + +policies: + SmallRL: {} + + SyntheticRLV2: + checkpoint: 40 + + Kadabra: {} + + Alakazam: + variants: + - { checkpoint: null, team_set: competitive } + - { checkpoint: 8, team_set: modern_replays_v2 } diff --git a/metamon/rl/evaluate/ladder_self_play/__init__.py b/metamon/rl/evaluate/ladder_self_play/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/metamon/rl/evaluate/ladder_self_play/__main__.py b/metamon/rl/evaluate/ladder_self_play/__main__.py new file mode 100644 index 0000000000..9f97cda986 --- /dev/null +++ b/metamon/rl/evaluate/ladder_self_play/__main__.py @@ -0,0 +1,8 @@ +""" +python -m metamon.rl.evaluate.ladder_self_play --format gen9ou --gpus 0 1 --config config.yaml --save_trajectories_to ./trajectories +""" + +from metamon.rl.evaluate.ladder_self_play.launch_models import main + +if __name__ == "__main__": + main() diff --git a/metamon/rl/evaluate/ladder_self_play/example_config.yaml b/metamon/rl/evaluate/ladder_self_play/example_config.yaml new file mode 100644 index 0000000000..1fd7446967 --- /dev/null +++ b/metamon/rl/evaluate/ladder_self_play/example_config.yaml @@ -0,0 +1,24 @@ +# Ladder self-play example: paper Synthetic line + PokéAgent Kadabra/Minikazam/Alakazam. +defaults: + team_set: competitive + battle_backend: metamon + checkpoints: [null] + temperatures: [1.0, 1.25, 1.5, 2.0] + num_agents: 1 + +agents: + SynRLV2: + model_name: SyntheticRLV2 + num_agents: 2 + + Kadabra: + model_name: Kadabra + num_agents: 2 + + Minikazam: + model_name: Minikazam + + Alakazam: + model_name: Alakazam + checkpoints: [null, 8] + num_agents: 2 diff --git a/metamon/rl/self_play/launch_models.py b/metamon/rl/evaluate/ladder_self_play/launch_models.py similarity index 54% rename from metamon/rl/self_play/launch_models.py rename to metamon/rl/evaluate/ladder_self_play/launch_models.py index 2e46aa399c..f6981a5f91 100644 --- a/metamon/rl/self_play/launch_models.py +++ b/metamon/rl/evaluate/ladder_self_play/launch_models.py @@ -1,145 +1,60 @@ -import gc import os -import random -import subprocess import sys import threading import time -import yaml from argparse import ArgumentParser +from collections import defaultdict from typing import List, Dict - -def run_username_on_gpu_continuous( - gpu_id: int, - username: str, - format_name: str, - config_path: str, - n_challenges: int = 50, - startup_delay: int = 0, - restart_delay: int = 60, - timeout: int = 2700, - save_trajectories_to: str = None, - verbose: bool = False, -): - if startup_delay > 0: - print( - f"Waiting {startup_delay} seconds before starting {username} on GPU {gpu_id}..." - ) - time.sleep(startup_delay) - - run_count = 0 - while True: - run_count += 1 - print(f"\n{'='*60}") - print( - f"[Run #{run_count}] Starting {username} on GPU {gpu_id} for format {format_name} with {n_challenges} challenges..." - ) - print(f"{'='*60}") - - # set GPU - env = os.environ.copy() - env["CUDA_VISIBLE_DEVICES"] = str(gpu_id) - - cmd = [ - "python", - "serve_model.py", - "--username", - username, - "--format", - format_name, - "--n_challenges", - str(n_challenges), - "--config", - config_path, - ] - - if save_trajectories_to: - cmd.extend(["--save_trajectories_to", save_trajectories_to]) - - process = None - try: - if verbose: - # verbose mode: stream output in real-time - process = subprocess.Popen( - cmd, - env=env, - cwd=os.path.dirname(os.path.abspath(__file__)), - text=True, - ) +from metamon.rl.evaluate.common import ( + distribute_across_gpus, + load_config, + merge_defaults, + run_subprocess, +) + + +class _StatsTracker: + """Thread-safe counter for battle-generation throughput.""" + + def __init__(self): + self._lock = threading.Lock() + self._start_time = time.monotonic() + self._total_battles = 0 + self._total_runs = 0 + self._failed_runs = 0 + self._battles_by_agent: Dict[str, int] = defaultdict(int) + + def record_run(self, username: str, n_battles: int, success: bool): + with self._lock: + self._total_runs += 1 + if success: + self._total_battles += n_battles + self._battles_by_agent[username] += n_battles else: - # quiet mode: capture output - process = subprocess.Popen( - cmd, - env=env, - cwd=os.path.dirname(os.path.abspath(__file__)), - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - bufsize=1, - ) - - # wait for completion - try: - process.wait(timeout=timeout) - if process.returncode == 0: - print( - f"✓ {username} on GPU {gpu_id} [Run #{run_count}] completed successfully" - ) - else: - print( - f"✗ {username} on GPU {gpu_id} [Run #{run_count}] failed with code {process.returncode}" - ) - if verbose: - # stderr already printed in real-time - pass - else: - stderr_output = process.stderr.read() - if stderr_output: - print(f"Error output from {username}:") - print(stderr_output) - except subprocess.TimeoutExpired: - print( - f"⏰ {username} on GPU {gpu_id} [Run #{run_count}] timed out after {timeout} seconds" - ) - process.kill() - process.wait() - - except Exception as e: - print( - f"✗ {username} on GPU {gpu_id} [Run #{run_count}] failed with exception: {e}" - ) - if process and process.poll() is None: - process.kill() - process.wait() - - finally: - # cleanup resources - if process: - if process.poll() is None: - process.kill() - try: - process.wait(timeout=5) - except: - pass - - if hasattr(process, "stdout") and process.stdout: - process.stdout.close() - if hasattr(process, "stderr") and process.stderr: - process.stderr.close() - - del process - - gc.collect() - - print(f"Waiting {restart_delay} seconds before relaunching {username}...") - time.sleep(restart_delay) + self._failed_runs += 1 + + def summary(self) -> str: + with self._lock: + elapsed = time.monotonic() - self._start_time + elapsed_h = elapsed / 3600 + bph = self._total_battles / elapsed_h if elapsed_h > 0 else 0 + lines = [ + f" Elapsed : {elapsed/60:.1f} min", + f" Battles : {self._total_battles:,} ({bph:,.0f} battles/hr)", + f" Runs : {self._total_runs} completed, {self._failed_runs} failed", + " By agent:", + ] + for agent, count in sorted( + self._battles_by_agent.items(), key=lambda x: -x[1] + ): + lines.append(f" {agent:<30} {count:>6,} battles") + return "\n".join(lines) def get_usernames(config_path: str) -> List[str]: """Expand agents based on num_agents field""" - with open(config_path, "r") as f: - raw_config = yaml.safe_load(f) + raw_config = load_config(config_path) # validate structure if "agents" not in raw_config: @@ -149,7 +64,13 @@ def get_usernames(config_path: str) -> List[str]: agents = raw_config.get("agents", {}) # validate defaults - required_defaults = ["team_set", "battle_backend", "checkpoints", "num_agents"] + required_defaults = [ + "team_set", + "battle_backend", + "checkpoints", + "temperatures", + "num_agents", + ] missing_defaults = [field for field in required_defaults if field not in defaults] if missing_defaults: raise ValueError( @@ -165,7 +86,7 @@ def get_usernames(config_path: str) -> List[str]: ) # expand based on num_agents - merged_config = {**defaults, **agent_config} + merged_config = merge_defaults(defaults, agent_config or {}) num_agents = merged_config.get("num_agents", 1) # handle None/null values in yaml if num_agents is None: @@ -185,14 +106,107 @@ def get_usernames(config_path: str) -> List[str]: return expanded_usernames -def distribute_across_gpus( - usernames: List[str], gpus: List[int] -) -> Dict[int, List[str]]: - gpu_assignments = {gpu: [] for gpu in gpus} - for i, username in enumerate(usernames): - gpu_id = gpus[i % len(gpus)] - gpu_assignments[gpu_id].append(username) - return gpu_assignments +def get_agent_details(config_path: str) -> List[dict]: + """Get full agent details for preview. Returns list of dicts with username, model_name, etc.""" + raw_config = load_config(config_path) + defaults = raw_config.get("defaults", {}) + agents = raw_config.get("agents", {}) + + details = [] + for base_username, agent_config in agents.items(): + merged = merge_defaults(defaults, agent_config or {}) + num_agents = merged.get("num_agents", 1) or 1 + + usernames = ( + [base_username] + if num_agents == 1 + else [f"{base_username}-{i}" for i in range(1, num_agents + 1)] + ) + for username in usernames: + details.append( + { + "username": username, + "model_name": merged.get("model_name", base_username), + "checkpoint": merged.get("checkpoints"), + "temperature": merged.get("temperatures"), + "team_set": merged.get("team_set"), + "battle_backend": merged.get("battle_backend"), + } + ) + return details + + +def run_username_on_gpu_continuous( + gpu_id: int, + username: str, + format_name: str, + config_path: str, + n_challenges: int = 50, + startup_delay: int = 0, + restart_delay: int = 60, + timeout: int = 2700, + save_trajectories_to: str = None, + verbose: bool = False, + stats: "_StatsTracker | None" = None, +): + if startup_delay > 0: + print( + f"Waiting {startup_delay} seconds before starting {username} on GPU {gpu_id}..." + ) + time.sleep(startup_delay) + + serve_script = os.path.join( + os.path.dirname(os.path.abspath(__file__)), "serve_model.py" + ) + + run_count = 0 + while True: + run_count += 1 + t0 = time.monotonic() + + cmd = [ + "python", + serve_script, + "--username", + username, + "--format", + format_name, + "--n_challenges", + str(n_challenges), + "--config", + config_path, + ] + if save_trajectories_to: + cmd.extend(["--save_trajectories_to", save_trajectories_to]) + + result = run_subprocess(cmd, gpu_id, timeout=timeout, verbose=verbose) + elapsed = time.monotonic() - t0 + + if result.returncode == 0: + bps = n_challenges / elapsed if elapsed > 0 else 0 + print( + f"✓ {username} GPU{gpu_id} run#{run_count} " + f"— {n_challenges} battles in {elapsed:.0f}s " + f"({bps:.2f} battles/s)" + ) + if stats: + stats.record_run(username, n_challenges, success=True) + elif result.stderr == "TIMEOUT": + print( + f"⏰ {username} GPU{gpu_id} run#{run_count} timed out after {timeout}s" + ) + if stats: + stats.record_run(username, 0, success=False) + else: + print( + f"✗ {username} GPU{gpu_id} run#{run_count} failed (code {result.returncode})" + ) + if not verbose and result.stderr: + print(f" stderr: {result.stderr[:500]}") + if stats: + stats.record_run(username, 0, success=False) + + time.sleep(restart_delay) def run_all_usernames_parallel( @@ -204,6 +218,7 @@ def run_all_usernames_parallel( timeout: int = 2700, save_trajectories_to: str = None, verbose: bool = False, + stats_interval: int = 300, ): usernames = get_usernames(config_path) @@ -214,17 +229,18 @@ def run_all_usernames_parallel( print(f"Challenges per username: {n_challenges}") print(f"Restart delay: {restart_delay} seconds") print(f"Timeout per run: {timeout} seconds ({timeout//60} minutes)") + print(f"Stats interval: every {stats_interval}s") if save_trajectories_to: print(f"Saving trajectories to: {save_trajectories_to}") print("-" * 50) - # distribute usernames across GPUs gpu_assignments = distribute_across_gpus(usernames, gpus) - for gpu_id, usernames_for_gpu in gpu_assignments.items(): print(f"GPU {gpu_id}: {', '.join(usernames_for_gpu)}") print("-" * 50) + stats = _StatsTracker() + threads = [] startup_delay = 0 for gpu_id, usernames_for_gpu in gpu_assignments.items(): @@ -242,24 +258,32 @@ def run_all_usernames_parallel( timeout, save_trajectories_to, verbose, + stats, ), daemon=True, ) threads.append(thread) thread.start() - startup_delay += ( - 10 # increased delay to allow bots to connect and start challenging - ) + startup_delay += 2 print(f"\n✓ All {len(threads)} bots launched and running continuously!") print("Press Ctrl+C to stop all bots") print("-" * 50) try: + last_print = time.monotonic() while True: - time.sleep(60) + time.sleep(10) + if time.monotonic() - last_print >= stats_interval: + print(f"\n{'─'*50}") + print("THROUGHPUT STATS") + print(stats.summary()) + print(f"{'─'*50}\n") + last_print = time.monotonic() except KeyboardInterrupt: - print("\n\nShutting down all bots...") + print("\n\nFinal stats:") + print(stats.summary()) + print("\nShutting down all bots...") sys.exit(0) @@ -282,8 +306,8 @@ def main(): ) parser.add_argument( "--config", - default="earlygen_config.yaml", - help="Path to YAML config file (default: earlygen_config.yaml)", + default="example_config.yaml", + help="Path to YAML config file (default: example_config.yaml)", ) parser.add_argument( "--n_challenges", @@ -313,6 +337,17 @@ def main(): action="store_true", help="Print error messages from failed runs", ) + parser.add_argument( + "--stats_interval", + type=int, + default=300, + help="Print throughput summary every this many seconds (default: 300)", + ) + parser.add_argument( + "--dry_run", + action="store_true", + help="Preview agents without launching them.", + ) args = parser.parse_args() @@ -324,6 +359,13 @@ def main(): # convert config path to absolute path so subprocesses can find it config_path = os.path.abspath(args.config) + if args.dry_run: + from metamon.rl.evaluate.preview import preview_ladder_agents + + agents = get_agent_details(config_path) + preview_ladder_agents(agents) + return + # run continuously run_all_usernames_parallel( args.format, @@ -334,6 +376,7 @@ def main(): args.timeout, args.save_trajectories_to, args.verbose, + args.stats_interval, ) diff --git a/metamon/rl/self_play/serve_model.py b/metamon/rl/evaluate/ladder_self_play/serve_model.py similarity index 84% rename from metamon/rl/self_play/serve_model.py rename to metamon/rl/evaluate/ladder_self_play/serve_model.py index 4ea23d8a91..58bdff348a 100644 --- a/metamon/rl/self_play/serve_model.py +++ b/metamon/rl/evaluate/ladder_self_play/serve_model.py @@ -1,10 +1,8 @@ +import json import os -import random import warnings -import yaml from functools import partial -from typing import Optional, Iterable -import json +from typing import Optional import amago @@ -12,6 +10,7 @@ from metamon.interface import ObservationSpace, RewardFunction, ActionSpace from metamon.rl.pretrained import get_pretrained_model from metamon.rl.metamon_to_amago import PSLadderAMAGOWrapper +from metamon.rl.evaluate.common import load_config, merge_defaults, random_choice warnings.filterwarnings("ignore") @@ -84,9 +83,8 @@ def make_ladder_env( ) args = parser.parse_args() - # load config - with open(args.config, "r") as f: - raw_config = yaml.safe_load(f) + # load config using shared utility + raw_config = load_config(args.config) # validate structure if "agents" not in raw_config: @@ -125,9 +123,9 @@ def make_ladder_env( else: raise ValueError(f"Username {username} not found in config") - # merge config + # merge config using shared utility agent_config = agents[base_username] - account_config = {**defaults, **agent_config} + account_config = merge_defaults(defaults, agent_config or {}) account_config["battle_format"] = args.format # validate required fields @@ -138,29 +136,16 @@ def make_ladder_env( model_name = account_config["model_name"] agent_maker = get_pretrained_model(model_name) - # get team_set - uniform random sampling if list - team_set_config = account_config["team_set"] - if isinstance(team_set_config, list) and len(team_set_config) > 0: - team_set_choice = random.choice(team_set_config) - else: - team_set_choice = team_set_config + # team_set / checkpoint / temperature all support: + # plain scalar, plain list, "range(...)", "linspace(...)", {weighted: {...}} + team_set_choice = random_choice(account_config["team_set"]) print(f"Using team_set {team_set_choice}") player_team_set = get_metamon_teams(args.format, team_set_choice) - # get checkpoint - uniform random sampling - checkpoints = account_config["checkpoints"] - if checkpoints is not None and len(checkpoints) > 0: - checkpoint = random.choice(checkpoints) - else: - checkpoint = None + checkpoint = random_choice(account_config["checkpoints"]) print(f"Using checkpoint {checkpoint}") - # get temperature - uniform random sampling - temperatures = account_config.get("temperatures", [1.0]) - if isinstance(temperatures, Iterable) and not isinstance(temperatures, str): - temperature = random.choice(temperatures) - else: - temperature = float(temperatures) + temperature = float(random_choice(account_config.get("temperatures", [1.0]))) print(f"Using temperature {temperature}") battle_backend = account_config["battle_backend"] print(f"Using battle backend {battle_backend}") diff --git a/metamon/rl/evaluate/launch.py b/metamon/rl/evaluate/launch.py new file mode 100644 index 0000000000..894ef81ae2 --- /dev/null +++ b/metamon/rl/evaluate/launch.py @@ -0,0 +1,159 @@ +""" +Shared launcher for challenge-based evaluation (h2h and sweep). + +Takes a list of MatchupSpecs and runs them with a thread pool, +tracking results for crash recovery. +""" + +import os +import sys +import time +from concurrent.futures import ThreadPoolExecutor, as_completed +from typing import List + +from metamon.rl.evaluate.common import ( + MatchupSpec, + MatchupPairResult, + distribute_across_gpus, + run_matchup_pair, +) +from metamon.rl.evaluate.results import ResultsTracker + + +def run_all_matchups( + matchups: List[MatchupSpec], + gpus: List[int], + output_dir: str, + max_concurrent: int = None, + timeout: int = 3600, + acceptor_startup_delay: float = 10.0, + verbose: bool = False, + save_trajectories: bool = False, +): + """Run a list of matchups with crash recovery and a thread pool. + + Args: + matchups: All matchups to run. + gpus: Available GPU IDs. + output_dir: Root directory for results, team logs, etc. + max_concurrent: Max matchups to run in parallel (default = len(gpus)). + timeout: Seconds before killing a matchup. + acceptor_startup_delay: Seconds to wait after launching acceptor before challenger. + verbose: Stream subprocess output in real-time. + save_trajectories: Whether to save trajectory files. + """ + if max_concurrent is None: + max_concurrent = len(gpus) + + tracker = ResultsTracker(output_dir) + + # Filter out completed matchups (crash recovery) + remaining = [m for m in matchups if not tracker.is_completed(m.matchup_id)] + n_skipped = len(matchups) - len(remaining) + + print(f"\n{'='*60}") + print(f" Head-to-Head Launcher") + print(f"{'='*60}") + print(f" Total matchups: {len(matchups)}") + print(f" Already completed: {n_skipped}") + print(f" Remaining: {len(remaining)}") + print(f" Max concurrent: {max_concurrent}") + print(f" GPUs: {gpus}") + print(f" Output: {output_dir}") + print(f" Timeout: {timeout}s ({timeout // 60}min)") + print(f"{'='*60}\n") + + if not remaining: + print("All matchups already completed!") + tracker.print_win_matrix() + tracker.write_win_matrix_csv() + return + + # Assign GPU pairs for each matchup (round-robin across available GPUs) + # Each matchup needs 2 GPU slots: one for challenger, one for acceptor + gpu_pairs = [] + for i, matchup in enumerate(remaining): + gpu_a = gpus[(2 * i) % len(gpus)] + gpu_b = gpus[(2 * i + 1) % len(gpus)] + gpu_pairs.append((gpu_a, gpu_b)) + + completed_count = n_skipped + failed_count = 0 + + def _run_one(matchup: MatchupSpec, gpu_a: int, gpu_b: int): + label = f"{matchup.policy_a.short_label} vs {matchup.policy_b.short_label}" + print(f"▶ Starting: {label} (GPUs {gpu_a},{gpu_b})") + t0 = time.time() + + pair = run_matchup_pair( + matchup=matchup, + gpu_a=gpu_a, + gpu_b=gpu_b, + output_dir=output_dir, + timeout=timeout, + acceptor_startup_delay=acceptor_startup_delay, + verbose=verbose, + save_trajectories=save_trajectories, + ) + + elapsed = time.time() - t0 + + # Parse results from the CSVs written by PokeEnvWrapper + results_dir = os.path.join(pair.matchup_dir, "results") + result = tracker.record_from_results_dir( + matchup_id=matchup.matchup_id, + policy_a_name=matchup.policy_a.short_label, + policy_b_name=matchup.policy_b.short_label, + results_dir=results_dir, + challenger_username=pair.challenger_username, + ) + + if result is not None: + print( + f"✓ Completed: {label} " + f"({result.policy_a_wins}W-{result.policy_b_wins}L, " + f"{elapsed:.0f}s)" + ) + return True + else: + print(f"✗ Failed: {label} ({elapsed:.0f}s)") + if not verbose: + if pair.challenger_proc.stderr: + print(f" Challenger stderr: {pair.challenger_proc.stderr[:500]}") + if pair.acceptor_proc.stderr: + print(f" Acceptor stderr: {pair.acceptor_proc.stderr[:500]}") + return False + + try: + with ThreadPoolExecutor(max_workers=max_concurrent) as pool: + futures = {} + for matchup, (gpu_a, gpu_b) in zip(remaining, gpu_pairs): + future = pool.submit(_run_one, matchup, gpu_a, gpu_b) + futures[future] = matchup + + for future in as_completed(futures): + matchup = futures[future] + try: + success = future.result() + if success: + completed_count += 1 + else: + failed_count += 1 + except Exception as e: + print(f"✗ Exception in {matchup.matchup_id}: {e}") + failed_count += 1 + + print( + f" Progress: {completed_count}/{len(matchups)} completed, " + f"{failed_count} failed" + ) + + except KeyboardInterrupt: + print("\n\nInterrupted! Partial results saved.") + + # Final summary + print(f"\n{'='*60}") + print(f" Completed: {completed_count}/{len(matchups)} | Failed: {failed_count}") + print(f"{'='*60}") + tracker.print_win_matrix() + tracker.write_win_matrix_csv() diff --git a/metamon/rl/evaluate/preview.py b/metamon/rl/evaluate/preview.py new file mode 100644 index 0000000000..e670086bae --- /dev/null +++ b/metamon/rl/evaluate/preview.py @@ -0,0 +1,252 @@ +""" +Dry-run visualization for auto-evaluation configs. + +Parses a config file and prints a detailed, human-readable preview +of all matchups/agents that will be launched — without actually running anything. + +Supports all modes: h2h (matrix), sweep (table), ladder_self_play (agent list). +""" + +from typing import Dict, List, Optional + +from rich.console import Console +from rich.panel import Panel +from rich.table import Table +from rich.text import Text + +from metamon.rl.evaluate.common import ( + MatchupSpec, + PolicySpec, + _format_value_for_display, +) + + +def _fmt(value) -> str: + """Format a raw config value (list / dict / scalar) as a compact string.""" + if value is None: + return "-" + return _format_value_for_display(value) + + +console = Console() + + +def _policy_detail(p: PolicySpec) -> Text: + """Rich-formatted one-line detail string for a policy.""" + t = Text() + t.append("model=", style="dim") + t.append(p.model_name, style="cyan") + t.append(" ckpt=", style="dim") + t.append(str(p.checkpoint), style="yellow" if p.checkpoint is not None else "dim") + t.append(" temp=", style="dim") + t.append(str(p.temperature), style="green" if p.temperature != 1.0 else "dim") + t.append(" teams=", style="dim") + t.append(p.team_set, style="magenta") + t.append(" backend=", style="dim") + t.append(p.battle_backend, style="dim") + return t + + +def preview_matchups( + matchups: List[MatchupSpec], + mode: str = "h2h", + template_values: Optional[dict] = None, +): + """Print a detailed preview of all matchups. + + Args: + matchups: List of matchups to preview. + mode: "h2h" shows a matrix, "sweep" shows a table. + template_values: Resolved ``${var}`` values (shown in header if present). + """ + if not matchups: + console.print("[dim]No matchups to preview.[/dim]") + return + + # Collect unique policies (ordered by first appearance) + policies = {} + for m in matchups: + if m.policy_a.short_label not in policies: + policies[m.policy_a.short_label] = m.policy_a + if m.policy_b.short_label not in policies: + policies[m.policy_b.short_label] = m.policy_b + + battle_format = matchups[0].battle_format + n_battles = matchups[0].n_battles + title = "HEAD-TO-HEAD" if mode == "h2h" else "SWEEP" + + # Header panel + header = Text() + header.append(f"{title} EVALUATION PREVIEW\n", style="bold white") + header.append(f"Format: ", style="dim") + header.append(f"{battle_format}", style="cyan bold") + header.append(f" │ Battles per matchup: ", style="dim") + header.append(f"{n_battles}", style="cyan bold") + header.append(f" │ Total matchups: ", style="dim") + header.append(f"{len(matchups)}", style="cyan bold") + header.append(f" │ Total battles: ", style="dim") + header.append(f"{len(matchups) * n_battles}", style="cyan bold") + if template_values: + header.append(f"\n") + header.append(f"Template: ", style="dim") + for k, v in template_values.items(): + header.append(f"${{{k}}}", style="yellow") + header.append(f"={v}", style="bold white") + header.append(f" ", style="dim") + console.print() + console.print(Panel(header, border_style="blue")) + + # Policy legend table + legend = Table( + title="Policies", + title_style="bold", + show_header=True, + header_style="bold", + pad_edge=False, + box=None, + padding=(0, 1), + ) + legend.add_column("#", style="bold yellow", justify="right", width=3) + legend.add_column("Label", style="bold white") + legend.add_column("Details", no_wrap=False) + + sorted_labels = sorted(policies.keys()) + label_to_idx = {} + for i, label in enumerate(sorted_labels, 1): + label_to_idx[label] = i + legend.add_row(str(i), label, _policy_detail(policies[label])) + + console.print(legend) + console.print() + + if mode == "h2h": + _preview_matrix(matchups, sorted_labels, label_to_idx) + else: + _preview_sweep_table(matchups) + + console.print() + + +def _preview_matrix( + matchups: List[MatchupSpec], sorted_labels: List[str], label_to_idx: dict +): + """Print the h2h matchup matrix using numbered indices.""" + # Build lookup: which matchups exist + matchup_set = set() + for m in matchups: + matchup_set.add((m.policy_a.short_label, m.policy_b.short_label)) + matchup_set.add((m.policy_b.short_label, m.policy_a.short_label)) + + # Build the Rich table with numbered columns + table = Table( + title="Matchup Matrix", + title_style="bold", + show_header=True, + header_style="bold cyan", + show_lines=True, + pad_edge=True, + ) + table.add_column("", style="bold yellow", justify="right") # row label + for label in sorted_labels: + idx = label_to_idx[label] + table.add_column(str(idx), justify="center", width=3) + + for row_label in sorted_labels: + row_idx = label_to_idx[row_label] + cells = [f"[bold yellow]{row_idx}[/bold yellow]"] + for col_label in sorted_labels: + if row_label == col_label: + cells.append("[dim]·[/dim]") + elif (row_label, col_label) in matchup_set: + cells.append("[bold green]✓[/bold green]") + else: + cells.append("") + table.add_row(*cells) + + console.print(table) + + +def _preview_sweep_table(matchups: List[MatchupSpec]): + """Print sweep matchups as a formatted table.""" + # In sweep mode, policy_b is always the fixed opponent + opponent = matchups[0].policy_b + + opp_text = Text() + opp_text.append("Fixed opponent: ", style="dim") + opp_text.append(opponent.short_label, style="bold white") + opp_text.append(" (") + opp_text.append_text(_policy_detail(opponent)) + opp_text.append(")") + console.print(opp_text) + console.print() + + table = Table( + title="Sweep Points", + title_style="bold", + show_header=True, + header_style="bold", + show_lines=False, + pad_edge=True, + ) + table.add_column("#", style="bold yellow", justify="right", width=4) + table.add_column("Policy", style="bold white") + table.add_column("Checkpoint", justify="right", style="yellow") + table.add_column("Temp", justify="right", style="green") + table.add_column("Team Set", style="magenta") + + for i, m in enumerate(matchups, 1): + p = m.policy_a + ckpt_str = str(p.checkpoint) if p.checkpoint is not None else "default" + table.add_row( + str(i), + p.short_label, + ckpt_str, + f"{p.temperature:.2f}", + p.team_set, + ) + + console.print(table) + + +def preview_ladder_agents(agents: List[dict]): + """Print self-play ladder agents as a formatted table. + + Args: + agents: List of dicts with at least 'username' and 'model_name' keys. + """ + if not agents: + console.print("[dim]No agents to preview.[/dim]") + return + + header = Text("LADDER SELF-PLAY PREVIEW\n", style="bold white") + header.append("All agents play each other via random matchmaking.", style="dim") + console.print() + console.print(Panel(header, border_style="blue")) + + table = Table( + title=f"Agents ({len(agents)})", + title_style="bold", + show_header=True, + header_style="bold", + show_lines=False, + pad_edge=True, + ) + table.add_column("#", style="bold yellow", justify="right", width=4) + table.add_column("Username", style="bold white") + table.add_column("Model", style="cyan") + table.add_column("Checkpoint", justify="right", style="yellow") + table.add_column("Temp", justify="right", style="green") + table.add_column("Team Set", style="magenta") + + for i, a in enumerate(agents, 1): + table.add_row( + str(i), + a.get("username", "?"), + a.get("model_name", "?"), + _fmt(a.get("checkpoint")), + _fmt(a.get("temperature")), + _fmt(a.get("team_set")), + ) + + console.print(table) + console.print() diff --git a/metamon/rl/evaluate/results.py b/metamon/rl/evaluate/results.py new file mode 100644 index 0000000000..022d4e9be2 --- /dev/null +++ b/metamon/rl/evaluate/results.py @@ -0,0 +1,264 @@ +""" +Results tracking, crash recovery, and win matrix output for auto-evaluation. + +Uses an append-only JSONL file so partial runs can be resumed. +""" + +import csv +import json +import os +from collections import defaultdict +from dataclasses import dataclass, asdict +from datetime import datetime +from typing import Dict, List, Optional, Set + + +@dataclass +class MatchupResult: + """Result of a single head-to-head matchup.""" + + matchup_id: str + policy_a_name: str + policy_b_name: str + policy_a_wins: int + policy_b_wins: int + total_battles: int + timestamp: str + + @property + def policy_a_win_rate(self) -> Optional[float]: + if self.total_battles < 1: + return None + return self.policy_a_wins / self.total_battles + + +class ResultsTracker: + """Track matchup results with crash recovery via append-only JSONL. + + Args: + output_dir: Directory for results files. + """ + + RESULTS_FILE = "matchup_results.jsonl" + WIN_MATRIX_FILE = "win_matrix.csv" + + def __init__(self, output_dir: str): + self.output_dir = output_dir + os.makedirs(output_dir, exist_ok=True) + self.results_path = os.path.join(output_dir, self.RESULTS_FILE) + self._completed: Dict[str, MatchupResult] = {} + self._load_existing() + + def _load_existing(self): + """Load previously completed matchups for crash recovery.""" + if not os.path.exists(self.results_path): + return + with open(self.results_path, "r") as f: + for line in f: + line = line.strip() + if not line: + continue + try: + data = json.loads(line) + result = MatchupResult(**data) + self._completed[result.matchup_id] = result + except (json.JSONDecodeError, TypeError) as e: + print(f"Warning: skipping malformed result line: {e}") + + def is_completed(self, matchup_id: str) -> bool: + return matchup_id in self._completed + + @property + def completed_ids(self) -> Set[str]: + return set(self._completed.keys()) + + def record_result(self, result: MatchupResult): + """Append a result to the JSONL file and in-memory cache.""" + self._completed[result.matchup_id] = result + with open(self.results_path, "a") as f: + f.write(json.dumps(asdict(result)) + "\n") + + def record_from_results_dir( + self, + matchup_id: str, + policy_a_name: str, + policy_b_name: str, + results_dir: str, + challenger_username: str, + ) -> Optional[MatchupResult]: + """Read battle CSVs written by PokeEnvWrapper and record the result. + + ``PokeEnvWrapper`` writes per-player CSV files inside ``results_dir`` + with columns: Player Username, Team File, Opponent Username, Result, + Turn Count, Battle ID (first row is a header). + + We count WIN/LOSS only from the challenger's rows (matched by exact + username) to avoid double-counting since both sides write CSVs. + """ + if not os.path.exists(results_dir): + print(f"Warning: results dir not found: {results_dir}") + return None + + a_wins = 0 + b_wins = 0 + total = 0 + + for csv_file in os.listdir(results_dir): + if not csv_file.endswith(".csv"): + continue + path = os.path.join(results_dir, csv_file) + try: + with open(path, "r") as f: + reader = csv.reader(f) + next(reader, None) # skip header + for row in reader: + if len(row) < 4: + continue + username = row[0].strip() + if username != challenger_username: + continue + result_str = row[3].strip() + total += 1 + if result_str == "WIN": + a_wins += 1 + elif result_str == "LOSS": + b_wins += 1 + except Exception as e: + print(f"Warning: failed to parse {path}: {e}") + + if total == 0: + print(f"Warning: no battle results found in {results_dir}") + return None + + result = MatchupResult( + matchup_id=matchup_id, + policy_a_name=policy_a_name, + policy_b_name=policy_b_name, + policy_a_wins=a_wins, + policy_b_wins=b_wins, + total_battles=total, + timestamp=datetime.now().isoformat(), + ) + self.record_result(result) + return result + + def get_all_results(self) -> List[MatchupResult]: + return list(self._completed.values()) + + def build_win_matrix(self) -> Dict[str, Dict[str, Optional[float]]]: + """Build a win-rate matrix from all completed matchups. + + Returns: + Nested dict: matrix[row_policy][col_policy] = row's win rate against col. + None for unplayed matchups, diagonal is empty. + """ + # Collect all unique policy names + names = set() + for r in self._completed.values(): + names.add(r.policy_a_name) + names.add(r.policy_b_name) + names = sorted(names) + + matrix = {a: {b: None for b in names} for a in names} + for r in self._completed.values(): + a, b = r.policy_a_name, r.policy_b_name + wr = r.policy_a_win_rate + if wr is not None: + matrix[a][b] = wr + matrix[b][a] = 1.0 - wr + + return matrix + + def print_win_matrix(self): + """Print a formatted win matrix to the terminal using rich.""" + from rich.console import Console + from rich.table import Table + from rich.text import Text + + matrix = self.build_win_matrix() + if not matrix: + print("No results to display.") + return + + console = Console() + names = sorted(matrix.keys()) + + # Build a numbered legend so column headers stay compact + idx_map = {name: i for i, name in enumerate(names, 1)} + + # Legend table + legend = Table( + title="Policy Legend", + title_style="bold", + show_header=True, + header_style="bold", + box=None, + pad_edge=False, + padding=(0, 1), + ) + legend.add_column("#", style="bold yellow", justify="right", width=3) + legend.add_column("Policy", style="bold white") + for name in names: + legend.add_row(str(idx_map[name]), name) + + console.print() + console.print(legend) + console.print() + + # Win matrix table + table = Table( + title="Win Matrix (row win rate vs column)", + title_style="bold", + show_header=True, + header_style="bold cyan", + show_lines=True, + pad_edge=True, + ) + table.add_column("", style="bold yellow", justify="right") + for name in names: + table.add_column(str(idx_map[name]), justify="center", width=7) + + for row in names: + cells = [f"[bold yellow]{idx_map[row]}[/bold yellow]"] + for col in names: + if row == col: + cells.append("[dim]—[/dim]") + elif matrix[row][col] is None: + cells.append("[dim italic]?[/dim italic]") + else: + wr = matrix[row][col] + # Color by win rate + if wr >= 0.6: + style = "bold green" + elif wr >= 0.4: + style = "yellow" + else: + style = "red" + cells.append(f"[{style}]{wr:.1%}[/{style}]") + table.add_row(*cells) + + console.print(table) + console.print() + + def write_win_matrix_csv(self): + """Write the win matrix to a CSV file.""" + matrix = self.build_win_matrix() + if not matrix: + return + + names = sorted(matrix.keys()) + path = os.path.join(self.output_dir, self.WIN_MATRIX_FILE) + with open(path, "w", newline="") as f: + writer = csv.writer(f) + writer.writerow([""] + names) + for row in names: + cells = [] + for col in names: + if row == col: + cells.append("") + elif matrix[row][col] is None: + cells.append("") + else: + cells.append(f"{matrix[row][col]:.4f}") + writer.writerow([row] + cells) + print(f"Win matrix written to {path}") diff --git a/metamon/rl/evaluate/serve_matchup.py b/metamon/rl/evaluate/serve_matchup.py new file mode 100644 index 0000000000..b2451e631d --- /dev/null +++ b/metamon/rl/evaluate/serve_matchup.py @@ -0,0 +1,99 @@ +""" +Worker script: runs one side of a head-to-head matchup. + +Loads a pretrained model, creates a ChallengeByUsername environment, and +runs battles. This is launched as a subprocess by the h2h/sweep launchers. + +Fully self-contained — all configuration is passed via CLI args. +""" + +import json +import warnings +from argparse import ArgumentParser +from functools import partial +from typing import Optional + +warnings.filterwarnings("ignore") + + +def main(): + parser = ArgumentParser(description="Run one side of a head-to-head matchup.") + parser.add_argument("--model_name", required=True, help="Pretrained model name.") + parser.add_argument("--username", required=True, help="This player's username.") + parser.add_argument( + "--opponent_username", required=True, help="Opponent's username." + ) + parser.add_argument( + "--role", + required=True, + choices=["challenger", "acceptor"], + help="challenger sends challenges, acceptor waits for them.", + ) + parser.add_argument("--format", required=True, help="Battle format (e.g. gen1ou).") + parser.add_argument( + "--n_battles", type=int, required=True, help="Number of battles." + ) + parser.add_argument("--team_set", default="competitive", help="Team set name.") + parser.add_argument("--battle_backend", default="metamon", help="Battle backend.") + parser.add_argument( + "--temperature", type=float, default=1.0, help="Sampling temperature." + ) + parser.add_argument( + "--checkpoint", type=int, default=None, help="Checkpoint epoch." + ) + parser.add_argument( + "--save_results_to", default=None, help="Directory for per-battle CSV logs." + ) + parser.add_argument( + "--save_trajectories_to", default=None, help="Directory for trajectory files." + ) + args = parser.parse_args() + + import amago + from metamon.env import get_metamon_teams, ChallengeByUsername + from metamon.rl.pretrained import get_pretrained_model + from metamon.rl.metamon_to_amago import make_challenge_env + + # Load model + pretrained = get_pretrained_model(args.model_name) + agent = pretrained.initialize_agent( + checkpoint=args.checkpoint, + log=False, + action_temperature=args.temperature, + ) + agent.env_mode = "sync" + agent.parallel_actors = 1 + agent.verbose = False + + # Load teams + player_team_set = get_metamon_teams(args.format, args.team_set) + + # Create env factory + make_env = partial( + make_challenge_env, + battle_format=args.format, + num_battles=args.n_battles, + observation_space=pretrained.observation_space, + action_space=pretrained.action_space, + reward_function=pretrained.reward_function, + player_team_set=player_team_set, + player_username=args.username, + opponent_username=args.opponent_username, + role=args.role, + battle_backend=args.battle_backend, + save_results_to=args.save_results_to, + save_trajectories_to=args.save_trajectories_to, + print_battle_bar=False, + ) + + # Run battles + results = agent.evaluate_test( + [make_env], + timesteps=args.n_battles * 1000, + episodes=args.n_battles, + ) + print(json.dumps(results, indent=4, sort_keys=True)) + + +if __name__ == "__main__": + main() diff --git a/metamon/rl/evaluate/sweep/__init__.py b/metamon/rl/evaluate/sweep/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/metamon/rl/evaluate/sweep/__main__.py b/metamon/rl/evaluate/sweep/__main__.py new file mode 100644 index 0000000000..e5ea6c55d3 --- /dev/null +++ b/metamon/rl/evaluate/sweep/__main__.py @@ -0,0 +1,108 @@ +""" +Sweep evaluation launcher. + +Usage: + python -m metamon.rl.evaluate.sweep \\ + --config sweep_config.yaml \\ + --gpus 0 1 2 3 \\ + --output_dir ./sweep_results + + # With template variables: + python -m metamon.rl.evaluate.sweep \\ + --config sweep_template.yaml \\ + --model SyntheticRLV2 \\ + --gpus 0 1 --output_dir ./results + +Add --dry_run to preview all matchups without running them. + +Config files may contain ``${var}`` template placeholders that become +extra CLI arguments automatically. See ``metamon.rl.evaluate.common`` +for details. +""" + +import os +import sys +from argparse import ArgumentParser + +from metamon.rl.evaluate.common import add_template_args, get_template_values +from metamon.rl.evaluate.sweep.config import parse_sweep_config +from metamon.rl.evaluate.launch import run_all_matchups +from metamon.rl.evaluate.preview import preview_matchups + + +def main(): + # --- first pass: discover --config so we can scan for template vars --- + pre_parser = ArgumentParser(add_help=False) + pre_parser.add_argument("--config") + pre_args, _ = pre_parser.parse_known_args() + + # --- full parser --- + parser = ArgumentParser( + description="Run sweep evaluation against a fixed opponent." + ) + parser.add_argument("--config", required=True, help="Path to sweep YAML config.") + parser.add_argument( + "--gpus", nargs="+", type=int, required=True, help="GPU IDs to use." + ) + parser.add_argument( + "--output_dir", required=True, help="Directory for results and logs." + ) + parser.add_argument( + "--max_concurrent", + type=int, + default=None, + help="Max concurrent matchups (default = number of GPUs).", + ) + parser.add_argument( + "--timeout", + type=int, + default=3600, + help="Timeout per matchup in seconds (default: 3600 = 1 hour).", + ) + parser.add_argument( + "--acceptor_startup_delay", + type=float, + default=10.0, + help="Seconds to wait for acceptor before launching challenger.", + ) + parser.add_argument( + "--save_trajectories", + action="store_true", + help="Save trajectory files for each matchup.", + ) + parser.add_argument( + "--verbose", action="store_true", help="Stream subprocess output." + ) + parser.add_argument( + "--dry_run", + action="store_true", + help="Preview matchups without running them.", + ) + + # auto-discover template variables and add them as CLI args + template_vars = add_template_args(parser, pre_args.config) + + args = parser.parse_args() + + config_path = os.path.abspath(args.config) + tpl_values = get_template_values(args, template_vars) if template_vars else None + matchups = parse_sweep_config(config_path, template_vars=tpl_values) + + if args.dry_run: + preview_matchups(matchups, mode="sweep", template_values=tpl_values) + return + + run_all_matchups( + matchups=matchups, + gpus=args.gpus, + output_dir=args.output_dir, + max_concurrent=args.max_concurrent, + timeout=args.timeout, + acceptor_startup_delay=args.acceptor_startup_delay, + verbose=args.verbose, + save_trajectories=args.save_trajectories, + ) + + +if __name__ == "__main__": + main() diff --git a/metamon/rl/evaluate/sweep/config.py b/metamon/rl/evaluate/sweep/config.py new file mode 100644 index 0000000000..2819aa9f0d --- /dev/null +++ b/metamon/rl/evaluate/sweep/config.py @@ -0,0 +1,96 @@ +""" +Config parsing for sweep evaluation. + +Sweep mode evaluates one policy across a parameter grid against a fixed opponent. + +Example config: + + battle_format: gen1ou + battles_per_matchup: 50 + + defaults: + team_set: modern_replays_v2 + battle_backend: metamon + + opponent: + model_name: Kadabra + checkpoint: null + temperature: 1.0 + + sweep: + model_name: SyntheticRLV2 + checkpoints: "range(2, 50, 2)" # or a list: [null, 32, 36, 40] + temperatures: "linspace(0.5, 3.0, 6)" # or a list: [1.0, 1.5, 2.0] + +Generates a cartesian product of checkpoints × temperatures, each played +against the fixed opponent. + +Shorthand syntax for sweep values: + - range(start, stop, step) — Python-style, stop exclusive. Best for int checkpoints. + - linspace(start, stop, n) — N evenly-spaced points, both endpoints inclusive. Best for floats. +""" + +import itertools +from typing import Dict, List, Optional + +from metamon.rl.evaluate.common import ( + PolicySpec, + MatchupSpec, + load_config, + build_policy_spec, + merge_defaults, + expand_value_list, +) + + +def parse_sweep_config( + config_path: str, template_vars: Optional[Dict[str, str]] = None +) -> List[MatchupSpec]: + """Parse a sweep YAML config into a list of MatchupSpecs.""" + raw = load_config(config_path, template_vars=template_vars) + defaults = raw.get("defaults", {}) + battle_format = raw["battle_format"] + n_battles = raw.get("battles_per_matchup", 50) + + if "opponent" not in raw: + raise ValueError("sweep config must have an 'opponent' section") + if "sweep" not in raw: + raise ValueError("sweep config must have a 'sweep' section") + + # Build the fixed opponent + opp_config = raw["opponent"] + opp_name = opp_config.get("name", opp_config.get("model_name", "opponent")) + opponent = build_policy_spec(opp_name, opp_config, defaults) + + # Build the sweep grid + sweep_config = raw["sweep"] + sweep_model = sweep_config.get("model_name") + if sweep_model is None: + raise ValueError("sweep section must have 'model_name'") + + base_sweep = { + k: v + for k, v in sweep_config.items() + if k not in ("checkpoints", "temperatures") + } + + checkpoints = expand_value_list(sweep_config.get("checkpoints", [None])) + temperatures = expand_value_list(sweep_config.get("temperatures", [1.0])) + + # Cartesian product + matchups = [] + for ckpt, temp in itertools.product(checkpoints, temperatures): + # Use the bare model name; short_label will append ckpt/temp for display + variant_config = {**base_sweep, "checkpoint": ckpt, "temperature": temp} + policy = build_policy_spec(sweep_model, variant_config, defaults) + + matchups.append( + MatchupSpec( + policy_a=policy, + policy_b=opponent, + n_battles=n_battles, + battle_format=battle_format, + ) + ) + + return matchups diff --git a/metamon/rl/evaluate/sweep/example_config.yaml b/metamon/rl/evaluate/sweep/example_config.yaml new file mode 100644 index 0000000000..e06f117ae6 --- /dev/null +++ b/metamon/rl/evaluate/sweep/example_config.yaml @@ -0,0 +1,17 @@ +# Sweep example: paper SyntheticRLV2 checkpoints vs fixed PokéAgent Kadabra opponent. +battle_format: gen1ou +battles_per_matchup: 50 + +defaults: + team_set: competitive + battle_backend: metamon + +opponent: + model_name: Kadabra + checkpoint: null + temperature: 1.0 + +sweep: + model_name: SyntheticRLV2 + checkpoints: "range(40, 50, 2)" + temperatures: [1.0, 1.5, 2.0] diff --git a/metamon/rl/evaluate/sweep/temperature_sweep_self_play.yaml b/metamon/rl/evaluate/sweep/temperature_sweep_self_play.yaml new file mode 100644 index 0000000000..628f608a4b --- /dev/null +++ b/metamon/rl/evaluate/sweep/temperature_sweep_self_play.yaml @@ -0,0 +1,25 @@ +# Template: sweep sampling temperatures for a paper or PAC model vs itself at temp 1.0. +# +# Usage: +# python -m metamon.rl.evaluate.sweep \ +# --config metamon/rl/evaluate/sweep/temperature_sweep_self_play.yaml \ +# --model Kadabra --format gen1ou --team_set competitive \ +# --gpus 0 1 --output_dir ./results --dry_run + +battle_format: ${format} +battles_per_matchup: ${battles:150} + +defaults: + team_set: ${team_set} + battle_backend: metamon + +opponent: + model_name: ${model} + checkpoint: ${checkpoint:null} + temperature: 1.0 + +sweep: + model_name: ${model} + checkpoints: + - ${checkpoint:null} + temperatures: "linspace(${temp_min:0.25}, ${temp_max:3.0}, ${temp_steps:12})" diff --git a/metamon/rl/experimental/__init__.py b/metamon/rl/experimental/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/metamon/rl/experimental/ensemble/__init__.py b/metamon/rl/experimental/ensemble/__init__.py new file mode 100644 index 0000000000..ce71019085 --- /dev/null +++ b/metamon/rl/experimental/ensemble/__init__.py @@ -0,0 +1,5 @@ +from metamon.rl.experimental.ensemble.ensemble import ( + EnsembleMemberSpec, + HeuristicRouterEnsemblePolicy, + build_heuristic_ensemble_experiment, +) diff --git a/metamon/rl/experimental/ensemble/agents.yaml b/metamon/rl/experimental/ensemble/agents.yaml new file mode 100644 index 0000000000..134e6db512 --- /dev/null +++ b/metamon/rl/experimental/ensemble/agents.yaml @@ -0,0 +1,8 @@ +agents: + PastaMittens: + family: kakuna + preset: dt4_k34_midjudge_switchk28 + + Exeggcute: + family: tauros + preset: tauros62_datajudge5 diff --git a/metamon/rl/experimental/ensemble/ensemble.py b/metamon/rl/experimental/ensemble/ensemble.py new file mode 100644 index 0000000000..56c988c60e --- /dev/null +++ b/metamon/rl/experimental/ensemble/ensemble.py @@ -0,0 +1,1571 @@ +from __future__ import annotations + +import atexit +from collections import deque +from dataclasses import dataclass, field +import json +import math +import os +from typing import Any, Iterable, Optional + +import gymnasium as gym +import numpy as np +import torch +import torch.nn.functional as F +from torch import nn + +EPS = 1e-8 + + +@dataclass(frozen=True) +class EnsembleMemberSpec: + """Configuration for one inference-only ensemble member.""" + + model_name: str + checkpoint: Optional[int] = None + gxe: float = 0.5 + proposer_bias: float = 1.0 + judge_bias: float = 1.0 + shortlist_k: int = 2 + proposal_roles: tuple[str, ...] = () + action_temperature: float = 1.0 + + +@dataclass +class _EnsembleMemberRuntime: + spec: EnsembleMemberSpec + policy: nn.Module + device: torch.device + experiment: Any + + +@dataclass +class _StallTransition: + state_key: tuple[Any, ...] + action: int + reward: float + + +@dataclass +class _StallTrackerState: + transitions: deque[_StallTransition] = field( + default_factory=lambda: deque(maxlen=24) + ) + pending_state_key: Optional[tuple[Any, ...]] = None + pending_action: Optional[int] = None + + +@dataclass +class _EnsembleHiddenState: + member_hidden: list[Any] + stall_trackers: list[_StallTrackerState] + + +@dataclass(frozen=True) +class _ProposerVariant: + proposer_idx: int + role: str + allowed_actions: tuple[int, ...] + weight: float + + +@dataclass +class _AnchorDeviationMetrics: + total_decisions: int = 0 + forced_switch_decisions: int = 0 + unforced_decisions: int = 0 + pre_override_non_anchor: int = 0 + pre_override_non_anchor_forced: int = 0 + pre_override_non_anchor_unforced: int = 0 + final_non_anchor: int = 0 + final_non_anchor_forced: int = 0 + final_non_anchor_unforced: int = 0 + anchor_consensus_swaps: int = 0 + single_judge_decisions: int = 0 + full_rerank_decisions: int = 0 + total_shortlist_size: int = 0 + total_proposer_variants: int = 0 + total_judges: int = 0 + + def to_dict(self) -> dict[str, Any]: + total = self.total_decisions + forced = self.forced_switch_decisions + unforced = self.unforced_decisions + return { + "total_decisions": total, + "forced_switch_decisions": forced, + "unforced_decisions": unforced, + "pre_override_non_anchor": self.pre_override_non_anchor, + "pre_override_non_anchor_forced": self.pre_override_non_anchor_forced, + "pre_override_non_anchor_unforced": self.pre_override_non_anchor_unforced, + "pre_override_non_anchor_rate": ( + self.pre_override_non_anchor / total if total else 0.0 + ), + "final_non_anchor": self.final_non_anchor, + "final_non_anchor_forced": self.final_non_anchor_forced, + "final_non_anchor_unforced": self.final_non_anchor_unforced, + "final_non_anchor_rate": self.final_non_anchor / total if total else 0.0, + "final_non_anchor_rate_forced": ( + self.final_non_anchor_forced / forced if forced else 0.0 + ), + "final_non_anchor_rate_unforced": ( + self.final_non_anchor_unforced / unforced if unforced else 0.0 + ), + "anchor_consensus_swaps": self.anchor_consensus_swaps, + "anchor_consensus_swap_rate": ( + self.anchor_consensus_swaps / total if total else 0.0 + ), + "single_judge_decisions": self.single_judge_decisions, + "single_judge_rate": ( + self.single_judge_decisions / total if total else 0.0 + ), + "full_rerank_decisions": self.full_rerank_decisions, + "full_rerank_rate": (self.full_rerank_decisions / total if total else 0.0), + "avg_shortlist_size": (self.total_shortlist_size / total if total else 0.0), + "avg_proposer_variants": ( + self.total_proposer_variants / total if total else 0.0 + ), + "avg_judges": self.total_judges / total if total else 0.0, + } + + +def _normalize(values: list[float]) -> list[float]: + total = sum(max(v, 0.0) for v in values) + if total <= 0: + return [1.0 / len(values)] * len(values) + return [max(v, 0.0) / total for v in values] + + +def _normalize_with_floor( + values: list[float], anchor_pos: Optional[int], floor: float +) -> list[float]: + weights = _normalize(values) + if anchor_pos is None or not (0 <= anchor_pos < len(weights)): + return weights + floor = min(max(floor, 0.0), 1.0) + if weights[anchor_pos] >= floor: + return weights + if len(weights) == 1: + return [1.0] + remainder = 1.0 - floor + other_total = sum(w for i, w in enumerate(weights) if i != anchor_pos) + if other_total <= EPS: + new_weights = [0.0] * len(weights) + new_weights[anchor_pos] = 1.0 + return new_weights + new_weights = [] + for idx, weight in enumerate(weights): + if idx == anchor_pos: + new_weights.append(floor) + else: + new_weights.append(weight / other_total * remainder) + return new_weights + + +def _zscore(values: torch.Tensor) -> torch.Tensor: + if values.numel() <= 1: + return torch.zeros_like(values) + mean = values.mean() + std = values.std(unbiased=False) + if float(std) < EPS: + return torch.zeros_like(values) + return (values - mean) / std + + +def _space_signature(space: gym.Space) -> Any: + if isinstance(space, gym.spaces.Dict): + return ( + "dict", + tuple((k, _space_signature(v)) for k, v in sorted(space.spaces.items())), + ) + if isinstance(space, gym.spaces.Box): + return ("box", tuple(space.shape), str(space.dtype)) + if isinstance(space, gym.spaces.Discrete): + return ("discrete", int(space.n)) + if isinstance(space, gym.spaces.Text): + return ("text", int(space.max_length), int(space.min_length)) + return (space.__class__.__name__, repr(space)) + + +def _parse_member_devices(num_members: int) -> list[torch.device]: + raw = os.environ.get("METAMON_ENSEMBLE_MEMBER_DEVICES") + if raw: + device_names = [part.strip() for part in raw.split(",") if part.strip()] + elif torch.cuda.is_available(): + device_names = [f"cuda:{i}" for i in range(torch.cuda.device_count())] + else: + device_names = ["cpu"] + if not device_names: + device_names = ["cpu"] + devices = [] + for idx in range(num_members): + name = device_names[idx % len(device_names)] + if name.isdigit(): + name = f"cuda:{name}" + devices.append(torch.device(name)) + return devices + + +class _EnsembleTrajEncoderProxy: + """Mimics the tiny subset of TrajEncoder used by AMAGO eval loops.""" + + def __init__(self, members: list[_EnsembleMemberRuntime]): + self.members = members + + def init_hidden_state(self, batch_size: int, device: torch.device): + return _EnsembleHiddenState( + member_hidden=[ + member.policy.traj_encoder.init_hidden_state(batch_size, member.device) + for member in self.members + ], + stall_trackers=[_StallTrackerState() for _ in range(batch_size)], + ) + + def reset_hidden_state(self, hidden_state, dones): + if hidden_state is None: + return None + if isinstance(dones, torch.Tensor): + dones = dones.detach().cpu().numpy() + dones = np.asarray(dones, dtype=bool) + if isinstance(hidden_state, _EnsembleHiddenState): + member_hidden = hidden_state.member_hidden + stall_trackers = list(hidden_state.stall_trackers) + else: + member_hidden = hidden_state + stall_trackers = [_StallTrackerState() for _ in range(len(dones))] + reset_member_hidden = [ + member.policy.traj_encoder.reset_hidden_state(member_hidden, dones) + for member, member_hidden in zip(self.members, member_hidden) + ] + for idx, done in enumerate(dones.tolist()): + if done: + stall_trackers[idx] = _StallTrackerState() + return _EnsembleHiddenState( + member_hidden=reset_member_hidden, + stall_trackers=stall_trackers, + ) + + +class HeuristicRouterEnsemblePolicy(nn.Module): + """Inference-only proposer/judge ensemble over a fixed set of pretrained policies. + + The router is intentionally heuristic rather than trained: it uses member GXE priors, + per-turn uncertainty, proposer disagreement, and action-count features to decide which + experts should propose candidates and which should judge them. + """ + + def __init__( + self, + members: list[_EnsembleMemberRuntime], + action_dim: int, + ): + super().__init__() + self.members = members + self.action_dim = action_dim + self.traj_encoder = _EnsembleTrajEncoderProxy(members) + self.anchor_idx = 0 + self._anchor_metrics_path = os.environ.get( + "METAMON_ENSEMBLE_DEBUG_METRICS_PATH" + ) + self._anchor_metrics = _AnchorDeviationMetrics() + if self._anchor_metrics_path: + atexit.register(self._flush_anchor_metrics) + + def eval(self): + super().eval() + for member in self.members: + member.policy.eval() + return self + + @staticmethod + def _is_move_action(action: int) -> bool: + return action <= 3 or action >= 9 + + @staticmethod + def _is_switch_action(action: int) -> bool: + return 4 <= action <= 8 + + def _extract_state_summary( + self, + *, + obs: dict[str, torch.Tensor], + time_idxs: torch.Tensor, + batch_idx: int, + ) -> dict[str, float]: + turn_idx = int(time_idxs[batch_idx].reshape(-1)[-1].item()) + summary = { + "turn_idx": float(turn_idx), + "resource_edge": 0.0, + "player_remaining": 0.0, + "opponent_remaining": 0.0, + "player_active_hp": 0.0, + "opponent_active_hp": 0.0, + } + numbers = obs.get("numbers") + if numbers is None: + return summary + current_numbers = numbers[batch_idx, -1].detach().float().cpu() + if current_numbers.numel() < 34: + return summary + + opponent_remaining = float(current_numbers[0].clamp(0.0, 1.0).item() * 6.0) + player_active_hp = float(current_numbers[1].clamp(0.0, 1.0).item()) + switch_hps = [max(float(value.item()), 0.0) for value in current_numbers[28:33]] + player_remaining = float(player_active_hp > 0.02) + sum( + hp > 0.02 for hp in switch_hps + ) + opponent_active_hp = float(current_numbers[33].clamp(0.0, 1.0).item()) + + active_edge = player_active_hp - opponent_active_hp + alive_edge = (player_remaining - opponent_remaining) / 6.0 + reserve_edge = ( + sum(switch_hps) / max(len(switch_hps), 1) - 0.5 if switch_hps else 0.0 + ) + resource_edge = 0.55 * active_edge + 0.35 * alive_edge + 0.10 * reserve_edge + summary.update( + { + "resource_edge": resource_edge, + "player_remaining": player_remaining, + "opponent_remaining": opponent_remaining, + "player_active_hp": player_active_hp, + "opponent_active_hp": opponent_active_hp, + } + ) + return summary + + def _record_anchor_decision( + self, + *, + default_anchor_action: int, + anchor_action: int, + proposed_best_action: int, + final_action: int, + forced_switch: bool, + single_judge: bool, + full_rerank: bool, + shortlist_size: int, + proposer_variant_count: int, + judge_count: int, + ) -> None: + if not self._anchor_metrics_path: + return + metrics = self._anchor_metrics + metrics.total_decisions += 1 + metrics.anchor_consensus_swaps += int(anchor_action != default_anchor_action) + metrics.single_judge_decisions += int(single_judge) + metrics.full_rerank_decisions += int(full_rerank) + metrics.total_shortlist_size += shortlist_size + metrics.total_proposer_variants += proposer_variant_count + metrics.total_judges += judge_count + if forced_switch: + metrics.forced_switch_decisions += 1 + else: + metrics.unforced_decisions += 1 + if proposed_best_action != anchor_action: + metrics.pre_override_non_anchor += 1 + if forced_switch: + metrics.pre_override_non_anchor_forced += 1 + else: + metrics.pre_override_non_anchor_unforced += 1 + if final_action != anchor_action: + metrics.final_non_anchor += 1 + if forced_switch: + metrics.final_non_anchor_forced += 1 + else: + metrics.final_non_anchor_unforced += 1 + if metrics.total_decisions % 50 == 0: + self._flush_anchor_metrics() + + def _flush_anchor_metrics(self) -> None: + if not self._anchor_metrics_path: + return + os.makedirs(os.path.dirname(self._anchor_metrics_path), exist_ok=True) + payload = { + "anchor_model_index": self.anchor_idx, + "anchor_model_name": self.members[self.anchor_idx].spec.model_name, + "anchor_checkpoint": self.members[self.anchor_idx].spec.checkpoint, + **self._anchor_metrics.to_dict(), + } + with open(self._anchor_metrics_path, "w", encoding="utf-8") as f: + json.dump(payload, f, indent=2, sort_keys=True) + + def get_actions( + self, + obs: dict[str, torch.Tensor], + rl2s: torch.Tensor, + time_idxs: torch.Tensor, + hidden_state=None, + sample: bool = True, + ): + batch_size = next(iter(obs.values())).shape[0] + output_device = next(iter(obs.values())).device + if hidden_state is None: + hidden_state = self.traj_encoder.init_hidden_state( + batch_size, output_device + ) + if isinstance(hidden_state, _EnsembleHiddenState): + member_hidden_state = hidden_state.member_hidden + stall_trackers = list(hidden_state.stall_trackers) + else: + member_hidden_state = hidden_state + stall_trackers = [_StallTrackerState() for _ in range(batch_size)] + + member_steps = [] + next_hidden = [] + for member, member_hidden in zip(self.members, member_hidden_state): + member_obs = { + key: value.to(member.device, non_blocking=True) + for key, value in obs.items() + } + member_rl2s = rl2s.to(member.device, non_blocking=True) + member_time_idxs = time_idxs.to(member.device, non_blocking=True) + traj_emb, member_hidden = member.policy.get_state_embedding( + obs=member_obs, + rl2s=member_rl2s, + time_idxs=member_time_idxs, + hidden_state=member_hidden, + ) + straight_from_obs = { + key: member_obs[key] for key in member.policy.pass_obs_keys_to_actor + } + action_dist = member.policy.actor( + traj_emb, + straight_from_obs=straight_from_obs, + ) + probs = action_dist.probs[:, -1, -1, :].detach() + member_steps.append( + { + "member": member, + "obs": member_obs, + "traj_emb": traj_emb[:, -1:, :].detach(), + "probs": probs, + } + ) + next_hidden.append(member_hidden) + + illegal_actions = obs["illegal_actions"][:, -1, :].bool() + actions = [] + for batch_idx in range(batch_size): + legal_actions = ( + (~illegal_actions[batch_idx]).nonzero(as_tuple=True)[0].tolist() + ) + if not legal_actions: + actions.append(0) + continue + if len(legal_actions) == 1: + actions.append(legal_actions[0]) + continue + tracker = stall_trackers[batch_idx] + current_state_key = self._make_cycle_key( + obs=obs, + batch_idx=batch_idx, + legal_actions=legal_actions, + ) + state_summary = self._extract_state_summary( + obs=obs, + time_idxs=time_idxs, + batch_idx=batch_idx, + ) + prev_reward = float(rl2s[batch_idx, -1, 0].item()) + self._finalize_stall_transition( + tracker=tracker, + prev_reward=prev_reward, + ) + chosen = self._choose_action_for_batch( + batch_idx=batch_idx, + legal_actions=legal_actions, + member_steps=member_steps, + sample=sample, + tracker=tracker, + current_state_key=current_state_key, + state_summary=state_summary, + ) + tracker.pending_state_key = current_state_key + tracker.pending_action = chosen + actions.append(chosen) + + actions = torch.tensor(actions, device=output_device, dtype=torch.uint8) + return actions.view(batch_size, 1, 1), _EnsembleHiddenState( + member_hidden=next_hidden, + stall_trackers=stall_trackers, + ) + + def _make_cycle_key( + self, + obs: dict[str, torch.Tensor], + batch_idx: int, + legal_actions: list[int], + ) -> tuple[Any, ...]: + key_parts: list[Any] = [tuple(legal_actions)] + text_tokens = obs.get("text_tokens") + if text_tokens is not None: + key_parts.append( + tuple( + text_tokens[batch_idx, -1].detach().cpu().to(torch.int16).tolist() + ) + ) + numbers = obs.get("numbers") + if numbers is not None: + coarse_numbers = torch.round( + numbers[batch_idx, -1].detach().float().cpu() * 4.0 + ).to(torch.int16) + key_parts.append(tuple(coarse_numbers.tolist())) + return tuple(key_parts) + + def _finalize_stall_transition( + self, + tracker: _StallTrackerState, + prev_reward: float, + ) -> None: + if tracker.pending_state_key is None or tracker.pending_action is None: + return + if not math.isfinite(prev_reward): + prev_reward = 0.0 + tracker.transitions.append( + _StallTransition( + state_key=tracker.pending_state_key, + action=tracker.pending_action, + reward=prev_reward, + ) + ) + tracker.pending_state_key = None + tracker.pending_action = None + + def _stall_penalties( + self, + tracker: _StallTrackerState, + current_state_key: tuple[Any, ...], + forced_switch: bool, + ) -> dict[int, float]: + if forced_switch: + return {} + history = list(tracker.transitions) + if len(history) < 4: + return {} + + penalties: dict[int, float] = {} + reward_cap = 0.08 + mean_reward_cap = 0.025 + + def low_progress(window: list[_StallTransition]) -> bool: + magnitudes = [abs(step.reward) for step in window] + return ( + max(magnitudes, default=0.0) <= reward_cap + and sum(magnitudes) / max(len(magnitudes), 1) <= mean_reward_cap + ) + + # Only react to clearly persistent cycles such as AAA or ABABAB. + for period in (1, 2): + if len(history) < 3 * period: + continue + recent = history[-3 * period :] + blocks = [recent[idx * period : (idx + 1) * period] for idx in range(3)] + state_blocks = [[step.state_key for step in block] for block in blocks] + action_blocks = [[step.action for step in block] for block in blocks] + if not ( + state_blocks[0] == state_blocks[1] == state_blocks[2] + and action_blocks[0] == action_blocks[1] == action_blocks[2] + ): + continue + if current_state_key != state_blocks[-1][0]: + continue + if not low_progress(recent): + continue + cycle_action = action_blocks[-1][0] + penalty = 0.07 + 0.02 * (period == 1) + penalties[cycle_action] = max(penalties.get(cycle_action, 0.0), penalty) + + if len(history) >= 4: + recent = history[-4:] + if ( + all(step.state_key == current_state_key for step in recent) + and len({step.action for step in recent}) == 1 + and low_progress(recent) + ): + repeat_action = recent[-1].action + penalties[repeat_action] = max(penalties.get(repeat_action, 0.0), 0.12) + + return penalties + + def _apply_action_penalties( + self, + shortlist: list[int], + final_scores: torch.Tensor, + penalties: dict[int, float], + ) -> torch.Tensor: + if not penalties: + return final_scores + adjusted = final_scores.clone() + for idx, action in enumerate(shortlist): + adjusted[idx] -= penalties.get(action, 0.0) + return adjusted + + def _build_proposer_variants( + self, + *, + legal_actions: list[int], + proposer_weights: list[float], + features: list[dict[str, Any]], + anchor_action: int, + forced_switch: bool, + ) -> tuple[list[_ProposerVariant], bool]: + move_actions = tuple( + action for action in legal_actions if self._is_move_action(action) + ) + switch_actions = tuple( + action for action in legal_actions if self._is_switch_action(action) + ) + mixed_choice = not forced_switch and bool(move_actions) and bool(switch_actions) + + variants: list[_ProposerVariant] = [] + for proposer_idx, proposer_weight in enumerate(proposer_weights): + if proposer_weight <= 0.0: + continue + info = features[proposer_idx] + counter_anchor_actions = tuple( + action for action in legal_actions if action != anchor_action + ) + if not mixed_choice: + role_scores: list[tuple[str, tuple[int, ...], float]] = [ + ( + "any", + tuple(legal_actions), + 0.80 + 0.20 * info["top_prob"], + ) + ] + if counter_anchor_actions: + role_scores.append( + ( + "counter_anchor", + counter_anchor_actions, + info["counter_anchor_mass"] + * (1.10 if info["top_action"] != anchor_action else 0.90), + ) + ) + total_role_score = sum( + score for _, _, score in role_scores if score > 0.0 + ) + for role, allowed_actions, role_score in role_scores: + if role_score <= 0.0 or total_role_score <= 0.0: + continue + variants.append( + _ProposerVariant( + proposer_idx=proposer_idx, + role=role, + allowed_actions=allowed_actions, + weight=proposer_weight * role_score / total_role_score, + ) + ) + continue + + role_scores = [] + if info["move_mass"] >= 0.15: + role_scores.append( + ( + "move", + move_actions, + info["move_mass"] + * (1.10 if self._is_move_action(info["top_action"]) else 0.90), + ) + ) + if info["switch_mass"] >= 0.15: + role_scores.append( + ( + "switch", + switch_actions, + info["switch_mass"] + * ( + 1.10 if self._is_switch_action(info["top_action"]) else 0.90 + ), + ) + ) + if counter_anchor_actions and info["counter_anchor_mass"] >= 0.20: + role_scores.append( + ( + "counter_anchor", + counter_anchor_actions, + info["counter_anchor_mass"] + * (1.10 if info["top_action"] != anchor_action else 0.85), + ) + ) + if not role_scores: + role_scores = ( + [("counter_anchor", counter_anchor_actions, 1.0)] + if counter_anchor_actions + else [("any", tuple(legal_actions), 1.0)] + ) + allowed_roles = info["spec"].proposal_roles + if allowed_roles: + filtered_role_scores = [ + (role, allowed_actions, role_score) + for role, allowed_actions, role_score in role_scores + if role in allowed_roles + ] + if filtered_role_scores: + role_scores = filtered_role_scores + total_role_score = sum(score for _, _, score in role_scores if score > 0.0) + for role, allowed_actions, role_score in role_scores: + if not allowed_actions or role_score <= 0.0 or total_role_score <= 0.0: + continue + variants.append( + _ProposerVariant( + proposer_idx=proposer_idx, + role=role, + allowed_actions=allowed_actions, + weight=proposer_weight * role_score / total_role_score, + ) + ) + return variants, mixed_choice + + def _select_proposer_variants( + self, + *, + proposer_variants: list[_ProposerVariant], + disagreement: float, + forced_switch: bool, + ) -> list[_ProposerVariant]: + if len(proposer_variants) <= 1: + return proposer_variants + + max_variants = 3 + int(disagreement > 0.30) + int(forced_switch) + if len(proposer_variants) <= max_variants: + selected = proposer_variants + else: + + def variant_score(variant: _ProposerVariant) -> float: + role_bonus = 1.0 + if variant.role == "counter_anchor": + role_bonus += 0.08 + 0.08 * float(disagreement > 0.25) + elif variant.role == "switch": + role_bonus += 0.04 * float(forced_switch) + return variant.weight * role_bonus + + ranked = sorted( + proposer_variants, + key=variant_score, + reverse=True, + ) + selected: list[_ProposerVariant] = [] + seen: set[tuple[int, str]] = set() + + def maybe_add(variant: _ProposerVariant) -> None: + key = (variant.proposer_idx, variant.role) + if key in seen or len(selected) >= max_variants: + return + selected.append(variant) + seen.add(key) + + anchor_variants = [ + variant for variant in ranked if variant.proposer_idx == self.anchor_idx + ] + if anchor_variants: + maybe_add(anchor_variants[0]) + + non_anchor_variants = [ + variant for variant in ranked if variant.proposer_idx != self.anchor_idx + ] + if non_anchor_variants: + maybe_add(non_anchor_variants[0]) + + for variant in ranked: + maybe_add(variant) + if len(selected) >= max_variants: + break + + total_weight = sum(variant.weight for variant in selected) + if total_weight <= 0.0: + return selected + return [ + _ProposerVariant( + proposer_idx=variant.proposer_idx, + role=variant.role, + allowed_actions=variant.allowed_actions, + weight=variant.weight / total_weight, + ) + for variant in selected + ] + + def _masked_action_distribution( + self, + *, + step: dict[str, Any], + batch_idx: int, + allowed_actions: tuple[int, ...], + ) -> tuple[list[int], torch.Tensor]: + allowed_list = list(allowed_actions) + masked_probs = step["probs"][batch_idx, allowed_list].float() + masked_probs = masked_probs / masked_probs.sum().clamp(min=EPS) + ranked_local = torch.argsort(masked_probs, descending=True) + ranked_actions = [allowed_list[idx] for idx in ranked_local.tolist()] + ranked_scores = masked_probs[ranked_local] + return ranked_actions, ranked_scores + + def _shortlist_k_for_variant( + self, + *, + info: dict[str, Any], + proposer_idx: int, + role: str, + allowed_actions: tuple[int, ...], + mixed_choice: bool, + ) -> int: + if role == "move": + shortlist_k = ( + 1 + + int(proposer_idx == self.anchor_idx and len(allowed_actions) >= 2) + + int( + proposer_idx == self.anchor_idx + and len(allowed_actions) >= 3 + and info["entropy"] > 0.45 + ) + ) + elif role == "switch": + shortlist_k = 1 + elif role == "counter_anchor": + shortlist_k = 1 + int( + proposer_idx == self.anchor_idx + and len(allowed_actions) >= 4 + and mixed_choice + ) + else: + shortlist_k = ( + info["spec"].shortlist_k + + int(proposer_idx == self.anchor_idx) + + int(info["entropy"] > 0.45 and proposer_idx == self.anchor_idx) + ) + return min(len(allowed_actions), shortlist_k) + + def _route_shortlist( + self, + *, + legal_actions: list[int], + anchor_action: int, + candidate_support: dict[int, float], + candidate_members: dict[int, set[int]], + candidate_roles: dict[int, set[str]], + features: list[dict[str, Any]], + disagreement: float, + mixed_choice: bool, + anchor_top_prob: float, + state_summary: dict[str, float], + strong_anchor: bool, + ) -> tuple[list[int], dict[int, float]]: + if not candidate_support: + return [anchor_action], {anchor_action: anchor_top_prob} + + actions = sorted(candidate_support) + support_tensor = torch.tensor( + [candidate_support[action] for action in actions], dtype=torch.float32 + ) + member_tensor = torch.tensor( + [len(candidate_members.get(action, set())) for action in actions], + dtype=torch.float32, + ) + role_tensor = torch.tensor( + [len(candidate_roles.get(action, set())) for action in actions], + dtype=torch.float32, + ) + non_anchor_tensor = torch.tensor( + [ + sum( + member_idx != self.anchor_idx + for member_idx in candidate_members.get(action, set()) + ) + for action in actions + ], + dtype=torch.float32, + ) + strength_tensor = torch.tensor( + [ + sum( + features[member_idx]["strength"] + for member_idx in candidate_members.get(action, set()) + ) + for action in actions + ], + dtype=torch.float32, + ) + + router_scores = 0.45 * _zscore(support_tensor) + router_scores = router_scores + 0.25 * _zscore(member_tensor) + router_scores = router_scores + 0.10 * _zscore(role_tensor) + router_scores = router_scores + 0.15 * _zscore(non_anchor_tensor) + router_scores = router_scores + 0.10 * _zscore(strength_tensor) + late_turn = state_summary["turn_idx"] >= 30.0 + desperation = state_summary["resource_edge"] < -0.10 or ( + late_turn and state_summary["resource_edge"] < 0.02 + ) + stabilize = state_summary["resource_edge"] > 0.14 and ( + state_summary["player_remaining"] >= state_summary["opponent_remaining"] + ) + if desperation: + router_scores = router_scores + 0.08 * _zscore(non_anchor_tensor) + if stabilize: + router_scores = router_scores + 0.05 * _zscore(strength_tensor) + if anchor_action in actions: + anchor_bonus = 0.04 + 0.03 * stabilize - 0.02 * desperation + if strong_anchor and not desperation: + anchor_bonus += 0.03 + router_scores[actions.index(anchor_action)] += anchor_bonus + + shortlist_k = min( + len(actions), + 3 + + int(len(legal_actions) >= 5) + + int(disagreement > 0.30) + + int(mixed_choice), + ) + shortlist_k = min( + len(actions), + shortlist_k + int(desperation) + int(late_turn and mixed_choice), + ) + ranked_indices = sorted( + range(len(actions)), + key=lambda idx: ( + float(router_scores[idx].item()), + float(support_tensor[idx].item()), + float(strength_tensor[idx].item()), + float(member_tensor[idx].item()), + -actions[idx], + ), + reverse=True, + ) + shortlist = [actions[idx] for idx in ranked_indices[:shortlist_k]] + + if anchor_action not in shortlist: + if len(shortlist) < shortlist_k: + shortlist.append(anchor_action) + else: + replace_idx = len(shortlist) - 1 + for idx in range(len(shortlist) - 1, -1, -1): + if shortlist[idx] != anchor_action: + replace_idx = idx + break + shortlist[replace_idx] = anchor_action + + if mixed_choice: + for predicate in (self._is_move_action, self._is_switch_action): + if any(predicate(action) for action in shortlist): + continue + candidate_idxs = [ + idx for idx, action in enumerate(actions) if predicate(action) + ] + if not candidate_idxs: + continue + best_idx = max( + candidate_idxs, + key=lambda idx: ( + float(router_scores[idx].item()), + float(support_tensor[idx].item()), + float(strength_tensor[idx].item()), + -actions[idx], + ), + ) + replacement = actions[best_idx] + replace_idx = len(shortlist) - 1 + for idx in range(len(shortlist) - 1, -1, -1): + if shortlist[idx] != anchor_action and not predicate( + shortlist[idx] + ): + replace_idx = idx + break + shortlist[replace_idx] = replacement + + shortlist = list(dict.fromkeys(shortlist)) + proposal_support = { + action: candidate_support.get(action, 0.0) for action in shortlist + } + proposal_support.setdefault(anchor_action, anchor_top_prob) + return shortlist, proposal_support + + def _choose_action_for_batch( + self, + batch_idx: int, + legal_actions: list[int], + member_steps: list[dict[str, Any]], + sample: bool, + tracker: _StallTrackerState, + current_state_key: tuple[Any, ...], + state_summary: dict[str, float], + ) -> int: + features = [] + top_actions = [] + for step in member_steps: + probs = step["probs"][batch_idx, legal_actions].float() + probs = probs / probs.sum().clamp(min=EPS) + entropy = float( + -torch.sum(probs * probs.clamp(min=EPS).log()).item() + / max(math.log(len(legal_actions)), 1.0) + ) + top_idx = int(torch.argmax(probs).item()) + top_action = legal_actions[top_idx] + sorted_probs = torch.sort(probs, descending=True).values + margin = float( + sorted_probs[0].item() - sorted_probs[1].item() + if sorted_probs.numel() > 1 + else 1.0 + ) + features.append( + { + "entropy": entropy, + "certainty": 1.0 - entropy, + "top_prob": float(sorted_probs[0].item()), + "margin": margin, + "top_action": top_action, + "strength": step["member"].spec.gxe, + "spec": step["member"].spec, + } + ) + top_actions.append(top_action) + + anchor = features[self.anchor_idx] + default_anchor_action = anchor["top_action"] + default_anchor_margin = anchor["margin"] + default_anchor_top_prob = anchor["top_prob"] + + vote_weights: dict[int, float] = {} + vote_counts: dict[int, int] = {} + for info in features: + action = info["top_action"] + vote_weights[action] = vote_weights.get(action, 0.0) + ( + 0.80 * info["strength"] + 0.20 * info["certainty"] + ) + vote_counts[action] = vote_counts.get(action, 0) + 1 + consensus_action = max( + sorted(vote_weights), + key=lambda action: ( + vote_counts[action], + vote_weights[action], + -action, + ), + ) + consensus_count = vote_counts[consensus_action] + anchor_action = default_anchor_action + if ( + consensus_action != default_anchor_action + and consensus_count >= 2 + and vote_weights[consensus_action] + > vote_weights.get(default_anchor_action, 0.0) + and default_anchor_top_prob < 0.62 + ): + anchor_action = consensus_action + + anchor_probs = member_steps[self.anchor_idx]["probs"][ + batch_idx, legal_actions + ].float() + anchor_probs = anchor_probs / anchor_probs.sum().clamp(min=EPS) + anchor_prob_map = { + action: float(prob.item()) + for action, prob in zip(legal_actions, anchor_probs) + } + anchor_top_prob = anchor_prob_map.get(anchor_action, default_anchor_top_prob) + if anchor_action == default_anchor_action: + anchor_margin = default_anchor_margin + else: + sorted_anchor_probs = sorted(anchor_prob_map.values(), reverse=True) + anchor_margin = max( + anchor_top_prob + - (sorted_anchor_probs[0] if sorted_anchor_probs else 0.0), + 0.0, + ) + for info, step in zip(features, member_steps): + full_probs = step["probs"][batch_idx, legal_actions].float() + full_probs = full_probs / full_probs.sum().clamp(min=EPS) + info["move_mass"] = float( + sum( + prob.item() + for action, prob in zip(legal_actions, full_probs) + if self._is_move_action(action) + ) + ) + info["switch_mass"] = float( + sum( + prob.item() + for action, prob in zip(legal_actions, full_probs) + if self._is_switch_action(action) + ) + ) + info["counter_anchor_mass"] = float( + sum( + prob.item() + for action, prob in zip(legal_actions, full_probs) + if action != anchor_action + ) + ) + forced_switch = all(action >= 4 for action in legal_actions) + stall_penalties = self._stall_penalties( + tracker=tracker, + current_state_key=current_state_key, + forced_switch=forced_switch, + ) + late_turn = state_summary["turn_idx"] >= 30.0 + desperation = state_summary["resource_edge"] < -0.10 or ( + late_turn and state_summary["resource_edge"] < 0.02 + ) + stabilize = state_summary["resource_edge"] > 0.14 and ( + state_summary["player_remaining"] >= state_summary["opponent_remaining"] + ) + disagreement = 1.0 - ( + max(top_actions.count(action) for action in set(top_actions)) + / max(len(top_actions), 1) + ) + + proposer_scores = [] + judge_scores = [] + for idx, info in enumerate(features): + proposer = info["spec"].proposer_bias * ( + 0.70 * info["strength"] + + 0.20 * info["entropy"] + + 0.10 * float(info["top_action"] != anchor_action) + ) + judge = info["spec"].judge_bias * ( + 0.85 * info["strength"] + 0.15 * info["certainty"] + ) + if forced_switch: + proposer *= 1.05 + judge *= 1.05 + if disagreement > 0.35: + proposer *= 1.05 + if idx == self.anchor_idx: + proposer *= 1.35 + judge *= 1.45 + if desperation: + if idx == self.anchor_idx: + proposer *= 0.95 + judge *= 0.96 + else: + proposer *= 1.04 + 0.06 * float(info["top_action"] != anchor_action) + judge *= 1.02 + if stabilize: + if idx == self.anchor_idx: + proposer *= 1.04 + judge *= 1.08 + elif info["top_action"] != anchor_action: + proposer *= 0.94 + judge *= 0.97 + if info["strength"] < 0.50: + proposer *= 0.75 + judge *= 0.50 + proposer_scores.append(proposer) + judge_scores.append(judge) + + anchor_judge_score = judge_scores[self.anchor_idx] + best_other_judge = max( + ( + judge_scores[idx] + for idx in range(len(member_steps)) + if idx != self.anchor_idx + ), + default=0.0, + ) + anchor_dominant_judge = ( + anchor_judge_score > 0.0 and best_other_judge <= 0.20 * anchor_judge_score + ) + + if anchor_dominant_judge: + num_judges = 1 + elif len(member_steps) <= 3: + num_judges = len(member_steps) + else: + num_judges = min( + len(member_steps), + 2 + + int(disagreement > 0.35 and anchor_margin < 0.15) + + int(desperation and len(member_steps) >= 4), + ) + + judge_order = [self.anchor_idx] + for idx in sorted( + (i for i in range(len(member_steps)) if i != self.anchor_idx), + key=lambda i: judge_scores[i], + reverse=True, + ): + if len(judge_order) >= num_judges: + break + judge_order.append(idx) + + strong_anchor = features[self.anchor_idx]["strength"] >= 0.82 + proposer_floor = 0.18 if desperation else 0.22 + judge_floor = 0.40 if desperation else 0.45 + if strong_anchor and not desperation: + proposer_floor += 0.05 + judge_floor += 0.08 + proposer_weights = _normalize_with_floor( + proposer_scores, + anchor_pos=self.anchor_idx, + floor=proposer_floor, + ) + judge_weights = _normalize_with_floor( + [judge_scores[idx] for idx in judge_order], + anchor_pos=judge_order.index(self.anchor_idx), + floor=judge_floor, + ) + proposer_variants, mixed_choice = self._build_proposer_variants( + legal_actions=legal_actions, + proposer_weights=proposer_weights, + features=features, + anchor_action=anchor_action, + forced_switch=forced_switch, + ) + proposer_variants = self._select_proposer_variants( + proposer_variants=proposer_variants, + disagreement=disagreement, + forced_switch=forced_switch, + ) + + candidate_support: dict[int, float] = {} + candidate_members: dict[int, set[int]] = {} + candidate_roles: dict[int, set[str]] = {} + for variant in proposer_variants: + if not variant.allowed_actions or variant.weight <= 0.0: + continue + proposer_idx = variant.proposer_idx + step = member_steps[proposer_idx] + info = features[proposer_idx] + shortlist_k = self._shortlist_k_for_variant( + info=info, + proposer_idx=proposer_idx, + role=variant.role, + allowed_actions=variant.allowed_actions, + mixed_choice=mixed_choice, + ) + ranked_actions, ranked_scores = self._masked_action_distribution( + step=step, + batch_idx=batch_idx, + allowed_actions=variant.allowed_actions, + ) + for action, action_score in zip( + ranked_actions[:shortlist_k], + ranked_scores[:shortlist_k].tolist(), + ): + candidate_support[action] = candidate_support.get(action, 0.0) + ( + variant.weight * float(action_score) + ) + candidate_members.setdefault(action, set()).add(proposer_idx) + candidate_roles.setdefault(action, set()).add(variant.role) + candidate_support.setdefault(anchor_action, anchor_top_prob) + candidate_members.setdefault(anchor_action, set()).add(self.anchor_idx) + candidate_roles.setdefault(anchor_action, set()).add("anchor_guard") + shortlist, proposal_support = self._route_shortlist( + legal_actions=legal_actions, + anchor_action=anchor_action, + candidate_support=candidate_support, + candidate_members=candidate_members, + candidate_roles=candidate_roles, + features=features, + disagreement=disagreement, + mixed_choice=mixed_choice, + anchor_top_prob=anchor_top_prob, + state_summary=state_summary, + strong_anchor=strong_anchor, + ) + if not shortlist: + shortlist = [consensus_action] + proposal_support = {consensus_action: 1.0} + + shortlist, final_scores = self._judge_shortlist( + batch_idx=batch_idx, + shortlist=shortlist, + judge_order=judge_order, + judge_weights=judge_weights, + member_steps=member_steps, + forced_switch=forced_switch, + proposal_support=proposal_support, + ) + final_scores = self._apply_action_penalties( + shortlist=shortlist, + final_scores=final_scores, + penalties=stall_penalties, + ) + + full_rerank = False + if len(shortlist) < len(legal_actions): + sorted_scores = torch.sort(final_scores, descending=True).values + margin = float( + sorted_scores[0].item() - sorted_scores[1].item() + if sorted_scores.numel() > 1 + else float("inf") + ) + if anchor_dominant_judge or disagreement > 0.25 or margin < 0.16: + shortlist, final_scores = self._judge_shortlist( + batch_idx=batch_idx, + shortlist=legal_actions, + judge_order=judge_order, + judge_weights=judge_weights, + member_steps=member_steps, + forced_switch=forced_switch, + proposal_support=proposal_support, + ) + final_scores = self._apply_action_penalties( + shortlist=shortlist, + final_scores=final_scores, + penalties=stall_penalties, + ) + full_rerank = True + + best_local = int(torch.argmax(final_scores).item()) + best_action = shortlist[best_local] + if best_action == anchor_action: + self._record_anchor_decision( + default_anchor_action=default_anchor_action, + anchor_action=anchor_action, + proposed_best_action=best_action, + final_action=best_action, + forced_switch=forced_switch, + single_judge=len(judge_order) == 1, + full_rerank=full_rerank, + shortlist_size=len(shortlist), + proposer_variant_count=len(proposer_variants), + judge_count=len(judge_order), + ) + return best_action + + anchor_score = float("-inf") + if anchor_action in shortlist: + anchor_score = float(final_scores[shortlist.index(anchor_action)].item()) + best_score = float(final_scores[best_local].item()) + supporting_members = sum( + idx != self.anchor_idx for idx in candidate_members.get(best_action, set()) + ) + supporting_roles = len(candidate_roles.get(best_action, set())) + alt_strength_sum = sum( + features[idx]["strength"] + for idx in candidate_members.get(best_action, set()) + if idx != self.anchor_idx + ) + strong_alt_support = sum( + features[idx]["strength"] >= 0.62 + for idx in candidate_members.get(best_action, set()) + if idx != self.anchor_idx + ) + allow_override = ( + alt_strength_sum >= 1.25 + or ( + alt_strength_sum >= 0.62 + and supporting_roles >= 2 + and anchor_top_prob < 0.58 + and disagreement > 0.18 + and best_score - anchor_score > 0.10 + ) + or ( + supporting_members >= 2 + and supporting_roles >= 2 + and best_score - anchor_score > 0.14 + ) + or ( + desperation + and alt_strength_sum >= 0.62 + and supporting_roles >= 2 + and best_score - anchor_score > 0.08 + ) + ) + if strong_anchor and not desperation: + allow_override = allow_override and ( + alt_strength_sum >= 1.45 + or ( + supporting_members >= 2 + and supporting_roles >= 2 + and best_score - anchor_score > 0.12 + ) + ) + override_margin = 0.05 if anchor_margin < 0.08 else 0.10 + if strong_anchor and not desperation: + override_margin += 0.03 + if len(judge_order) >= 2 and len(shortlist) <= 4 and not full_rerank: + override_margin = max(0.03, override_margin - 0.015) + if supporting_members >= 1 and supporting_roles >= 2: + allow_override = allow_override or ( + alt_strength_sum >= 0.62 and best_score - anchor_score > 0.07 + ) + if desperation: + override_margin = max(0.03, override_margin - 0.03) + if stabilize: + override_margin += 0.02 + if not forced_switch and ( + not allow_override or best_score - anchor_score < override_margin + ): + self._record_anchor_decision( + default_anchor_action=default_anchor_action, + anchor_action=anchor_action, + proposed_best_action=best_action, + final_action=anchor_action, + forced_switch=forced_switch, + single_judge=len(judge_order) == 1, + full_rerank=full_rerank, + shortlist_size=len(shortlist), + proposer_variant_count=len(proposer_variants), + judge_count=len(judge_order), + ) + return anchor_action + self._record_anchor_decision( + default_anchor_action=default_anchor_action, + anchor_action=anchor_action, + proposed_best_action=best_action, + final_action=best_action, + forced_switch=forced_switch, + single_judge=len(judge_order) == 1, + full_rerank=full_rerank, + shortlist_size=len(shortlist), + proposer_variant_count=len(proposer_variants), + judge_count=len(judge_order), + ) + return best_action + + def _judge_shortlist( + self, + batch_idx: int, + shortlist: list[int], + judge_order: list[int], + judge_weights: list[float], + member_steps: list[dict[str, Any]], + forced_switch: bool, + proposal_support: dict[int, float], + ) -> tuple[list[int], torch.Tensor]: + single_judge = len(judge_order) == 1 + if single_judge: + critic_mix = 0.35 if forced_switch else 0.15 + proposal_bonus = 0.18 + else: + critic_mix = 0.25 if forced_switch else 0.10 + proposal_bonus = 0.50 + actor_mix = 1.0 - critic_mix + final_scores = torch.zeros(len(shortlist), dtype=torch.float32) + for judge_weight, judge_idx in zip(judge_weights, judge_order): + step = member_steps[judge_idx] + probs = step["probs"][batch_idx, shortlist].float().cpu() + q_vals = self._score_candidates_with_critic( + step=step, + batch_idx=batch_idx, + shortlist=shortlist, + ).cpu() + member_score = actor_mix * _zscore(probs) + member_score = member_score + critic_mix * _zscore(q_vals) + final_scores += judge_weight * member_score + proposer_scores = torch.tensor( + [proposal_support.get(action, 0.0) for action in shortlist], + dtype=torch.float32, + ) + final_scores += proposal_bonus * _zscore(proposer_scores) + return shortlist, final_scores + + def _score_candidates_with_critic( + self, + step: dict[str, Any], + batch_idx: int, + shortlist: list[int], + ) -> torch.Tensor: + member = step["member"] + policy = member.policy + device = member.device + traj_emb = step["traj_emb"][batch_idx : batch_idx + 1] + num_gammas = len(policy.gammas) + action_tensor = torch.tensor(shortlist, device=device, dtype=torch.long) + one_hot = F.one_hot(action_tensor, num_classes=self.action_dim).float() + one_hot = one_hot.view(1, len(shortlist), 1, 1, self.action_dim) + one_hot = one_hot.repeat(1, 1, 1, num_gammas, 1) + traj_emb = traj_emb.repeat(len(shortlist), 1, 1) + critic_actions = policy.actor.policy_dist.action_from_buffer(one_hot) + critic_values = policy.critics(traj_emb, critic_actions) + if hasattr(policy.critics, "bin_dist_to_raw_vals"): + critic_values = policy.critics.bin_dist_to_raw_vals(critic_values) + else: + critic_values = policy.popart(critic_values, normalized=False) + critic_values = critic_values.mean(dim=3) + return critic_values[0, :, 0, -1, 0].detach() + + +def build_heuristic_ensemble_experiment( + *, + reference_model_name: str, + member_specs: list[EnsembleMemberSpec], + expected_obs_space, + expected_action_space, + log: bool, + action_temperature: float, +): + import gin + from metamon.rl.pretrained import get_pretrained_model + + if not member_specs: + raise ValueError("Ensemble requires at least one member") + devices = _parse_member_devices(len(member_specs)) + expected_obs_sig = _space_signature(expected_obs_space.gym_space) + expected_action_n = expected_action_space.gym_space.n + + runtimes: list[_EnsembleMemberRuntime] = [] + reference_experiment = None + reference_policy = None + + for idx, (spec, device) in enumerate(zip(member_specs, devices)): + builder = get_pretrained_model(spec.model_name) + if builder.action_space.gym_space.n != expected_action_n: + raise ValueError( + f"Ensemble member {spec.model_name} uses {builder.action_space.gym_space.n} actions, " + f"expected {expected_action_n}" + ) + if _space_signature(builder.observation_space.gym_space) != expected_obs_sig: + raise ValueError( + f"Observation space mismatch for {spec.model_name}; only compatible " + "models may be combined in this ensemble." + ) + + checkpoint = spec.checkpoint + if idx == 0: + # Reuse the first compatible member's AMAGO shell so non-Kakuna + # anchors can still participate in the same proposer/judge pipeline. + gin.clear_config() + reference_builder = builder + reference_experiment = reference_builder.initialize_agent( + checkpoint=checkpoint, + log=log, + action_temperature=action_temperature * spec.action_temperature, + ) + reference_policy = reference_experiment.policy + reference_policy.to(device) + reference_policy.eval() + runtimes.append( + _EnsembleMemberRuntime( + spec=spec, + policy=reference_policy, + device=device, + experiment=reference_experiment, + ) + ) + continue + + gin.clear_config() + experiment = builder.initialize_agent( + checkpoint=checkpoint, + log=False, + action_temperature=action_temperature * spec.action_temperature, + ) + policy = experiment.policy + policy.to(device) + policy.eval() + runtimes.append( + _EnsembleMemberRuntime( + spec=spec, + policy=policy, + device=device, + experiment=experiment, + ) + ) + if device.type == "cuda": + torch.cuda.empty_cache() + + assert reference_experiment is not None + ensemble_policy = HeuristicRouterEnsemblePolicy( + members=runtimes, + action_dim=expected_action_n, + ) + if os.environ.get("METAMON_ENSEMBLE_VERBOSE", "").lower() in {"1", "true", "yes"}: + roster = ", ".join( + f"{runtime.spec.model_name}@{runtime.spec.checkpoint or 'default'}->{runtime.device}" + for runtime in runtimes + ) + print(f"Ensemble roster: {roster}") + reference_experiment.policy_aclr = ensemble_policy + reference_experiment.sample_actions_val = False + reference_experiment._ensemble_members = runtimes + reference_experiment._ensemble_policy = ensemble_policy + return reference_experiment diff --git a/metamon/rl/experimental/ensemble/ensemble_presets.json b/metamon/rl/experimental/ensemble/ensemble_presets.json new file mode 100644 index 0000000000..6509efe6a1 --- /dev/null +++ b/metamon/rl/experimental/ensemble/ensemble_presets.json @@ -0,0 +1,1171 @@ +{ + "best_large_sample_dualtemp4": [ + { + "model_name": "Kakuna", + "checkpoint": 28, + "gxe": 0.78, + "proposer_bias": 1.1, + "judge_bias": 1.55, + "shortlist_k": 5, + "proposal_roles": [], + "action_temperature": 0.85 + }, + { + "model_name": "Kakuna", + "checkpoint": 28, + "gxe": 0.78, + "proposer_bias": 1.55, + "judge_bias": 0.01, + "shortlist_k": 4, + "proposal_roles": [ + "move", + "counter_anchor" + ], + "action_temperature": 1.3 + }, + { + "model_name": "Kakuna", + "checkpoint": 30, + "gxe": 0.72, + "proposer_bias": 1.4, + "judge_bias": 0.01, + "shortlist_k": 2, + "proposal_roles": [ + "move", + "counter_anchor" + ], + "action_temperature": 1.15 + }, + { + "model_name": "Alakazam", + "checkpoint": 8, + "gxe": 0.64, + "proposer_bias": 1.45, + "judge_bias": 0.01, + "shortlist_k": 2, + "proposal_roles": [ + "move", + "counter_anchor" + ], + "action_temperature": 1.1 + } + ], + "sparse_superhi": [ + { + "model_name": "Kakuna", + "checkpoint": 34, + "gxe": 0.75, + "proposer_bias": 1.0, + "judge_bias": 1.55, + "shortlist_k": 5, + "proposal_roles": [], + "action_temperature": 0.85 + }, + { + "model_name": "Superkazam", + "checkpoint": 50, + "gxe": 0.7, + "proposer_bias": 0.85, + "judge_bias": 1.8, + "shortlist_k": 3, + "proposal_roles": [], + "action_temperature": 0.95 + }, + { + "model_name": "Kakuna", + "checkpoint": 28, + "gxe": 0.78, + "proposer_bias": 1.55, + "judge_bias": 0.2, + "shortlist_k": 4, + "proposal_roles": [ + "move", + "counter_anchor" + ], + "action_temperature": 1.3 + }, + { + "model_name": "Alakazam", + "checkpoint": 8, + "gxe": 0.66, + "proposer_bias": 1.45, + "judge_bias": 0.05, + "shortlist_k": 2, + "proposal_roles": [ + "move", + "counter_anchor" + ], + "action_temperature": 1.1 + } + ], + "switchmix_superhi": [ + { + "model_name": "Kakuna", + "checkpoint": 34, + "gxe": 0.75, + "proposer_bias": 1.0, + "judge_bias": 1.55, + "shortlist_k": 5, + "proposal_roles": [], + "action_temperature": 0.85 + }, + { + "model_name": "Superkazam", + "checkpoint": 50, + "gxe": 0.7, + "proposer_bias": 0.85, + "judge_bias": 1.8, + "shortlist_k": 3, + "proposal_roles": [], + "action_temperature": 0.95 + }, + { + "model_name": "Kakuna", + "checkpoint": 28, + "gxe": 0.78, + "proposer_bias": 1.55, + "judge_bias": 0.2, + "shortlist_k": 4, + "proposal_roles": [ + "move", + "switch", + "counter_anchor" + ], + "action_temperature": 1.3 + }, + { + "model_name": "Alakazam", + "checkpoint": 8, + "gxe": 0.66, + "proposer_bias": 1.45, + "judge_bias": 0.05, + "shortlist_k": 2, + "proposal_roles": [ + "move", + "switch", + "counter_anchor" + ], + "action_temperature": 1.1 + } + ], + "hybrid_k28_superhi": [ + { + "model_name": "Kakuna", + "checkpoint": 28, + "gxe": 0.78, + "proposer_bias": 1.1, + "judge_bias": 1.55, + "shortlist_k": 5, + "proposal_roles": [], + "action_temperature": 0.85 + }, + { + "model_name": "Superkazam", + "checkpoint": 50, + "gxe": 0.7, + "proposer_bias": 0.85, + "judge_bias": 1.8, + "shortlist_k": 3, + "proposal_roles": [], + "action_temperature": 0.95 + }, + { + "model_name": "Kakuna", + "checkpoint": 28, + "gxe": 0.78, + "proposer_bias": 1.55, + "judge_bias": 0.2, + "shortlist_k": 4, + "proposal_roles": [ + "move", + "counter_anchor" + ], + "action_temperature": 1.3 + }, + { + "model_name": "Alakazam", + "checkpoint": 8, + "gxe": 0.66, + "proposer_bias": 1.45, + "judge_bias": 0.05, + "shortlist_k": 2, + "proposal_roles": [ + "move", + "counter_anchor" + ], + "action_temperature": 1.1 + } + ], + "k34_superjudge": [ + { + "model_name": "Kakuna", + "checkpoint": 34, + "gxe": 0.75, + "proposer_bias": 1.05, + "judge_bias": 1.45, + "shortlist_k": 5, + "proposal_roles": [], + "action_temperature": 0.85 + }, + { + "model_name": "Superkazam", + "checkpoint": 50, + "gxe": 0.7, + "proposer_bias": 0.8, + "judge_bias": 1.85, + "shortlist_k": 3, + "proposal_roles": [], + "action_temperature": 0.95 + }, + { + "model_name": "Kakuna", + "checkpoint": 28, + "gxe": 0.78, + "proposer_bias": 1.55, + "judge_bias": 0.05, + "shortlist_k": 4, + "proposal_roles": [ + "move", + "counter_anchor" + ], + "action_temperature": 1.3 + }, + { + "model_name": "Kakuna", + "checkpoint": 30, + "gxe": 0.72, + "proposer_bias": 1.35, + "judge_bias": 0.05, + "shortlist_k": 2, + "proposal_roles": [ + "move", + "counter_anchor" + ], + "action_temperature": 1.15 + }, + { + "model_name": "Alakazam", + "checkpoint": 8, + "gxe": 0.64, + "proposer_bias": 1.25, + "judge_bias": 0.01, + "shortlist_k": 2, + "proposal_roles": [ + "move", + "counter_anchor" + ], + "action_temperature": 1.1 + } + ], + "k28_superjudge": [ + { + "model_name": "Kakuna", + "checkpoint": 28, + "gxe": 0.78, + "proposer_bias": 1.1, + "judge_bias": 1.55, + "shortlist_k": 5, + "proposal_roles": [], + "action_temperature": 0.85 + }, + { + "model_name": "Superkazam", + "checkpoint": 50, + "gxe": 0.7, + "proposer_bias": 0.8, + "judge_bias": 1.85, + "shortlist_k": 3, + "proposal_roles": [], + "action_temperature": 0.95 + }, + { + "model_name": "Kakuna", + "checkpoint": 28, + "gxe": 0.78, + "proposer_bias": 1.55, + "judge_bias": 0.05, + "shortlist_k": 4, + "proposal_roles": [ + "move", + "counter_anchor" + ], + "action_temperature": 1.3 + }, + { + "model_name": "Kakuna", + "checkpoint": 30, + "gxe": 0.72, + "proposer_bias": 1.35, + "judge_bias": 0.05, + "shortlist_k": 2, + "proposal_roles": [ + "move", + "counter_anchor" + ], + "action_temperature": 1.15 + }, + { + "model_name": "Alakazam", + "checkpoint": 8, + "gxe": 0.64, + "proposer_bias": 1.25, + "judge_bias": 0.01, + "shortlist_k": 2, + "proposal_roles": [ + "move", + "counter_anchor" + ], + "action_temperature": 1.1 + } + ], + "dt4_k34_midjudge": [ + { + "model_name": "Kakuna", + "checkpoint": 28, + "gxe": 0.78, + "proposer_bias": 1.1, + "judge_bias": 1.55, + "shortlist_k": 5, + "proposal_roles": [], + "action_temperature": 0.85 + }, + { + "model_name": "Kakuna", + "checkpoint": 34, + "gxe": 0.75, + "proposer_bias": 0.1, + "judge_bias": 0.8, + "shortlist_k": 4, + "proposal_roles": [], + "action_temperature": 0.9 + }, + { + "model_name": "Kakuna", + "checkpoint": 28, + "gxe": 0.78, + "proposer_bias": 1.55, + "judge_bias": 0.01, + "shortlist_k": 4, + "proposal_roles": [ + "move", + "counter_anchor" + ], + "action_temperature": 1.3 + }, + { + "model_name": "Kakuna", + "checkpoint": 30, + "gxe": 0.72, + "proposer_bias": 1.4, + "judge_bias": 0.01, + "shortlist_k": 2, + "proposal_roles": [ + "move", + "counter_anchor" + ], + "action_temperature": 1.15 + }, + { + "model_name": "Alakazam", + "checkpoint": 8, + "gxe": 0.64, + "proposer_bias": 1.45, + "judge_bias": 0.01, + "shortlist_k": 2, + "proposal_roles": [ + "move", + "counter_anchor" + ], + "action_temperature": 1.1 + } + ], + "dt4_k34_midjudge_superprop": [ + { + "model_name": "Kakuna", + "checkpoint": 28, + "gxe": 0.78, + "proposer_bias": 1.1, + "judge_bias": 1.55, + "shortlist_k": 5, + "proposal_roles": [], + "action_temperature": 0.85 + }, + { + "model_name": "Kakuna", + "checkpoint": 34, + "gxe": 0.75, + "proposer_bias": 0.1, + "judge_bias": 0.8, + "shortlist_k": 4, + "proposal_roles": [], + "action_temperature": 0.9 + }, + { + "model_name": "Kakuna", + "checkpoint": 28, + "gxe": 0.78, + "proposer_bias": 1.55, + "judge_bias": 0.01, + "shortlist_k": 4, + "proposal_roles": [ + "move", + "counter_anchor" + ], + "action_temperature": 1.3 + }, + { + "model_name": "Kakuna", + "checkpoint": 30, + "gxe": 0.72, + "proposer_bias": 1.4, + "judge_bias": 0.01, + "shortlist_k": 2, + "proposal_roles": [ + "move", + "counter_anchor" + ], + "action_temperature": 1.15 + }, + { + "model_name": "Superkazam", + "checkpoint": 50, + "gxe": 0.7, + "proposer_bias": 1.2, + "judge_bias": 0.01, + "shortlist_k": 2, + "proposal_roles": [ + "move", + "counter_anchor" + ], + "action_temperature": 1.0 + } + ], + "dt4_k34_midjudge_superprop_switchk28": [ + { + "model_name": "Kakuna", + "checkpoint": 28, + "gxe": 0.78, + "proposer_bias": 1.1, + "judge_bias": 1.55, + "shortlist_k": 5, + "proposal_roles": [], + "action_temperature": 0.85 + }, + { + "model_name": "Kakuna", + "checkpoint": 34, + "gxe": 0.75, + "proposer_bias": 0.1, + "judge_bias": 0.8, + "shortlist_k": 4, + "proposal_roles": [], + "action_temperature": 0.9 + }, + { + "model_name": "Kakuna", + "checkpoint": 28, + "gxe": 0.78, + "proposer_bias": 1.55, + "judge_bias": 0.01, + "shortlist_k": 4, + "proposal_roles": [ + "move", + "switch", + "counter_anchor" + ], + "action_temperature": 1.3 + }, + { + "model_name": "Kakuna", + "checkpoint": 30, + "gxe": 0.72, + "proposer_bias": 1.4, + "judge_bias": 0.01, + "shortlist_k": 2, + "proposal_roles": [ + "move", + "counter_anchor" + ], + "action_temperature": 1.15 + }, + { + "model_name": "Superkazam", + "checkpoint": 50, + "gxe": 0.7, + "proposer_bias": 1.2, + "judge_bias": 0.01, + "shortlist_k": 2, + "proposal_roles": [ + "move", + "counter_anchor" + ], + "action_temperature": 1.0 + } + ], + "dt4_k34_midjudge_dualprop6": [ + { + "model_name": "Kakuna", + "checkpoint": 28, + "gxe": 0.78, + "proposer_bias": 1.1, + "judge_bias": 1.55, + "shortlist_k": 5, + "proposal_roles": [], + "action_temperature": 0.85 + }, + { + "model_name": "Kakuna", + "checkpoint": 34, + "gxe": 0.75, + "proposer_bias": 0.15, + "judge_bias": 0.85, + "shortlist_k": 4, + "proposal_roles": [], + "action_temperature": 0.9 + }, + { + "model_name": "Kakuna", + "checkpoint": 28, + "gxe": 0.78, + "proposer_bias": 1.5, + "judge_bias": 0.01, + "shortlist_k": 4, + "proposal_roles": [ + "move", + "switch", + "counter_anchor" + ], + "action_temperature": 1.25 + }, + { + "model_name": "Kakuna", + "checkpoint": 30, + "gxe": 0.72, + "proposer_bias": 1.3, + "judge_bias": 0.01, + "shortlist_k": 2, + "proposal_roles": [ + "move", + "counter_anchor" + ], + "action_temperature": 1.1 + }, + { + "model_name": "Superkazam", + "checkpoint": 50, + "gxe": 0.7, + "proposer_bias": 1.15, + "judge_bias": 0.12, + "shortlist_k": 2, + "proposal_roles": [ + "move", + "counter_anchor" + ], + "action_temperature": 1.0 + }, + { + "model_name": "Alakazam", + "checkpoint": 8, + "gxe": 0.64, + "proposer_bias": 1.15, + "judge_bias": 0.01, + "shortlist_k": 2, + "proposal_roles": [ + "move", + "counter_anchor" + ], + "action_temperature": 1.05 + } + ], + "dt4_k34_midjudge_switchk28": [ + { + "model_name": "Kakuna", + "checkpoint": 28, + "gxe": 0.78, + "proposer_bias": 1.1, + "judge_bias": 1.55, + "shortlist_k": 5, + "proposal_roles": [], + "action_temperature": 0.85 + }, + { + "model_name": "Kakuna", + "checkpoint": 34, + "gxe": 0.75, + "proposer_bias": 0.1, + "judge_bias": 0.8, + "shortlist_k": 4, + "proposal_roles": [], + "action_temperature": 0.9 + }, + { + "model_name": "Kakuna", + "checkpoint": 28, + "gxe": 0.78, + "proposer_bias": 1.55, + "judge_bias": 0.01, + "shortlist_k": 4, + "proposal_roles": [ + "move", + "switch", + "counter_anchor" + ], + "action_temperature": 1.3 + }, + { + "model_name": "Kakuna", + "checkpoint": 30, + "gxe": 0.72, + "proposer_bias": 1.4, + "judge_bias": 0.01, + "shortlist_k": 2, + "proposal_roles": [ + "move", + "counter_anchor" + ], + "action_temperature": 1.15 + }, + { + "model_name": "Alakazam", + "checkpoint": 8, + "gxe": 0.64, + "proposer_bias": 1.45, + "judge_bias": 0.01, + "shortlist_k": 2, + "proposal_roles": [ + "move", + "counter_anchor" + ], + "action_temperature": 1.1 + } + ], + "dt4_k34_midjudge_switchspec": [ + { + "model_name": "Kakuna", + "checkpoint": 28, + "gxe": 0.78, + "proposer_bias": 1.1, + "judge_bias": 1.55, + "shortlist_k": 5, + "proposal_roles": [], + "action_temperature": 0.85 + }, + { + "model_name": "Kakuna", + "checkpoint": 34, + "gxe": 0.75, + "proposer_bias": 0.1, + "judge_bias": 0.8, + "shortlist_k": 4, + "proposal_roles": [], + "action_temperature": 0.9 + }, + { + "model_name": "Kakuna", + "checkpoint": 28, + "gxe": 0.78, + "proposer_bias": 1.55, + "judge_bias": 0.01, + "shortlist_k": 4, + "proposal_roles": [ + "switch", + "counter_anchor" + ], + "action_temperature": 1.3 + }, + { + "model_name": "Kakuna", + "checkpoint": 30, + "gxe": 0.72, + "proposer_bias": 1.4, + "judge_bias": 0.01, + "shortlist_k": 2, + "proposal_roles": [ + "move", + "counter_anchor" + ], + "action_temperature": 1.15 + }, + { + "model_name": "Alakazam", + "checkpoint": 8, + "gxe": 0.64, + "proposer_bias": 1.45, + "judge_bias": 0.01, + "shortlist_k": 2, + "proposal_roles": [ + "move", + "counter_anchor" + ], + "action_temperature": 1.1 + } + ], + "tauros62_dualtemp5": [ + { + "model_name": "TaurosV5", + "checkpoint": 62, + "gxe": 0.86, + "proposer_bias": 1.05, + "judge_bias": 1.65, + "shortlist_k": 5, + "proposal_roles": [], + "action_temperature": 0.82 + }, + { + "model_name": "TaurosV5", + "checkpoint": 62, + "gxe": 0.86, + "proposer_bias": 1.65, + "judge_bias": 0.05, + "shortlist_k": 4, + "proposal_roles": [ + "move", + "switch", + "counter_anchor" + ], + "action_temperature": 1.22 + }, + { + "model_name": "TaurosV5", + "checkpoint": 66, + "gxe": 0.83, + "proposer_bias": 1.15, + "judge_bias": 0.55, + "shortlist_k": 3, + "proposal_roles": [ + "move", + "counter_anchor" + ], + "action_temperature": 0.98 + }, + { + "model_name": "V2AGroupedV2DataAblation", + "checkpoint": 90, + "gxe": 0.79, + "proposer_bias": 1.35, + "judge_bias": 0.2, + "shortlist_k": 3, + "proposal_roles": [ + "move", + "switch", + "counter_anchor" + ], + "action_temperature": 1.08 + }, + { + "model_name": "V2AGroupedV2ISFilter", + "checkpoint": 88, + "gxe": 0.77, + "proposer_bias": 1.1, + "judge_bias": 0.6, + "shortlist_k": 3, + "proposal_roles": [ + "move", + "counter_anchor" + ], + "action_temperature": 1.02 + } + ], + "tauros62_datajudge5": [ + { + "model_name": "TaurosV5", + "checkpoint": 62, + "gxe": 0.86, + "proposer_bias": 1.0, + "judge_bias": 1.7, + "shortlist_k": 5, + "proposal_roles": [], + "action_temperature": 0.82 + }, + { + "model_name": "TaurosV5", + "checkpoint": 62, + "gxe": 0.86, + "proposer_bias": 1.55, + "judge_bias": 0.05, + "shortlist_k": 4, + "proposal_roles": [ + "move", + "switch", + "counter_anchor" + ], + "action_temperature": 1.18 + }, + { + "model_name": "V2AGroupedV2DataAblation", + "checkpoint": 90, + "gxe": 0.79, + "proposer_bias": 0.75, + "judge_bias": 1.1, + "shortlist_k": 4, + "proposal_roles": [], + "action_temperature": 0.95 + }, + { + "model_name": "V2AGroupedV2DataAblation", + "checkpoint": 90, + "gxe": 0.79, + "proposer_bias": 1.3, + "judge_bias": 0.05, + "shortlist_k": 3, + "proposal_roles": [ + "move", + "switch", + "counter_anchor" + ], + "action_temperature": 1.08 + }, + { + "model_name": "TaurosV5", + "checkpoint": 64, + "gxe": 0.84, + "proposer_bias": 1.1, + "judge_bias": 0.15, + "shortlist_k": 3, + "proposal_roles": [ + "move", + "counter_anchor" + ], + "action_temperature": 1.0 + } + ], + "tauros62_dualtemp6": [ + { + "model_name": "TaurosV5", + "checkpoint": 62, + "gxe": 0.86, + "proposer_bias": 1.05, + "judge_bias": 1.6, + "shortlist_k": 5, + "proposal_roles": [], + "action_temperature": 0.82 + }, + { + "model_name": "TaurosV5", + "checkpoint": 62, + "gxe": 0.86, + "proposer_bias": 1.6, + "judge_bias": 0.05, + "shortlist_k": 4, + "proposal_roles": [ + "move", + "switch", + "counter_anchor" + ], + "action_temperature": 1.22 + }, + { + "model_name": "TaurosV5", + "checkpoint": 66, + "gxe": 0.83, + "proposer_bias": 1.1, + "judge_bias": 0.55, + "shortlist_k": 3, + "proposal_roles": [ + "move", + "counter_anchor" + ], + "action_temperature": 1.0 + }, + { + "model_name": "V2AGroupedV2DataAblation", + "checkpoint": 90, + "gxe": 0.79, + "proposer_bias": 1.25, + "judge_bias": 0.2, + "shortlist_k": 3, + "proposal_roles": [ + "move", + "switch", + "counter_anchor" + ], + "action_temperature": 1.08 + }, + { + "model_name": "V2AGroupedV2ISFilter", + "checkpoint": 88, + "gxe": 0.77, + "proposer_bias": 0.95, + "judge_bias": 0.65, + "shortlist_k": 3, + "proposal_roles": [], + "action_temperature": 0.98 + }, + { + "model_name": "V2AGroupedV2Patched", + "checkpoint": 98, + "gxe": 0.74, + "proposer_bias": 1.15, + "judge_bias": 0.1, + "shortlist_k": 2, + "proposal_roles": [ + "move", + "counter_anchor" + ], + "action_temperature": 1.08 + } + ], + "tauros62_taurosonly5": [ + { + "model_name": "TaurosV5", + "checkpoint": 62, + "gxe": 0.86, + "proposer_bias": 1.0, + "judge_bias": 1.7, + "shortlist_k": 5, + "proposal_roles": [], + "action_temperature": 0.82 + }, + { + "model_name": "TaurosV5", + "checkpoint": 62, + "gxe": 0.86, + "proposer_bias": 1.65, + "judge_bias": 0.05, + "shortlist_k": 4, + "proposal_roles": [ + "move", + "switch", + "counter_anchor" + ], + "action_temperature": 1.22 + }, + { + "model_name": "TaurosV5", + "checkpoint": 64, + "gxe": 0.84, + "proposer_bias": 1.15, + "judge_bias": 0.65, + "shortlist_k": 3, + "proposal_roles": [], + "action_temperature": 0.92 + }, + { + "model_name": "TaurosV5", + "checkpoint": 64, + "gxe": 0.84, + "proposer_bias": 1.25, + "judge_bias": 0.05, + "shortlist_k": 3, + "proposal_roles": [ + "move", + "counter_anchor" + ], + "action_temperature": 1.05 + }, + { + "model_name": "TaurosV5", + "checkpoint": 66, + "gxe": 0.82, + "proposer_bias": 1.1, + "judge_bias": 0.15, + "shortlist_k": 2, + "proposal_roles": [ + "move", + "counter_anchor" + ], + "action_temperature": 1.02 + } + ], + "tauros62_dataanchor5": [ + { + "model_name": "V2AGroupedV2DataAblation", + "checkpoint": 90, + "gxe": 0.88, + "proposer_bias": 0.85, + "judge_bias": 1.7, + "shortlist_k": 5, + "proposal_roles": [], + "action_temperature": 0.84 + }, + { + "model_name": "TaurosV5", + "checkpoint": 62, + "gxe": 0.86, + "proposer_bias": 1.0, + "judge_bias": 1.05, + "shortlist_k": 4, + "proposal_roles": [], + "action_temperature": 0.88 + }, + { + "model_name": "TaurosV5", + "checkpoint": 62, + "gxe": 0.86, + "proposer_bias": 1.6, + "judge_bias": 0.05, + "shortlist_k": 4, + "proposal_roles": [ + "move", + "switch", + "counter_anchor" + ], + "action_temperature": 1.2 + }, + { + "model_name": "V2AGroupedV2DataAblation", + "checkpoint": 90, + "gxe": 0.88, + "proposer_bias": 1.35, + "judge_bias": 0.05, + "shortlist_k": 4, + "proposal_roles": [ + "move", + "switch", + "counter_anchor" + ], + "action_temperature": 1.08 + }, + { + "model_name": "TaurosV5", + "checkpoint": 64, + "gxe": 0.84, + "proposer_bias": 1.15, + "judge_bias": 0.25, + "shortlist_k": 3, + "proposal_roles": [ + "move", + "counter_anchor" + ], + "action_temperature": 1.0 + } + ], + "tauros62_temporal5": [ + { + "model_name": "TaurosV5", + "checkpoint": 62, + "gxe": 0.86, + "proposer_bias": 1.0, + "judge_bias": 1.7, + "shortlist_k": 5, + "proposal_roles": [], + "action_temperature": 0.82 + }, + { + "model_name": "TaurosV5", + "checkpoint": 62, + "gxe": 0.86, + "proposer_bias": 1.65, + "judge_bias": 0.05, + "shortlist_k": 4, + "proposal_roles": [ + "move", + "switch", + "counter_anchor" + ], + "action_temperature": 1.22 + }, + { + "model_name": "TaurosV5", + "checkpoint": 60, + "gxe": 0.82, + "proposer_bias": 1.05, + "judge_bias": 0.65, + "shortlist_k": 3, + "proposal_roles": [], + "action_temperature": 0.9 + }, + { + "model_name": "TaurosV5", + "checkpoint": 64, + "gxe": 0.84, + "proposer_bias": 1.2, + "judge_bias": 0.1, + "shortlist_k": 3, + "proposal_roles": [ + "move", + "counter_anchor" + ], + "action_temperature": 1.02 + }, + { + "model_name": "TaurosV5", + "checkpoint": 68, + "gxe": 0.8, + "proposer_bias": 1.15, + "judge_bias": 0.1, + "shortlist_k": 3, + "proposal_roles": [ + "move", + "switch", + "counter_anchor" + ], + "action_temperature": 1.06 + } + ], + "tauros62_wide6": [ + { + "model_name": "TaurosV5", + "checkpoint": 62, + "gxe": 0.86, + "proposer_bias": 1.0, + "judge_bias": 1.7, + "shortlist_k": 5, + "proposal_roles": [], + "action_temperature": 0.82 + }, + { + "model_name": "TaurosV5", + "checkpoint": 62, + "gxe": 0.86, + "proposer_bias": 1.6, + "judge_bias": 0.05, + "shortlist_k": 4, + "proposal_roles": [ + "move", + "switch", + "counter_anchor" + ], + "action_temperature": 1.2 + }, + { + "model_name": "TaurosV5", + "checkpoint": 60, + "gxe": 0.82, + "proposer_bias": 1.0, + "judge_bias": 0.6, + "shortlist_k": 3, + "proposal_roles": [], + "action_temperature": 0.9 + }, + { + "model_name": "TaurosV5", + "checkpoint": 64, + "gxe": 0.84, + "proposer_bias": 1.15, + "judge_bias": 0.1, + "shortlist_k": 3, + "proposal_roles": [ + "move", + "counter_anchor" + ], + "action_temperature": 1.02 + }, + { + "model_name": "TaurosV5", + "checkpoint": 68, + "gxe": 0.8, + "proposer_bias": 1.1, + "judge_bias": 0.1, + "shortlist_k": 3, + "proposal_roles": [ + "move", + "switch", + "counter_anchor" + ], + "action_temperature": 1.06 + }, + { + "model_name": "TaurosV5", + "checkpoint": 70, + "gxe": 0.78, + "proposer_bias": 1.0, + "judge_bias": 0.05, + "shortlist_k": 2, + "proposal_roles": [ + "counter_anchor" + ], + "action_temperature": 1.08 + } + ] +} diff --git a/metamon/rl/experimental/ensemble/register.py b/metamon/rl/experimental/ensemble/register.py new file mode 100644 index 0000000000..ddef286074 --- /dev/null +++ b/metamon/rl/experimental/ensemble/register.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +import json +from pathlib import Path +from typing import Type + +import yaml + +from metamon.rl.experimental.ensemble import EnsembleMemberSpec + +_AGENTS_PATH = Path(__file__).with_name("agents.yaml") +_PRESETS_PATH = Path(__file__).with_name("ensemble_presets.json") + +_FAMILY_BASES: dict[str, Type] = {} + + +def _load_presets() -> dict[str, list[EnsembleMemberSpec]]: + raw_presets = json.loads(_PRESETS_PATH.read_text()) + return { + name: [ + EnsembleMemberSpec( + **{ + **spec, + "proposal_roles": tuple(spec.get("proposal_roles", [])), + } + ) + for spec in member_specs + ] + for name, member_specs in raw_presets.items() + } + + +def _make_nickname_class( + nickname: str, + base_cls: Type, + member_specs: list[EnsembleMemberSpec], +) -> Type: + class NicknamedEnsemble(base_cls): + MEMBER_SPECS = member_specs + + @classmethod + def _member_specs_from_env(cls) -> list[EnsembleMemberSpec]: + return cls.MEMBER_SPECS + + NicknamedEnsemble.__name__ = nickname + NicknamedEnsemble.__qualname__ = nickname + NicknamedEnsemble.__module__ = __name__ + return NicknamedEnsemble + + +def register_nickname_agents() -> None: + from metamon.rl.pretrained import ( + KakunaEnsemble, + TaurosEnsemble, + pretrained_model, + ) + + global _FAMILY_BASES + _FAMILY_BASES = { + "kakuna": KakunaEnsemble, + "tauros": TaurosEnsemble, + } + + presets = _load_presets() + config = yaml.safe_load(_AGENTS_PATH.read_text()) + for nickname, spec in config["agents"].items(): + family = spec["family"] + preset_name = spec["preset"] + if family not in _FAMILY_BASES: + raise ValueError(f"Unknown ensemble family '{family}' for agent {nickname}") + if preset_name not in presets: + raise ValueError( + f"Unknown preset '{preset_name}' for agent {nickname} " + f"(available: {sorted(presets)})" + ) + base_cls = _FAMILY_BASES[family] + agent_cls = _make_nickname_class(nickname, base_cls, presets[preset_name]) + pretrained_model(nickname)(agent_cls) + + +register_nickname_agents() diff --git a/metamon/rl/finetune.py b/metamon/rl/finetune.py new file mode 100644 index 0000000000..5f3fa8003e --- /dev/null +++ b/metamon/rl/finetune.py @@ -0,0 +1,266 @@ +"""Iterative finetuning with MetamonFinetuneAgent (tortoise EMA + IS correction). + +Two independent knobs (example: finetuning ``Kakuna``): + +- **Data** — ``--dataset_config`` YAML (``replay_weight``, ``self_play``, + ``custom_replays``, optional ``prev_dataset`` / ``anneal_epochs``). +- **Weights** — omit ``--prev_run_*`` to start from HuggingFace; set + ``--prev_run_dir/name/checkpoint`` to continue a prior finetune run. + +``--base_model`` is always required (architecture, obs/action spaces, tokenizer). +See ``metamon.rl.dataset_config`` and ``metamon/rl/configs/datasets/`` for YAML +details. The repo ships ``self_play_dset.yaml`` as a starting mix. + +Typical loop: finetune from HF → collect self-play → finetune again with new +YAML + ``--prev_run_*``. + +**Iter 1 — HuggingFace weights**:: + + python -m metamon.rl.finetune \\ + --run_name kakuna_iter1 --save_dir /path/to/ckpts \\ + --base_model Kakuna --dataset_config self_play_dset.yaml --log + +**Collect self-play** (point ``custom_replays[].dir`` at the output pile in the +next YAML):: + + python -m metamon.rl.evaluate.ladder_self_play \\ + --config metamon/rl/evaluate/ladder_self_play/example_config.yaml \\ + --format gen1ou --gpus 0 1 2 3 \\ + --save_trajectories_to /path/to/kakuna_iter2_pile + +**Iter 2+ — local weights + new YAML**:: + + python -m metamon.rl.finetune \\ + --run_name kakuna_iter2 --save_dir /path/to/ckpts \\ + --base_model Kakuna \\ + --prev_run_dir /path/to/ckpts --prev_run_name kakuna_iter1 \\ + --prev_checkpoint 10 --dataset_config my_iter2.yaml --log + +Example ``my_iter2.yaml`` (save under ``metamon/rl/configs/datasets/`` or pass +an absolute path):: + + replay_weight: 0.05 + prev_dataset: /path/to/ckpts/kakuna_iter1/dataset_config.yaml + prev_weight: 0.75 + custom_replays: + - dir: /path/to/kakuna_iter2_pile + weight: 0.20 + anneal_epochs: 10 + formats: + - gen1ou + +Each run saves a flattened config to +``{save_dir}/{run_name}/dataset_config.yaml`` — reuse that path as ``prev_dataset`` +on the next iteration. +""" + +import os + +import wandb + +import metamon +from metamon.rl.train import ( + create_offline_rl_trainer, + WANDB_PROJECT, + WANDB_ENTITY, +) +from metamon.rl.dataset_config import ( + load_dataset_config, + save_dataset_config, + flatten_config, + build_dataset, +) +from metamon.rl.pretrained import get_pretrained_model_names, get_pretrained_model +from metamon.interface import get_reward_function_names, get_reward_function + + +def add_cli(parser): + # Identity + parser.add_argument("--run_name", required=True) + parser.add_argument("--save_dir", type=str, required=True) + + # Base model (architecture source) + parser.add_argument( + "--base_model", + type=str, + required=True, + choices=get_pretrained_model_names(), + help="Registered pretrained model that defines the architecture.", + ) + parser.add_argument( + "--base_checkpoint", + type=int, + default=None, + help="Checkpoint epoch of the base model. Only used on the first " + "iteration (ignored when --prev_run_dir is set). " + "Defaults to the model's default checkpoint.", + ) + + # Previous iteration (optional -- omit for the first round) + parser.add_argument( + "--prev_run_dir", + type=str, + default=None, + help="--save_dir of the previous finetuning iteration. " + "If omitted, initialises from --base_model directly.", + ) + parser.add_argument( + "--prev_run_name", + type=str, + default=None, + help="--run_name of the previous finetuning iteration.", + ) + parser.add_argument( + "--prev_checkpoint", + type=int, + default=None, + help="Checkpoint epoch to load from the previous iteration. " + "Required when --prev_run_dir is set.", + ) + + # Training + parser.add_argument("--train_gin_config", type=str, default="finetune.gin") + parser.add_argument("--epochs", type=int, default=300) + parser.add_argument("--steps_per_epoch", type=int, default=1_000) + parser.add_argument("--batch_size_per_gpu", type=int, default=12) + parser.add_argument("--grad_accum", type=int, default=1) + parser.add_argument("--ckpt_interval", type=int, default=10) + + # Data + parser.add_argument( + "--dataset_config", + type=str, + required=True, + help="Path to a dataset config YAML file. Can include prev_dataset / " + "anneal_epochs for iterative dataset transitions.", + ) + + # Reward + parser.add_argument( + "--reward_function", + type=str, + default=None, + choices=get_reward_function_names(), + ) + + # Eval / infra + parser.add_argument("--dloader_workers", type=int, default=10) + parser.add_argument("--async_env_mp_context", type=str, default="forkserver") + parser.add_argument( + "--eval_gens", + type=int, + nargs="*", + default=[1, 2, 3, 4, 9], + ) + parser.add_argument("--log", action="store_true") + return parser + + +def _resolve_checkpoint_path(args, pretrained): + """Return the path to the policy weights file to load.""" + if args.prev_run_dir is not None: + assert ( + args.prev_run_name is not None + ), "--prev_run_name is required when --prev_run_dir is set" + assert ( + args.prev_checkpoint is not None + ), "--prev_checkpoint is required when --prev_run_dir is set" + return os.path.join( + args.prev_run_dir, + args.prev_run_name, + "ckpts", + "policy_weights", + f"policy_epoch_{args.prev_checkpoint}.pt", + ) + ckpt = args.base_checkpoint or pretrained.default_checkpoint + return pretrained.get_path_to_checkpoint(ckpt) + + +if __name__ == "__main__": + from argparse import ArgumentParser + + parser = ArgumentParser( + description="Iterative finetuning with MetamonFinetuneAgent." + ) + add_cli(parser) + args = parser.parse_args() + + metamon.print_banner() + is_continuation = args.prev_run_dir is not None + if is_continuation: + print( + f" Finetune (iter): {args.prev_run_name} @ epoch {args.prev_checkpoint}" + f" → {args.run_name}" + ) + else: + print(f" Finetune (init): {args.base_model} → {args.run_name}") + print(f" Dataset config: {args.dataset_config}") + print() + + pretrained = get_pretrained_model(args.base_model) + + dataset_config = load_dataset_config(args.dataset_config) + + # Auto-fill prev_dataset from the base model when the config declares + # prev_weight (iterative finetuning) but omits prev_dataset. + if dataset_config.prev_weight is not None and dataset_config.prev_dataset is None: + if pretrained.dataset_config is None: + raise ValueError( + f"Base model '{args.base_model}' has no known dataset_config. " + f"Set prev_dataset explicitly in your dataset config YAML." + ) + dataset_config.prev_dataset = pretrained.dataset_config + print( + f" prev_dataset inferred from {args.base_model}: " + f"{pretrained.dataset_config}" + ) + + amago_dataset = build_dataset( + config=dataset_config, + obs_space=pretrained.observation_space, + action_space=pretrained.action_space, + reward_function=pretrained.reward_function, + ) + + # auto-save effective config to checkpoint directory + config_save_path = os.path.join(args.save_dir, args.run_name, "dataset_config.yaml") + save_dataset_config(flatten_config(dataset_config), config_save_path) + print(f" Dataset config saved to: {config_save_path}\n") + + reward_function = ( + get_reward_function(args.reward_function) + if args.reward_function is not None + else pretrained.reward_function + ) + + experiment = create_offline_rl_trainer( + ckpt_dir=args.save_dir, + run_name=args.run_name, + model_gin_config=pretrained.model_gin_config_path, + train_gin_config=args.train_gin_config, + obs_space=pretrained.observation_space, + action_space=pretrained.action_space, + reward_function=reward_function, + amago_dataset=amago_dataset, + eval_gens=args.eval_gens, + async_env_mp_context=args.async_env_mp_context, + dloader_workers=args.dloader_workers, + epochs=args.epochs, + steps_per_epoch=args.steps_per_epoch, + grad_accum=args.grad_accum, + batch_size_per_gpu=args.batch_size_per_gpu, + log=args.log, + wandb_project=WANDB_PROJECT, + wandb_entity=WANDB_ENTITY, + manual_gin_overrides=pretrained.gin_overrides, + ckpt_interval=args.ckpt_interval, + ) + + experiment.start() + + ckpt_path = _resolve_checkpoint_path(args, pretrained) + print(f" Loading weights from: {ckpt_path}") + experiment.load_checkpoint_from_path(ckpt_path, is_accelerate_state=False) + + experiment.learn() + wandb.finish() diff --git a/metamon/rl/finetune_from_hf.py b/metamon/rl/finetune_from_hf.py deleted file mode 100644 index 4c580ae0ee..0000000000 --- a/metamon/rl/finetune_from_hf.py +++ /dev/null @@ -1,217 +0,0 @@ -import wandb - -import metamon -from metamon.rl.train import ( - create_offline_dataset, - create_offline_rl_trainer, - WANDB_PROJECT, - WANDB_ENTITY, -) -from metamon.rl.pretrained import get_pretrained_model_names, get_pretrained_model -from metamon.interface import get_reward_function_names, get_reward_function - - -def add_cli(parser): - parser.add_argument( - "--run_name", - required=True, - help="Give the run a name to identify logs and checkpoints.", - ) - parser.add_argument( - "--save_dir", - type=str, - required=True, - help="Path to save your custom checkpoints. Find checkpoints under save_dir/run_name/ckpts/", - ) - parser.add_argument( - "--finetune_from_model", - type=str, - required=True, - choices=get_pretrained_model_names(), - help="Name of a pretrained model to finetune from.", - ) - parser.add_argument( - "--finetune_from_checkpoint", - type=int, - default=None, - help="Checkpoint number to finetune from. You can find a full list on HuggingFace: jakegrigsby/metamon. Most models have checkpoints in range(2, 42, 2). Defaults to the default evaluation checkpoint of the base model.", - ) - parser.add_argument( - "--epochs", - type=int, - default=10, - help="Number of epochs to finetune for. In offline RL mode, an epoch is an arbitrary interval (here: 25k) of training steps on a fixed dataset.", - ) - parser.add_argument( - "--steps_per_epoch", - type=int, - default=25_000, - help="Number of training steps to perform per epoch. Convention is 25k, but you may want to go shorter if finetuning on a small dataset.", - ) - parser.add_argument( - "--batch_size_per_gpu", - type=int, - default=12, - help="Batch size per GPU. Total batch size is batch_size_per_gpu * num_gpus.", - ) - parser.add_argument( - "--grad_accum", - type=int, - default=1, - help="Number of gradient accumulations per update.", - ) - parser.add_argument( - "--train_gin_config", - type=str, - default=None, - help="Path to a gin config file that edits the training parameters. Note that when finetuning, you are not able to change settings that impact the model architecture (e.g., cannot switch an IL model to an RL update). Defaults to the same config as the base model.", - ) - parser.add_argument( - "--reward_function", - type=str, - default=None, - choices=get_reward_function_names(), - help="Defaults to the same reward function as the base model. See the README for a description of each reward function, or create your own!", - ) - parser.add_argument( - "--dloader_workers", - type=int, - default=10, - help="Number of workers for the data loader.", - ) - parser.add_argument( - "--parsed_replay_dir", - type=str, - default=None, - help="Path to the parsed replay directory. Defaults to the official huggingface version.", - ) - parser.add_argument( - "--replay_weight", - type=float, - default=1.0, - help="Sampling weight for the human parsed replay dataset (metamon-parsed-replays). Will be renormalized with other weights.", - ) - parser.add_argument( - "--self_play_subsets", - type=str, - nargs="+", - default=None, - help="Official self-play dataset (metamon-parsed-pile) subsets to include (e.g., 'pac-base', 'pac-exploratory'). If not provided, self-play data is not used.", - ) - parser.add_argument( - "--self_play_weights", - type=float, - nargs="+", - default=None, - help="Sampling weights for each self-play subset. Must match length of --self_play_subsets.", - ) - parser.add_argument( - "--custom_replay_dir", - type=str, - default=None, - help="Path to an optional custom parsed replay dataset (e.g., additional self-play data you've collected).", - ) - parser.add_argument( - "--custom_replay_weight", - type=float, - default=0.25, - help="Sampling weight for the custom dataset (if provided). Will be renormalized with other weights.", - ) - parser.add_argument( - "--use_cached_filenames", - action="store_true", - help="Use cached filename index for faster startup when reusing an identical training set.", - ) - parser.add_argument( - "--async_env_mp_context", - type=str, - default="forkserver", - help="Async environment setup method. Options: 'forkserver' (recommended, fast), 'fork' (fastest but unsafe with threads), 'spawn' (slowest but safest). Use 'spawn' only if others hang.", - ) - parser.add_argument( - "--eval_gens", - type=int, - nargs="*", - default=[1, 2, 3, 4, 9], - help="Generations (of OU) to play against heuristics between training epochs. Win rates usually saturate at 90%%+ quickly, so this is mostly a sanity-check. Reduce gens to save time on launch! Use `--eval_gens` (no arguments) to disable evaluation.", - ) - parser.add_argument( - "--formats", - nargs="+", - default=None, - help="Showdown battle formats to include in the dataset. Defaults to all supported formats.", - ) - parser.add_argument("--log", action="store_true", help="Log to wandb.") - return parser - - -if __name__ == "__main__": - from argparse import ArgumentParser - - parser = ArgumentParser( - description="Finetune a pretrained Metamon model from HuggingFace (jakegrigsby/metamon). " - "This script allows you to continue training any of the published pretrained models " - "on additional data or with modified training parameters. The model architecture " - "and base configuration are inherited from the chosen pretrained model." - ) - add_cli(parser) - args = parser.parse_args() - - metamon.print_banner() - print(f" Finetuning: {args.finetune_from_model} → {args.run_name}") - print() - - pretrained = get_pretrained_model(args.finetune_from_model) - # create the dataset we'll be finetuning on - amago_dataset = create_offline_dataset( - obs_space=pretrained.observation_space, - action_space=pretrained.action_space, - reward_function=pretrained.reward_function, - parsed_replay_dir=args.parsed_replay_dir, - replay_weight=args.replay_weight, - self_play_subsets=args.self_play_subsets, - self_play_weights=args.self_play_weights, - custom_replay_dir=args.custom_replay_dir, - custom_replay_weight=args.custom_replay_weight, - formats=args.formats, - use_cached_filenames=args.use_cached_filenames, - ) - if args.reward_function is not None: - # custom reward function - reward_function = get_reward_function(args.reward_function) - else: - # use the base reward function - reward_function = pretrained.reward_function - # create a new policy that matches the pretrained policy's architecture - experiment = create_offline_rl_trainer( - ckpt_dir=args.save_dir, - run_name=args.run_name, - model_gin_config=pretrained.model_gin_config_path, - train_gin_config=args.train_gin_config or pretrained.train_gin_config_path, - obs_space=pretrained.observation_space, - action_space=pretrained.action_space, - reward_function=reward_function, - amago_dataset=amago_dataset, - eval_gens=args.eval_gens, - async_env_mp_context=args.async_env_mp_context, - dloader_workers=args.dloader_workers, - epochs=args.epochs, - steps_per_epoch=args.steps_per_epoch, - grad_accum=args.grad_accum, - batch_size_per_gpu=args.batch_size_per_gpu, - log=args.log, - wandb_project=WANDB_PROJECT, - wandb_entity=WANDB_ENTITY, - manual_gin_overrides=pretrained.gin_overrides, - ) - experiment.start() - # load the pretrained checkpoint - checkpoint = args.finetune_from_checkpoint or pretrained.default_checkpoint - start_checkpoint = pretrained.get_path_to_checkpoint(checkpoint) - experiment.load_checkpoint_from_path( - start_checkpoint, - is_accelerate_state=False, - ) - # finetune! - experiment.learn() - wandb.finish() diff --git a/metamon/rl/metamon_to_amago.py b/metamon/rl/metamon_to_amago.py index 2c6a28a113..962440fa96 100644 --- a/metamon/rl/metamon_to_amago.py +++ b/metamon/rl/metamon_to_amago.py @@ -8,6 +8,7 @@ import torch import torch.nn as nn import torch.nn.functional as F +from torch.nn.attention.flex_attention import flex_attention, create_block_mask import einops @@ -17,7 +18,14 @@ ActionSpace, UniversalAction, ) -from metamon.il.model import TransformerTurnEmbedding, PerceiverTurnEmbedding +from metamon.il.model import ( + TransformerTurnEmbedding, + PerceiverTurnEmbedding, + TokenEmbedding, + MultiModalEmbedding, + LearnablePosEmb, + PerceiverEncoder, +) from metamon.tokenizer import PokemonTokenizer, UNKNOWN_TOKEN from metamon.data import ParsedReplayDataset from metamon.env import ( @@ -25,35 +33,25 @@ PokeEnvWrapper, BattleAgainstBaseline, QueueOnLocalLadder, + ChallengeByUsername, PokeAgentLadder, ) - -_AMAGO_REQUIRED_VERSION = "3.1.2" - try: import amago - from importlib.metadata import version as _pkg_version - - _amago_version = _pkg_version("amago") except ImportError: raise ImportError( - "Must install `amago` RL package. Visit: https://ut-austin-rpl.github.io/amago/\n" - f"The supported version is {_AMAGO_REQUIRED_VERSION}. Install via:\n" - f" pip install 'amago @ git+https://github.com/UT-Austin-RPL/amago.git@v3.1'" + "Must install `amago` RL package. Visit: https://ut-austin-rpl.github.io/amago/ " ) else: - if _amago_version != _AMAGO_REQUIRED_VERSION: - raise ImportError( - f"amago version {_amago_version!r} is not supported. " - f"Required: {_AMAGO_REQUIRED_VERSION!r}.\n" - f"Install the correct version via:\n" - f" pip install 'amago @ git+https://github.com/UT-Austin-RPL/amago.git@v3.1'" - ) + assert ( + hasattr(amago, "__version__") and amago.__version__ >= "3.4.0" + ), f"AMAGO v3.4.0+ required; found {getattr(amago, '__version__', 'unknown')}." from amago.envs import AMAGOEnv - from amago.nets.utils import symlog - from amago.loading import RLData, RLDataset, Batch + from amago.nets.utils import symlog, add_activation_log + from amago.loading import RLData, RLDataset, Batch, MAGIC_PAD_VAL from amago.envs.amago_env import AMAGO_ENV_LOG_PREFIX + from amago.nets.ff import Normalization def _block_warnings(): @@ -62,6 +60,75 @@ def _block_warnings(): warnings.filterwarnings("ignore", category=amago.utils.AmagoWarning) +@gin.configurable +class BatchNormalizedExpFilter: + """Batch-normalized exponential weighting for filtered behavior cloning. + + Z-scores advantages over *unmasked* positions before applying the + exponential, making ``beta`` invariant to the absolute scale of + Q-values / rewards. Inspired by GRPO-style relative advantage + normalization. + + Because amago's ``fbc_filter_func`` interface only passes the advantage + tensor, the mask must be injected externally via :meth:`set_mask` before + the agent forward pass. :class:`MetamonAMAGOExperiment` handles this + automatically in :meth:`train_step`. + + Args: + beta: Scale applied after normalization. With unit-variance inputs, + values in [1, 3] give a stable curriculum. + eps: Small constant for numerical stability in std computation. + clip_weights_low: Floor for output weights. + clip_weights_high: Ceiling for output weights. + """ + + def __init__( + self, + beta: float = 1.0, + eps: float = 1e-8, + clip_weights_low: Optional[float] = 1e-7, + clip_weights_high: Optional[float] = 100.0, + ): + self.beta = beta + self.eps = eps + self.clip_weights_low = clip_weights_low + self.clip_weights_high = clip_weights_high + self._mask: Optional[torch.Tensor] = None + + def set_mask(self, mask: Optional[torch.Tensor]): + """Set the boolean mask for the next ``__call__``. + + Args: + mask: (Batch, Length, 1) or broadcastable bool tensor. ``True`` + where the advantage is valid. Cleared after each call. + """ + self._mask = mask + + def __call__(self, adv: torch.Tensor) -> torch.Tensor: + mask = self._mask + self._mask = None + + if mask is not None: + mask = mask[:, : adv.shape[1], ...] + while mask.ndim < adv.ndim: + mask = mask.unsqueeze(-1) + mask = mask.expand_as(adv) + valid = adv[mask] + mu = valid.mean() + sigma = valid.std() + self.eps + else: + mu = adv.mean() + sigma = adv.std() + self.eps + + adv_norm = (adv - mu) / sigma + weights = torch.exp(self.beta * adv_norm) + if self.clip_weights_low is not None or self.clip_weights_high is not None: + weights = torch.clamp( + weights, min=self.clip_weights_low, max=self.clip_weights_high + ) + return weights + + def make_placeholder_env( observation_space: ObservationSpace, action_space: ActionSpace ) -> AMAGOEnv: @@ -119,6 +186,18 @@ def make_pokeagent_ladder_env(*args, **kwargs): return PSLadderAMAGOWrapper(menv) +def make_challenge_env(*args, **kwargs): + """ + Battle a specific opponent by username (head-to-head challenge mode). + """ + _block_warnings() + menv = ChallengeByUsername(*args, **kwargs) + print( + f"Made Challenge Env ({menv._role}): {menv.player_username} vs {menv._opponent_username}" + ) + return PSLadderAMAGOWrapper(menv) + + def make_baseline_env(*args, **kwargs): """ Battle against a built-in baseline opponent @@ -135,20 +214,24 @@ def make_placeholder_experiment( log: bool, observation_space: ObservationSpace, action_space: ActionSpace, + experiment_type: type = None, ): """ Initialize an AMAGO experiment that will be used to load a pretrained checkpoint and manage agent/env interaction. + + Args: + experiment_type: Experiment class to instantiate. Defaults to MetamonAMAGOExperiment. """ - # the environment is only used to initialize the network - # before loading the correct checkpoint + if experiment_type is None: + experiment_type = MetamonAMAGOExperiment penv = make_placeholder_env( observation_space=observation_space, action_space=action_space, ) dummy_dset = amago.loading.DoNothingDataset() dummy_env = lambda: penv - experiment = MetamonAMAGOExperiment( + experiment = experiment_type( # assumes that positional args # agent_type, tstep_encoder_type, # traj_encoder_type, and max_seq_len @@ -424,8 +507,14 @@ def actor_network_forward( class PSLadderAMAGOWrapper(MetamonAMAGOWrapper): + """AMAGO wrapper for envs with a fixed number of battles (ladder or challenge mode). + + Blocks auto-resets after num_battles to avoid creating battles that won't be completed. + Works with both QueueOnLocalLadder and ChallengeByUsername. + """ + def __init__(self, env): - assert isinstance(env, QueueOnLocalLadder) + assert isinstance(env, (QueueOnLocalLadder, ChallengeByUsername)) self.placeholder_obs = None self.battle_counter = 0 super().__init__(env) @@ -433,7 +522,7 @@ def __init__(self, env): def inner_reset(self, *args, **kwargs): if self.battle_counter >= self.env.num_battles: # quirk of amago's parallel actor auto-resets that matters - # for online ladder. + # for online ladder and challenge mode. warnings.warn( "Blocking auto-reset to avoid creating a battle that will not be completed!" ) @@ -523,10 +612,13 @@ def inner_forward(self, obs, rl2s, log_dict=None): if self.training and self.token_mask_aug: obs["text_tokens"] = unknown_token_mask(obs["text_tokens"]) extras = F.leaky_relu(self.extra_emb(symlog(rl2s))) + add_activation_log("MetamonTstepEncoder/extra_emb", extras, log_dict) numerical = torch.cat((obs["numbers"], extras), dim=-1) + add_activation_log("MetamonTstepEncoder/numerical", numerical, log_dict) turn_emb = self.turn_embedding( token_inputs=obs["text_tokens"], numerical_inputs=numerical ) + add_activation_log("MetamonTstepEncoder/turn_emb", turn_emb, log_dict) return turn_emb @@ -581,13 +673,589 @@ def inner_forward(self, obs, rl2s, log_dict=None): if self.training and self.token_mask_aug: obs["text_tokens"] = unknown_token_mask(obs["text_tokens"]) extras = F.leaky_relu(self.extra_emb(symlog(rl2s))) + add_activation_log("MetamonPerceiverTstepEncoder/extra_emb", extras, log_dict) numerical = torch.cat((obs["numbers"], extras), dim=-1) + add_activation_log( + "MetamonPerceiverTstepEncoder/numerical", numerical, log_dict + ) turn_emb = self.turn_embedding( token_inputs=obs["text_tokens"], numerical_inputs=numerical ) + add_activation_log("MetamonPerceiverTstepEncoder/turn_emb", turn_emb, log_dict) return turn_emb +class _PerceiverLayer(nn.Module): + """Cross-attention + self-attention with fused projections and F.scaled_dot_product_attention. + + Drop-in replacement for the PerceiverEncoder's paired CrossAttentionBlock + + SelfAttentionBlock. Same parameter count and semantics, but uses a single + fused KV projection (cross) or QKV projection (self) and calls + F.scaled_dot_product_attention directly instead of nn.MultiheadAttention. + + Optional ``cross_mask`` / ``self_mask`` boolean tensors (``True`` = masked + out) enable block-diagonal attention for grouped independent processing. + """ + + def __init__( + self, + d_model: int, + n_heads: int, + dropout: float, + normformer_norms: bool = False, + qk_norm: bool = False, + ff_mult: int = 4, + ): + super().__init__() + assert d_model % n_heads == 0 + self.n_heads = n_heads + self.head_dim = d_model // n_heads + self.d_model = d_model + self._dp = dropout + self._normformer = normformer_norms + self._qk_norm = qk_norm + + d_ff = d_model * ff_mult + + self.cross_norm_q = nn.LayerNorm(d_model) + self.cross_norm_kv = nn.LayerNorm(d_model) + self.cross_q = nn.Linear(d_model, d_model) + self.cross_kv = nn.Linear(d_model, 2 * d_model) + self.cross_out = nn.Linear(d_model, d_model) + self.cross_ff_norm = nn.LayerNorm(d_model) + self.cross_ff1 = nn.Linear(d_model, d_ff) + self.cross_ff2 = nn.Linear(d_ff, d_model) + self.cross_ff_drop = nn.Dropout(dropout) + + self.self_norm = nn.LayerNorm(d_model) + self.self_qkv = nn.Linear(d_model, 3 * d_model) + self.self_out = nn.Linear(d_model, d_model) + self.self_ff_norm = nn.LayerNorm(d_model) + self.self_ff1 = nn.Linear(d_model, d_ff) + self.self_ff2 = nn.Linear(d_ff, d_model) + self.self_ff_drop = nn.Dropout(dropout) + + if normformer_norms: + self.cross_post_attn_norm = nn.LayerNorm(d_model) + self.cross_mid_ff_norm = nn.LayerNorm(d_ff) + self.self_post_attn_norm = nn.LayerNorm(d_model) + self.self_mid_ff_norm = nn.LayerNorm(d_ff) + + if qk_norm: + hd = self.head_dim + self.cross_q_norm = nn.LayerNorm(hd) + self.cross_k_norm = nn.LayerNorm(hd) + self.self_q_norm = nn.LayerNorm(hd) + self.self_k_norm = nn.LayerNorm(hd) + + def forward( + self, + latents: torch.Tensor, + kv_input: torch.Tensor, + cross_mask: Optional[torch.Tensor] = None, + self_mask: Optional[torch.Tensor] = None, + cross_block_mask=None, + self_block_mask=None, + ) -> torch.Tensor: + H, HD, D = self.n_heads, self.head_dim, self.d_model + dp = self._dp if self.training else 0.0 + B, Lq = latents.shape[:2] + + q = self.cross_q(self.cross_norm_q(latents)) + q = q.unflatten(-1, (H, HD)).transpose(1, 2) # (B, H, Lq, HD) + kv = self.cross_kv(self.cross_norm_kv(kv_input)) + kv = kv.unflatten(-1, (2, H, HD)) + k = kv[:, :, 0].transpose(1, 2) # (B, H, Lkv, HD) + v = kv[:, :, 1].transpose(1, 2) # (B, H, Lkv, HD) + if self._qk_norm: + q = self.cross_q_norm(q) + k = self.cross_k_norm(k) + if cross_block_mask is not None: + attn = flex_attention(q, k, v, block_mask=cross_block_mask) + else: + attn = F.scaled_dot_product_attention( + q, k, v, attn_mask=cross_mask, dropout_p=dp + ) + cross_out = self.cross_out(attn.transpose(1, 2).reshape(B, Lq, D)) + if self._normformer: + cross_out = self.cross_post_attn_norm(cross_out) + latents = latents + cross_out + h = F.silu(self.cross_ff1(self.cross_ff_norm(latents))) + if self._normformer: + h = self.cross_mid_ff_norm(h) + latents = latents + self.cross_ff_drop(self.cross_ff2(h)) + + qkv = self.self_qkv(self.self_norm(latents)) + qkv = qkv.unflatten(-1, (3, H, HD)) + sq = qkv[:, :, 0].transpose(1, 2) # (B, H, Lq, HD) + sk = qkv[:, :, 1].transpose(1, 2) # (B, H, Lq, HD) + sv = qkv[:, :, 2].transpose(1, 2) # (B, H, Lq, HD) + if self._qk_norm: + sq = self.self_q_norm(sq) + sk = self.self_k_norm(sk) + if self_block_mask is not None: + attn = flex_attention(sq, sk, sv, block_mask=self_block_mask) + else: + attn = F.scaled_dot_product_attention( + sq, sk, sv, attn_mask=self_mask, dropout_p=dp + ) + self_out = self.self_out(attn.transpose(1, 2).reshape(B, Lq, D)) + if self._normformer: + self_out = self.self_post_attn_norm(self_out) + latents = latents + self_out + h = F.silu(self.self_ff1(self.self_ff_norm(latents))) + if self._normformer: + h = self.self_mid_ff_norm(h) + latents = latents + self.self_ff_drop(self.self_ff2(h)) + + return latents + + +class _FastPerceiverEncoder(nn.Module): + """Perceiver encoder with fused attention projections. + + Functionally identical to :class:`PerceiverEncoder` from ``metamon.il.model`` + but replaces ``nn.MultiheadAttention`` with fused QKV/KV linear projections + and direct ``F.scaled_dot_product_attention`` calls. + """ + + def __init__( + self, + latent_tokens: int, + d_model: int, + n_heads: int, + n_layers: int, + dropout: float, + normformer_norms: bool = False, + qk_norm: bool = False, + ff_mult: int = 4, + ): + super().__init__() + self.latents = nn.Parameter(torch.randn(latent_tokens, d_model) * 0.02) + self.layers = nn.ModuleList( + [ + _PerceiverLayer( + d_model, n_heads, dropout, normformer_norms, qk_norm, ff_mult + ) + for _ in range(n_layers) + ] + ) + self.output_dim = latent_tokens * d_model + + def forward(self, x: torch.Tensor, flatten: bool = True) -> torch.Tensor: + B = x.shape[0] + latents = self.latents.unsqueeze(0).expand(B, -1, -1) + for layer in self.layers: + latents = layer(latents, x) + if flatten: + return latents.reshape(B, 1, -1) + return latents + + +class _BlockDiagPerceiverEncoder(nn.Module): + """Perceiver for *N* independent groups via block-diagonal attention masking. + + Tiles the shared learnable latent queries *N* times and pre-computes + block-diagonal masks so each group's latents only attend to their own + input tokens (cross-attention) and to each other (self-attention). + + This is **semantically identical** to running a perceiver *N* times with + shared weights on *N* separate inputs, but everything happens in a single + attention call (batch = B, seq = N * group_seq_len) so the GPU sees fewer, + larger kernels. + + When *use_flex_attention* is True, uses ``flex_attention`` with compiled + block-sparse masks — this produces a Triton kernel whose backward pass is + significantly faster than the memory-efficient SDPA backward triggered by + boolean masks. + """ + + def __init__( + self, + latent_tokens: int, + d_model: int, + n_heads: int, + n_layers: int, + dropout: float, + n_groups: int, + group_seq_len: int, + use_flex_attention: bool = False, + normformer_norms: bool = False, + qk_norm: bool = False, + ff_mult: int = 4, + ): + super().__init__() + self.n_groups = n_groups + self.latent_tokens = latent_tokens + self.use_flex_attention = use_flex_attention + self.latents = nn.Parameter(torch.randn(latent_tokens, d_model) * 0.02) + self.layers = nn.ModuleList( + [ + _PerceiverLayer( + d_model, n_heads, dropout, normformer_norms, qk_norm, ff_mult + ) + for _ in range(n_layers) + ] + ) + self.output_dim = latent_tokens * d_model + + total_q = n_groups * latent_tokens + total_kv = n_groups * group_seq_len + + if use_flex_attention: + lt = latent_tokens + gs = group_seq_len + + def cross_mask_mod(b, h, q_idx, kv_idx): + return (q_idx // lt) == (kv_idx // gs) + + def self_mask_mod(b, h, q_idx, kv_idx): + return (q_idx // lt) == (kv_idx // lt) + + self._cross_block_mask = create_block_mask( + cross_mask_mod, + B=None, + H=None, + Q_LEN=total_q, + KV_LEN=total_kv, + device="cuda", + ) + self._self_block_mask = create_block_mask( + self_mask_mod, + B=None, + H=None, + Q_LEN=total_q, + KV_LEN=total_q, + device="cuda", + ) + self._cross_mask = None + self._self_mask = None + else: + # SDPA bool convention: True = allowed to attend, False = masked out + cross_mask = torch.zeros(total_q, total_kv, dtype=torch.bool) + self_mask = torch.zeros(total_q, total_q, dtype=torch.bool) + for i in range(n_groups): + qs, qe = i * latent_tokens, (i + 1) * latent_tokens + kvs, kve = i * group_seq_len, (i + 1) * group_seq_len + cross_mask[qs:qe, kvs:kve] = True + self_mask[qs:qe, qs:qe] = True + + self.register_buffer("_cross_mask", cross_mask) + self.register_buffer("_self_mask", self_mask) + self._cross_block_mask = None + self._self_block_mask = None + + def forward(self, x: torch.Tensor, flatten: bool = True) -> torch.Tensor: + """ + Args: + x: ``(B, n_groups * group_seq_len, d_model)`` — all groups concatenated. + Returns: + If *flatten*: ``(B, n_groups, latent_tokens * d_model)`` + Else: ``(B, n_groups, latent_tokens, d_model)`` + """ + B = x.shape[0] + latents = self.latents.repeat(self.n_groups, 1) + latents = latents.unsqueeze(0).expand(B, -1, -1) + + for layer in self.layers: + latents = layer( + latents, + x, + cross_mask=self._cross_mask, + self_mask=self._self_mask, + cross_block_mask=self._cross_block_mask, + self_block_mask=self._self_block_mask, + ) + + latents = latents.unflatten(1, (self.n_groups, self.latent_tokens)) + if flatten: + return latents.flatten(2) + return latents + + +@gin.configurable +class MetamonGroupedTstepEncoderV2(amago.nets.tstep_encoders.TstepEncoder): + """Timestep encoder for GroupedObservationSpace. + + Three-stage architecture: + 1. Pokemon perceiver (shared): encodes each of 7 Pokemon independently + 2. Global perceiver: encodes misc features (format, conditions, etc.) + rl2 + 3. Fusion perceiver: combines 8 entity embeddings into final representation + + Slightly optimized by some fancy attention masking tricks. + """ + + POKEMON_TEXT_LEN = 12 + POKEMON_NUM_LEN = 31 + MISC_TEXT_LEN = 20 + MISC_NUM_LEN = 4 + NUM_POKEMON = 7 + + def __init__( + self, + obs_space, + rl2_space, + tokenizer: PokemonTokenizer, + # Pokemon encoder + d_pokemon: int = 64, + n_heads_pokemon: int = 4, + n_layers_pokemon: int = 2, + latent_tokens_pokemon: int = 4, + numerical_tokens_pokemon: int = 4, + pokemon_out_norm: str = "layer", + # Global encoder + d_global: int = 64, + n_heads_global: int = 4, + n_layers_global: int = 2, + latent_tokens_global: int = 4, + numerical_tokens_global: int = 2, + global_out_norm: str = "layer", + # Fusion encoder + d_fusion: int = 128, + n_heads_fusion: int = 4, + n_layers_fusion: int = 2, + latent_tokens_fusion: int = 4, + fusion_out_norm: str = "layer", + # General + extra_emb_dim: int = 16, + dropout: float = 0.05, + use_flex_attention: bool = False, + normformer_norms: bool = False, + qk_norm: bool = False, + ff_mult: int = 4, + pokemon_role_emb: bool = False, + ): + super().__init__(obs_space=obs_space, rl2_space=rl2_space) + + self.extra_emb = nn.Linear(rl2_space.shape[-1], extra_emb_dim) + + # --- Pokemon encoder (shared for all 7, block-diagonal masking) --- + self.pokemon_token_emb = TokenEmbedding(tokenizer, d_pokemon) + self.pokemon_fuse = MultiModalEmbedding( + token_emb_dim=d_pokemon, + numerical_d_inp=self.POKEMON_NUM_LEN, + output_dim=d_pokemon, + numerical_tokens=numerical_tokens_pokemon, + dropout=dropout, + ) + pokemon_seq_len = self.POKEMON_TEXT_LEN + numerical_tokens_pokemon + self.pokemon_pos = LearnablePosEmb(max_len=pokemon_seq_len, d_model=d_pokemon) + self.pokemon_perceiver = _BlockDiagPerceiverEncoder( + latent_tokens=latent_tokens_pokemon, + d_model=d_pokemon, + n_heads=n_heads_pokemon, + n_layers=n_layers_pokemon, + dropout=dropout, + n_groups=self.NUM_POKEMON, + group_seq_len=pokemon_seq_len, + use_flex_attention=use_flex_attention, + normformer_norms=normformer_norms, + qk_norm=qk_norm, + ff_mult=ff_mult, + ) + self.pokemon_out_norm = Normalization(pokemon_out_norm, d_pokemon) + self.pokemon_proj = nn.Linear(latent_tokens_pokemon * d_pokemon, d_fusion) + self.register_buffer( + "_pokemon_pos_ids", + torch.arange(pokemon_seq_len, dtype=torch.long), + ) + self._pokemon_role_emb = ( + nn.Embedding(3, d_pokemon) if pokemon_role_emb else None + ) + if pokemon_role_emb: + # 0 = player active, 1 = bench/switch, 2 = opponent active + self.register_buffer( + "_pokemon_role_ids", + torch.tensor([0, 1, 1, 1, 1, 1, 2], dtype=torch.long), + ) + + # --- Global encoder --- + self.global_token_emb = TokenEmbedding(tokenizer, d_global) + self.global_fuse = MultiModalEmbedding( + token_emb_dim=d_global, + numerical_d_inp=self.MISC_NUM_LEN + extra_emb_dim, + output_dim=d_global, + numerical_tokens=numerical_tokens_global, + dropout=dropout, + ) + global_seq_len = self.MISC_TEXT_LEN + numerical_tokens_global + self.global_pos = LearnablePosEmb(max_len=global_seq_len, d_model=d_global) + self.global_perceiver = _FastPerceiverEncoder( + latent_tokens=latent_tokens_global, + d_model=d_global, + n_heads=n_heads_global, + n_layers=n_layers_global, + dropout=dropout, + normformer_norms=normformer_norms, + qk_norm=qk_norm, + ff_mult=ff_mult, + ) + self.global_out_norm = Normalization(global_out_norm, d_global) + self.global_proj = nn.Linear(latent_tokens_global * d_global, d_fusion) + self.register_buffer( + "_global_pos_ids", torch.arange(global_seq_len, dtype=torch.long) + ) + + # --- Fusion encoder --- + self.entity_type_emb = nn.Embedding(self.NUM_POKEMON + 1, d_fusion) + self.fusion = _FastPerceiverEncoder( + latent_tokens=latent_tokens_fusion, + d_model=d_fusion, + n_heads=n_heads_fusion, + n_layers=n_layers_fusion, + dropout=dropout, + normformer_norms=normformer_norms, + qk_norm=qk_norm, + ff_mult=ff_mult, + ) + self.fusion_out_norm = Normalization(fusion_out_norm, d_fusion) + self.register_buffer( + "_entity_type_ids", torch.arange(self.NUM_POKEMON + 1, dtype=torch.long) + ) + + self._emb_dim = self.fusion.output_dim + + @property + def emb_dim(self): + return self._emb_dim + + def inner_forward(self, obs, rl2s, log_dict=None): + pokemon_text = torch.stack( + [ + obs["text_active_pokemon_tokens"], + obs["text_switch_0_tokens"], + obs["text_switch_1_tokens"], + obs["text_switch_2_tokens"], + obs["text_switch_3_tokens"], + obs["text_switch_4_tokens"], + obs["text_opponent_active_pokemon_tokens"], + ], + dim=2, + ) + pokemon_nums = torch.stack( + [ + obs["numbers_active_pokemon"], + obs["numbers_switch_0"], + obs["numbers_switch_1"], + obs["numbers_switch_2"], + obs["numbers_switch_3"], + obs["numbers_switch_4"], + obs["numbers_opponent_active_pokemon"], + ], + dim=2, + ) + + B, L = pokemon_text.shape[:2] + pokemon_text = pokemon_text.flatten(0, 1) + pokemon_nums = pokemon_nums.flatten(0, 1) + rl2s_flat = rl2s.flatten(0, 1) + global_nums_flat = obs["numbers_misc"].flatten(0, 1) + global_text_flat = obs["text_misc_tokens"].flatten(0, 1) + + emb = self._inner_forward_impl( + pokemon_text, + pokemon_nums, + rl2s_flat, + global_nums_flat, + global_text_flat, + log_dict, + ) + return emb.unflatten(0, (B, L)) + + def _encode_pokemon( + self, text_tokens: torch.Tensor, numerical: torch.Tensor, log_dict=None + ) -> torch.Tensor: + B = text_tokens.size(0) + + # Embed each pokemon independently (shared weights) + text_flat = text_tokens.flatten(0, 1) + nums_flat = numerical.flatten(0, 1) + + tok_emb = self.pokemon_token_emb(text_flat) + tok_emb = tok_emb.unsqueeze(1) + nums_flat = nums_flat.unsqueeze(1) + seq = self.pokemon_fuse(tok_emb, nums_flat).squeeze(1) + + seq = seq + self.pokemon_pos(self._pokemon_pos_ids) + + # Concatenate all 7 pokemon into one sequence for block-diagonal attn + seq = seq.unflatten(0, (-1, self.NUM_POKEMON)).flatten(1, 2) + + if self._pokemon_role_emb is not None: + role = self._pokemon_role_emb(self._pokemon_role_ids) # (7, d_pokemon) + tokens_per_pokemon = seq.shape[1] // self.NUM_POKEMON + idx = torch.arange(self.NUM_POKEMON, device=seq.device) * tokens_per_pokemon + role_signal = torch.zeros( + seq.shape[1], seq.shape[2], device=seq.device, dtype=seq.dtype + ) + role_signal[idx] = role + seq = seq + role_signal + + # Block-diagonal perceiver → (B, 7, latent_tokens, d_pokemon) + emb = self.pokemon_perceiver(seq, flatten=False) + add_activation_log( + "MetamonGroupedTstepEncoderV2/pokemon_perceiver", emb, log_dict + ) + + emb = self.pokemon_out_norm(emb) + emb = emb.flatten(2) + emb = self.pokemon_proj(emb) + add_activation_log("MetamonGroupedTstepEncoderV2/pokemon_proj", emb, log_dict) + + return emb + + def _encode_global( + self, text_tokens: torch.Tensor, numerical: torch.Tensor, log_dict=None + ) -> torch.Tensor: + tok_emb = self.global_token_emb(text_tokens) + tok_emb = tok_emb.unsqueeze(1) + numerical = numerical.unsqueeze(1) + seq = self.global_fuse(tok_emb, numerical).squeeze(1) + + seq = seq + self.global_pos(self._global_pos_ids) + + emb = self.global_perceiver(seq, flatten=False) + add_activation_log( + "MetamonGroupedTstepEncoderV2/global_perceiver", emb, log_dict + ) + + emb = self.global_out_norm(emb) + emb = emb.flatten(1) + emb = self.global_proj(emb) + add_activation_log("MetamonGroupedTstepEncoderV2/global_proj", emb, log_dict) + + return emb + + @torch.compile + def _inner_forward_impl( + self, + pokemon_text, + pokemon_nums, + rl2s_flat, + global_nums_flat, + global_text_flat, + log_dict=None, + ): + pokemon_embs = self._encode_pokemon(pokemon_text, pokemon_nums, log_dict) + + extras = F.leaky_relu(self.extra_emb(symlog(rl2s_flat))) + global_nums = torch.cat([global_nums_flat, extras], dim=-1) + global_emb = self._encode_global(global_text_flat, global_nums, log_dict) + all_embs = torch.cat([pokemon_embs, global_emb.unsqueeze(1)], dim=1) + + all_embs = all_embs + self.entity_type_emb(self._entity_type_ids) + + emb = self.fusion(all_embs, flatten=False) + add_activation_log("MetamonGroupedTstepEncoderV2/fusion", emb, log_dict) + + emb = self.fusion_out_norm(emb) + add_activation_log( + "MetamonGroupedTstepEncoderV2/fusion_out_norm", emb, log_dict + ) + + return emb.flatten(1) + + class MetamonAMAGODataset(RLDataset): """A wrapper around the ParsedReplayDataset that converts to an AMAGO RLDataset. @@ -698,23 +1366,41 @@ def evaluate_val(self): amago.utils.call_async_env(self.val_envs, "take_long_break") return out - def edit_actor_mask( - self, batch: Batch, actor_loss: torch.FloatTensor, pad_mask: torch.BoolTensor - ) -> torch.BoolTensor: - B, L, G, _ = actor_loss.shape - # missing_action_mask is one timestep too long to match the size of observations - # True where the action is missing, False where it's provided. - # pad_mask is True where the timestep should count towards loss, False where it shouldn't. - missing_action_mask = einops.repeat( - ~batch.obs["missing_action_mask"][:, :-1], "b l 1 -> b l g 1", g=G - ) - return pad_mask & missing_action_mask - - def edit_critic_mask( - self, batch: Batch, critic_loss: torch.FloatTensor, pad_mask: torch.BoolTensor - ) -> torch.BoolTensor: - B, L, C, G, _ = pad_mask.shape - missing_action_mask = einops.repeat( - ~batch.obs["missing_action_mask"][:, :-1], "b l 1 -> b l c g 1", g=G, c=C - ) - return pad_mask & missing_action_mask + def init_model(self): + super().init_model() + policy = self.policy + + def _edit_actor_mask(batch, actor_loss, pad_mask): + B, L, G, _ = actor_loss.shape + missing_action_mask = einops.repeat( + ~batch.obs["missing_action_mask"][:, :-1], "b l 1 -> b l g 1", g=G + ) + return pad_mask & missing_action_mask + + def _edit_critic_mask(batch, critic_loss, pad_mask): + if pad_mask is None: + return pad_mask + B, L, C, G, _ = pad_mask.shape + missing_action_mask = einops.repeat( + ~batch.obs["missing_action_mask"][:, :-1], + "b l 1 -> b l c g 1", + g=G, + c=C, + ) + return pad_mask & missing_action_mask + + policy.edit_actor_mask = _edit_actor_mask + policy.edit_critic_mask = _edit_critic_mask + + def train_step(self, batch: Batch, log_step: bool): + fbc_filter = self.policy.fbc_filter_func + if hasattr(fbc_filter, "set_mask"): + state_mask = ~(batch.rl2s == MAGIC_PAD_VAL).all(-1, keepdim=True) + action_mask = ~batch.obs["missing_action_mask"] + fbc_filter.set_mask(state_mask & action_mask) + if hasattr(fbc_filter, "set_seq_mask") and getattr( + fbc_filter, "seq_enabled", False + ): + seq_mask = (~(batch.rl2s == MAGIC_PAD_VAL).all(-1, keepdim=True)).bool() + fbc_filter.set_seq_mask(seq_mask) + return super().train_step(batch, log_step=log_step) diff --git a/metamon/rl/pretrained.py b/metamon/rl/pretrained.py index 2970a1157e..6c32d982a5 100644 --- a/metamon/rl/pretrained.py +++ b/metamon/rl/pretrained.py @@ -1,3 +1,4 @@ +import json import os from pathlib import Path import warnings @@ -6,10 +7,6 @@ warnings.filterwarnings("ignore") -def red_warning(msg: str): - print(f"\033[91m{msg}\033[0m") - - import huggingface_hub import torch import amago @@ -19,6 +16,10 @@ def red_warning(msg: str): make_placeholder_experiment, MetamonDiscrete, ) +from metamon.rl.experimental.ensemble import ( + EnsembleMemberSpec, + build_heuristic_ensemble_experiment, +) from metamon.interface import ( ObservationSpace, RewardFunction, @@ -30,16 +31,38 @@ def red_warning(msg: str): ) from metamon.tokenizer import PokemonTokenizer, get_tokenizer +from metamon.config import METAMON_CACHE_DIR -if metamon.METAMON_CACHE_DIR is None: +if METAMON_CACHE_DIR is None: raise ValueError("Set METAMON_CACHE_DIR environment variable") # downloads checkpoints to the metamon cache dir where we're putting all the other data -MODEL_DOWNLOAD_DIR = os.path.join(metamon.METAMON_CACHE_DIR, "pretrained_models") +MODEL_DOWNLOAD_DIR = os.path.join(METAMON_CACHE_DIR, "pretrained_models") # registry for pretrained models ALL_PRETRAINED_MODELS = {} +ENSEMBLE_PRESETS_PATH = ( + Path(__file__).parent / "experimental/ensemble/ensemble_presets.json" +) + + +def _load_ensemble_member_presets(): + raw_presets = json.loads(ENSEMBLE_PRESETS_PATH.read_text()) + return { + name: [ + EnsembleMemberSpec( + **{ + **spec, + "proposal_roles": tuple(spec.get("proposal_roles", [])), + } + ) + for spec in member_specs + ] + for name, member_specs in raw_presets.items() + } + + def pretrained_model(name: Optional[str] = None): """ Decorator to register pretrained model classes. @@ -111,6 +134,9 @@ class PretrainedModel: 'poke-env' is deprecated; maintains the original paper's models. 'metamon' is the lateset version 'pokeagent' maintains policies trained (and used as the organizer baselines) during the PokéAgent Challenge + dataset_config: Path to the dataset config YAML that describes the training data + composition (replay weights, self-play subsets, custom replay dirs). None for + HuggingFace models where the dataset composition is lost to time... action_temperature: Temperature for temperature-based sampling. Higher temperature means more exploration. Default is 1.0 (no scaling). """ @@ -131,11 +157,13 @@ def __init__( default_checkpoint: int = 40, gin_overrides: Optional[dict] = None, battle_backend: str = "metamon", + dataset_config: Optional[str] = None, ): self.model_name = model_name self.model_gin_config = model_gin_config self.train_gin_config = train_gin_config self.battle_backend = battle_backend + self.dataset_config = dataset_config self.model_gin_config_path = os.path.join( metamon.rl.MODEL_CONFIG_DIR, self.model_gin_config ) @@ -159,23 +187,9 @@ def base_config(self) -> dict: """ Override to set one-off changes to the gin config files - By default, adds ability to fallback to vanilla attention if flash attention is not available, - sets the tokenizer, and enbables faster initialization. + By default, sets the tokenizer and enables faster initialization. """ - has_gpu = torch.cuda.is_available() - try: - import flash_attn - - has_flash_attn = True - except ImportError: - has_flash_attn = False - if has_flash_attn and has_gpu: - attn_type = amago.nets.transformer.FlashAttention - else: - attn_type = amago.nets.transformer.VanillaAttention - red_warning("Warning: Using unofficial VanillaAttention implementation") config = { - "amago.nets.traj_encoders.TformerTrajEncoder.attention_type": attn_type, "MetamonTstepEncoder.tokenizer": self.tokenizer, # skip cpu-intensive init, because we're going to be replacing the weights # with a checkpoint anyway.... @@ -220,10 +234,47 @@ def initialize_agent( # starting the experiment will build the initial model experiment.start() if checkpoint > 0: - # replace the weights with the pretrained checkpoint - experiment.load_checkpoint_from_path(ckpt_path, is_accelerate_state=False) + ckpt_state = torch.load(ckpt_path, map_location="cpu") + model_state = experiment.policy.state_dict() + self._validate_checkpoint(ckpt_state, model_state) + experiment.policy.load_state_dict(ckpt_state, strict=True) + experiment.policy.on_checkpoint_loaded(is_resume=False) return experiment + @staticmethod + def _validate_checkpoint(ckpt_state: dict, model_state: dict) -> None: + ckpt_keys = set(ckpt_state.keys()) + model_keys = set(model_state.keys()) + missing = model_keys - ckpt_keys + unexpected = ckpt_keys - model_keys + if missing: + raise RuntimeError( + f"Checkpoint is missing {len(missing)} keys expected by the model:\n" + + "\n".join(f" {k}" for k in sorted(missing)) + ) + if unexpected: + raise RuntimeError( + f"Checkpoint has {len(unexpected)} unexpected keys not in the model:\n" + + "\n".join(f" {k}" for k in sorted(unexpected)) + ) + shape_mismatches = [] + for k in model_keys: + if model_state[k].shape != ckpt_state[k].shape: + shape_mismatches.append( + f" {k}: model={list(model_state[k].shape)} vs ckpt={list(ckpt_state[k].shape)}" + ) + if shape_mismatches: + raise RuntimeError( + f"Shape mismatch for {len(shape_mismatches)} parameters:\n" + + "\n".join(shape_mismatches) + ) + ckpt_params = sum(p.numel() for p in ckpt_state.values()) + model_params = sum(p.numel() for p in model_state.values()) + print( + f"Checkpoint validated: {len(model_keys)} keys, " + f"{model_params:,} params (model) == {ckpt_params:,} params (ckpt)" + ) + class LocalPretrainedModel(PretrainedModel): """ @@ -265,6 +316,7 @@ class LocalFinetunedModel(LocalPretrainedModel): default_checkpoint: The checkpoint number to load by default (e.g., the last epoch number) train_gin_config: The gin config file to use for training. Defaults to the same as used by the base model (like the finetuning script does). reward_function: The reward function to use. Defaults to the same as used by the base model (like the finetuning script does). + dataset_config: Path to the dataset config YAML used for this finetuning run (or None if unknown). """ def __init__( @@ -276,6 +328,7 @@ def __init__( train_gin_config: Optional[str] = None, reward_function: Optional[RewardFunction] = None, battle_backend: Optional[str] = None, + dataset_config: Optional[str] = None, ): base_model = base_model() train_gin_config = train_gin_config or base_model.train_gin_config @@ -292,12 +345,13 @@ def __init__( action_space=base_model.action_space, reward_function=reward_function, battle_backend=battle_backend, + dataset_config=dataset_config, ) -##################### -## Paper Policies ### -##################### +#################################################### +## Paper Policies (Nov 2024 - Feb 2025) Gens 1-4 ### +#################################################### @pretrained_model() @@ -537,9 +591,9 @@ def __init__(self): ) -################################### -## PokéAgent Challenge Policies ### -################################### +########################################################################### +## PokéAgent Challenge Policies (June 2025 - November 2025) Gens1-4 & 9 ### +########################################################################### """ @@ -856,3 +910,639 @@ def __init__(self): ), }, ) + + +################################################# +### Gen 1 Specialists (Feb 2026 - April 2026) ### +################################################# +""" +Post-PokéAgent Challenge effort to reach the top of the Gen 1 OU leaderboard. + +All of these policies are trained to play Gen 1 OU specifically. + +The many "V2A" runs are small-scale (~12-15M param) RL hparam ablations. +They all have pretty similar performance, in the range between SyntheticRLV2 +and Kakuna. There isn't much of a reason to use them aside from boosting self-play +diversity. Tauros-v0 scales up the findings on a fresh dataset and is the best +standalone Gen1OU policy in metamon to date. +""" + + +@pretrained_model() +class V2A(PretrainedModel): + def __init__(self): + super().__init__( + model_name="v2_small_rl_baseline", + model_gin_config="smaller_multitaskagent.gin", + train_gin_config="alakazam3.gin", + default_checkpoint=26, + action_space=get_action_space("DefaultActionSpace"), + observation_space=get_observation_space("OpponentMoveObservationSpace"), + reward_function=get_reward_function("AggressiveShapedReward"), + tokenizer=get_tokenizer("DefaultObservationSpace-v1"), + battle_backend="metamon", + gin_overrides={ + "MetamonPerceiverTstepEncoder.tokenizer": get_tokenizer( + "DefaultObservationSpace-v1" + ), + }, + ) + + +@pretrained_model() +class V2ASeed2(PretrainedModel): + def __init__(self): + super().__init__( + model_name="v2_small_rl_baseline_track_metrics", + model_gin_config="smaller_multitaskagent.gin", + train_gin_config="alakazam3.gin", + default_checkpoint=26, + action_space=get_action_space("DefaultActionSpace"), + observation_space=get_observation_space("OpponentMoveObservationSpace"), + reward_function=get_reward_function("AggressiveShapedReward"), + tokenizer=get_tokenizer("DefaultObservationSpace-v1"), + battle_backend="metamon", + gin_overrides={ + "MetamonPerceiverTstepEncoder.tokenizer": get_tokenizer( + "DefaultObservationSpace-v1" + ), + }, + ) + + +@pretrained_model() +class V2ABeta01(PretrainedModel): + def __init__(self): + super().__init__( + model_name="beta_01", + model_gin_config="smaller_multitaskagent.gin", + train_gin_config="alakazam_beta0.1.gin", + default_checkpoint=20, + action_space=get_action_space("DefaultActionSpace"), + observation_space=get_observation_space("OpponentMoveObservationSpace"), + reward_function=get_reward_function("AggressiveShapedReward"), + tokenizer=get_tokenizer("DefaultObservationSpace-v1"), + battle_backend="metamon", + gin_overrides={ + "MetamonPerceiverTstepEncoder.tokenizer": get_tokenizer( + "DefaultObservationSpace-v1" + ), + }, + ) + + +@pretrained_model() +class V2ABeta1(PretrainedModel): + def __init__(self): + super().__init__( + model_name="beta_1", + model_gin_config="smaller_multitaskagent.gin", + train_gin_config="alakazam_beta1.gin", + default_checkpoint=20, + action_space=get_action_space("DefaultActionSpace"), + observation_space=get_observation_space("OpponentMoveObservationSpace"), + reward_function=get_reward_function("AggressiveShapedReward"), + tokenizer=get_tokenizer("DefaultObservationSpace-v1"), + battle_backend="metamon", + gin_overrides={ + "MetamonPerceiverTstepEncoder.tokenizer": get_tokenizer( + "DefaultObservationSpace-v1" + ), + }, + ) + + +@pretrained_model() +class V2ABeta3(PretrainedModel): + def __init__(self): + super().__init__( + model_name="beta_3", + model_gin_config="smaller_multitaskagent.gin", + train_gin_config="alakazam_beta3.gin", + default_checkpoint=20, + action_space=get_action_space("DefaultActionSpace"), + observation_space=get_observation_space("OpponentMoveObservationSpace"), + reward_function=get_reward_function("AggressiveShapedReward"), + tokenizer=get_tokenizer("DefaultObservationSpace-v1"), + battle_backend="metamon", + gin_overrides={ + "MetamonPerceiverTstepEncoder.tokenizer": get_tokenizer( + "DefaultObservationSpace-v1" + ), + }, + ) + + +@pretrained_model() +class V2ABeta10(PretrainedModel): + def __init__(self): + super().__init__( + model_name="beta_10", + model_gin_config="smaller_multitaskagent.gin", + train_gin_config="alakazam_beta10.gin", + default_checkpoint=20, + action_space=get_action_space("DefaultActionSpace"), + observation_space=get_observation_space("OpponentMoveObservationSpace"), + reward_function=get_reward_function("AggressiveShapedReward"), + tokenizer=get_tokenizer("DefaultObservationSpace-v1"), + battle_backend="metamon", + gin_overrides={ + "MetamonPerceiverTstepEncoder.tokenizer": get_tokenizer( + "DefaultObservationSpace-v1" + ), + }, + ) + + +@pretrained_model() +class V2ABNBeta3HLGauss(PretrainedModel): + def __init__(self): + super().__init__( + model_name="bn_beta3_hlgauss", + model_gin_config="smaller_multitaskagent.gin", + train_gin_config="alakazam_bnorm_beta3_hlgauss.gin", + default_checkpoint=26, + action_space=get_action_space("DefaultActionSpace"), + observation_space=get_observation_space("OpponentMoveObservationSpace"), + reward_function=get_reward_function("AggressiveShapedReward"), + tokenizer=get_tokenizer("DefaultObservationSpace-v1"), + battle_backend="metamon", + gin_overrides={ + "MetamonPerceiverTstepEncoder.tokenizer": get_tokenizer( + "DefaultObservationSpace-v1" + ), + }, + ) + + +@pretrained_model() +class V2ABNBeta3HLGaussVanilla(PretrainedModel): + def __init__(self): + super().__init__( + model_name="bn_beta3_hlgauss_vanilla", + model_gin_config="smaller_multitaskagent.gin", + train_gin_config="alakazam_bnorm_beta3_hlgauss_vanilla.gin", + default_checkpoint=26, + action_space=get_action_space("DefaultActionSpace"), + observation_space=get_observation_space("OpponentMoveObservationSpace"), + reward_function=get_reward_function("AggressiveShapedReward"), + tokenizer=get_tokenizer("DefaultObservationSpace-v1"), + battle_backend="metamon", + gin_overrides={ + "MetamonPerceiverTstepEncoder.tokenizer": get_tokenizer( + "DefaultObservationSpace-v1" + ), + }, + ) + + +@pretrained_model() +class V2ABNBeta3(PretrainedModel): + def __init__(self): + super().__init__( + model_name="bn_beta3", + model_gin_config="smaller_multitaskagent.gin", + train_gin_config="alakazam_bnorm_beta3.gin", + default_checkpoint=20, + action_space=get_action_space("DefaultActionSpace"), + observation_space=get_observation_space("OpponentMoveObservationSpace"), + reward_function=get_reward_function("AggressiveShapedReward"), + tokenizer=get_tokenizer("DefaultObservationSpace-v1"), + battle_backend="metamon", + gin_overrides={ + "MetamonPerceiverTstepEncoder.tokenizer": get_tokenizer( + "DefaultObservationSpace-v1" + ), + }, + ) + + +@pretrained_model() +class V2AMixedGens(PretrainedModel): + def __init__(self): + super().__init__( + model_name="v2_small_rl_baseline_all_gens", + model_gin_config="smaller_multitaskagent.gin", + train_gin_config="alakazam3.gin", + # this one trained all the way out to epoch 80! + default_checkpoint=26, + action_space=get_action_space("DefaultActionSpace"), + observation_space=get_observation_space("OpponentMoveObservationSpace"), + reward_function=get_reward_function("AggressiveShapedReward"), + tokenizer=get_tokenizer("DefaultObservationSpace-v1"), + battle_backend="metamon", + gin_overrides={ + "MetamonPerceiverTstepEncoder.tokenizer": get_tokenizer( + "DefaultObservationSpace-v1" + ), + }, + ) + + +@pretrained_model() +class V2ANoMG(PretrainedModel): + def __init__(self): + super().__init__( + model_name="v2_small_rl_baseline_nomg_g99", + model_gin_config="smaller_multitaskagent.gin", + train_gin_config="alakazam3.gin", + default_checkpoint=26, + action_space=get_action_space("DefaultActionSpace"), + observation_space=get_observation_space("OpponentMoveObservationSpace"), + reward_function=get_reward_function("AggressiveShapedReward"), + tokenizer=get_tokenizer("DefaultObservationSpace-v1"), + battle_backend="metamon", + gin_overrides={ + "MetamonPerceiverTstepEncoder.tokenizer": get_tokenizer( + "DefaultObservationSpace-v1" + ), + "MultiTaskAgent.use_multigamma": False, + "MultiTaskAgent.gamma": 0.99, + }, + ) + + +@pretrained_model() +class V2AIL(PretrainedModel): + def __init__(self): + super().__init__( + model_name="v2_small_il_baseline", + model_gin_config="smaller_multitaskagent.gin", + train_gin_config="il.gin", + default_checkpoint=26, + action_space=get_action_space("DefaultActionSpace"), + observation_space=get_observation_space("OpponentMoveObservationSpace"), + reward_function=get_reward_function("AggressiveShapedReward"), + tokenizer=get_tokenizer("DefaultObservationSpace-v1"), + battle_backend="metamon", + gin_overrides={ + "MetamonPerceiverTstepEncoder.tokenizer": get_tokenizer( + "DefaultObservationSpace-v1" + ), + "MetamonAMAGOExperiment.learning_rate": 1.25e-4, + "MetamonAMAGOExperiment.lr_warmup_steps": 2000, + }, + ) + + +@pretrained_model() +class V2AGroupedV2ISFilter(PretrainedModel): + def __init__(self): + super().__init__( + model_name="v2_small_rl_grouped_v2_isfilter", + model_gin_config="smaller_multitaskagent_grouped_v2.gin", + train_gin_config="alakazam3_isfilter.gin", + default_checkpoint=88, + action_space=get_action_space("DefaultActionSpace"), + observation_space=get_observation_space("GroupedObservationSpace"), + reward_function=get_reward_function("AggressiveShapedReward"), + tokenizer=get_tokenizer("DefaultObservationSpace-v1"), + battle_backend="metamon", + gin_overrides={ + "MetamonGroupedTstepEncoderV2.tokenizer": get_tokenizer( + "DefaultObservationSpace-v1" + ), + }, + ) + + +@pretrained_model() +class V2AGroupedV2Patched(PretrainedModel): + def __init__(self): + super().__init__( + model_name="v2_small_rl_grouped_v2_patched", + model_gin_config="smaller_multitaskagent_grouped_v2.gin", + train_gin_config="alakazam3.gin", + default_checkpoint=26, + action_space=get_action_space("DefaultActionSpace"), + observation_space=get_observation_space("GroupedObservationSpace"), + reward_function=get_reward_function("AggressiveShapedReward"), + tokenizer=get_tokenizer("DefaultObservationSpace-v1"), + battle_backend="metamon", + gin_overrides={ + "MetamonGroupedTstepEncoderV2.tokenizer": get_tokenizer( + "DefaultObservationSpace-v1" + ), + }, + ) + + +@pretrained_model() +class V2AGroupedV2ArchAblation(PretrainedModel): + """Grouped V2 arch trained on the V2A baseline data mix (pac-base 60%, pac-exploratory 35%). + + Paired with V2AGroupedV2DataAblation to isolate the effect of the Tauros data mix + vs. the architecture change (smaller_multitaskagent_grouped_v2_arch). + """ + + def __init__(self): + super().__init__( + model_name="v2_grouped_v2_arch_ablation", + model_gin_config="smaller_multitaskagent_grouped_v2_arch.gin", + train_gin_config="grouped_v2_large_isfilter.gin", + default_checkpoint=32, + action_space=get_action_space("DefaultActionSpace"), + observation_space=get_observation_space("GroupedObservationSpace"), + reward_function=get_reward_function("AggressiveShapedReward"), + tokenizer=get_tokenizer("DefaultObservationSpace-v1"), + battle_backend="metamon", + gin_overrides={ + "MetamonGroupedTstepEncoderV2.tokenizer": get_tokenizer( + "DefaultObservationSpace-v1" + ), + }, + ) + + +@pretrained_model() +class V2AGroupedV2DataAblation(PretrainedModel): + def __init__(self): + super().__init__( + model_name="v2_grouped_v2_arch_data_ablation", + model_gin_config="smaller_multitaskagent_grouped_v2_arch.gin", + train_gin_config="grouped_v2_large_isfilter.gin", + default_checkpoint=90, + action_space=get_action_space("DefaultActionSpace"), + observation_space=get_observation_space("GroupedObservationSpace"), + reward_function=get_reward_function("AggressiveShapedReward"), + tokenizer=get_tokenizer("DefaultObservationSpace-v1"), + battle_backend="metamon", + gin_overrides={ + "MetamonGroupedTstepEncoderV2.tokenizer": get_tokenizer( + "DefaultObservationSpace-v1" + ), + }, + ) + + +@pretrained_model() +class TaurosV0(PretrainedModel): + def __init__(self): + super().__init__( + model_name="tauros-v0", + model_gin_config="grouped_v2_50m.gin", + train_gin_config="grouped_v2_large_isfilter.gin", + default_checkpoint=62, + action_space=get_action_space("DefaultActionSpace"), + observation_space=get_observation_space("GroupedObservationSpace"), + reward_function=get_reward_function("AggressiveShapedReward"), + tokenizer=get_tokenizer("DefaultObservationSpace-v1"), + battle_backend="metamon", + gin_overrides={ + "MetamonGroupedTstepEncoderV2.tokenizer": get_tokenizer( + "DefaultObservationSpace-v1" + ), + }, + ) + + +@pretrained_model() +class KakunaEnsemble(PretrainedModel): + """ + A prototype version of ensembling metamon policies. + + Notably became the first Metamon agent to reach #1 on the Showdown leaderboard. + """ + + # these gxe scores are incorrect + MEMBER_SPECS = [ + EnsembleMemberSpec( + model_name="Kakuna", + checkpoint=34, + gxe=0.75, + proposer_bias=1.10, + judge_bias=1.15, + shortlist_k=3, + ), + EnsembleMemberSpec( + model_name="Kakuna", + checkpoint=28, + gxe=0.78, + proposer_bias=1.20, + judge_bias=1.15, + shortlist_k=3, + ), + EnsembleMemberSpec( + model_name="Kakuna", + checkpoint=30, + gxe=0.72, + proposer_bias=1.35, + judge_bias=0.15, + shortlist_k=2, + proposal_roles=("move", "counter_anchor"), + ), + EnsembleMemberSpec( + model_name="Alakazam", + checkpoint=8, + gxe=0.64, + proposer_bias=1.45, + judge_bias=0.05, + shortlist_k=2, + proposal_roles=("move", "counter_anchor"), + ), + ] + MEMBER_PRESETS = _load_ensemble_member_presets() + + @classmethod + def _parse_member_specs_raw(cls, raw: str) -> list[EnsembleMemberSpec]: + specs: list[EnsembleMemberSpec] = [] + for chunk in raw.split(";"): + chunk = chunk.strip() + if not chunk: + continue + parts = [part.strip() for part in chunk.split(",")] + if len(parts) not in {1, 5, 6, 7}: + raise ValueError( + "METAMON_ENSEMBLE_MEMBER_SPECS entries must look like " + "'Model@Checkpoint,gxe,proposer_bias,judge_bias,shortlist_k[,role1|role2][,action_temperature]'" + ) + + name_part = parts[0] + if "@" in name_part: + model_name, checkpoint_str = name_part.split("@", 1) + checkpoint = int(checkpoint_str) if checkpoint_str else None + else: + model_name = name_part + checkpoint = None + + if len(parts) == 1: + specs.append( + EnsembleMemberSpec(model_name=model_name, checkpoint=checkpoint) + ) + continue + + proposal_roles: tuple[str, ...] = () + action_temperature = 1.0 + if len(parts) >= 6: + try: + action_temperature = float(parts[5]) + except ValueError: + proposal_roles = tuple( + role.strip() for role in parts[5].split("|") if role.strip() + ) + if len(parts) == 7: + action_temperature = float(parts[6]) + + specs.append( + EnsembleMemberSpec( + model_name=model_name, + checkpoint=checkpoint, + gxe=float(parts[1]), + proposer_bias=float(parts[2]), + judge_bias=float(parts[3]), + shortlist_k=int(parts[4]), + proposal_roles=proposal_roles, + action_temperature=action_temperature, + ) + ) + if not specs: + raise ValueError("METAMON_ENSEMBLE_MEMBER_SPECS produced no members") + return specs + + @classmethod + def _member_specs_from_env(cls) -> list[EnsembleMemberSpec]: + raw = os.environ.get("METAMON_ENSEMBLE_MEMBER_SPECS", "").strip() + if raw: + return cls._parse_member_specs_raw(raw) + + preset_name = os.environ.get("METAMON_ENSEMBLE_PRESET", "").strip() + if preset_name: + if preset_name not in cls.MEMBER_PRESETS: + raise ValueError( + f"Unknown METAMON_ENSEMBLE_PRESET '{preset_name}' " + f"(available: {sorted(cls.MEMBER_PRESETS)})" + ) + return cls.MEMBER_PRESETS[preset_name] + + return cls.MEMBER_SPECS + + def __init__(self): + super().__init__( + model_name="kakuna-ensemble", + model_gin_config="superkazam.gin", + train_gin_config="kakuna.gin", + default_checkpoint=34, + action_space=get_action_space("DefaultActionSpace"), + observation_space=get_observation_space("OpponentMoveObservationSpace"), + reward_function=get_reward_function("AggressiveShapedReward"), + tokenizer=get_tokenizer("DefaultObservationSpace-v1"), + battle_backend="metamon", + gin_overrides={ + "MetamonPerceiverTstepEncoder.tokenizer": get_tokenizer( + "DefaultObservationSpace-v1" + ), + }, + ) + + def initialize_agent( + self, + checkpoint: Optional[int] = None, + log: bool = False, + action_temperature: float = 1.0, + ): + member_specs = self._member_specs_from_env() + return build_heuristic_ensemble_experiment( + reference_model_name="Kakuna", + member_specs=member_specs, + expected_obs_space=self.observation_space, + expected_action_space=self.action_space, + log=log, + action_temperature=action_temperature, + ) + + +@pretrained_model() +class TaurosEnsemble(KakunaEnsemble): + """ + Followup to KakunaEnsemble that also reached #1 on the Showdown leaderboard. + """ + + MEMBER_SPECS = [ + EnsembleMemberSpec( + model_name="TaurosV0", + checkpoint=62, + gxe=0.86, + proposer_bias=1.05, + judge_bias=1.65, + shortlist_k=5, + action_temperature=0.82, + ), + EnsembleMemberSpec( + model_name="TaurosV0", + checkpoint=62, + gxe=0.86, + proposer_bias=1.65, + judge_bias=0.05, + shortlist_k=4, + proposal_roles=("move", "switch", "counter_anchor"), + action_temperature=1.22, + ), + EnsembleMemberSpec( + model_name="TaurosV0", + checkpoint=66, + gxe=0.83, + proposer_bias=1.15, + judge_bias=0.55, + shortlist_k=3, + proposal_roles=("move", "counter_anchor"), + action_temperature=0.98, + ), + EnsembleMemberSpec( + model_name="V2AGroupedV2DataAblation", + checkpoint=90, + gxe=0.79, + proposer_bias=1.35, + judge_bias=0.20, + shortlist_k=3, + proposal_roles=("move", "switch", "counter_anchor"), + action_temperature=1.08, + ), + EnsembleMemberSpec( + model_name="V2AGroupedV2ISFilter", + checkpoint=88, + gxe=0.77, + proposer_bias=1.10, + judge_bias=0.60, + shortlist_k=3, + proposal_roles=("move", "counter_anchor"), + action_temperature=1.02, + ), + ] + + def __init__(self): + PretrainedModel.__init__( + self, + model_name="tauros-ensemble", + model_gin_config="grouped_v2_50m.gin", + train_gin_config="grouped_v2_large_isfilter.gin", + default_checkpoint=62, + action_space=get_action_space("DefaultActionSpace"), + observation_space=get_observation_space("GroupedObservationSpace"), + reward_function=get_reward_function("AggressiveShapedReward"), + tokenizer=get_tokenizer("DefaultObservationSpace-v1"), + battle_backend="metamon", + gin_overrides={ + "MetamonGroupedTstepEncoderV2.tokenizer": get_tokenizer( + "DefaultObservationSpace-v1" + ), + }, + ) + + def initialize_agent( + self, + checkpoint: Optional[int] = None, + log: bool = False, + action_temperature: float = 1.0, + ): + member_specs = self._member_specs_from_env() + return build_heuristic_ensemble_experiment( + reference_model_name="TaurosV0", + member_specs=member_specs, + expected_obs_space=self.observation_space, + expected_action_space=self.action_space, + log=log, + action_temperature=action_temperature, + ) + + +import metamon.rl.experimental.ensemble.register # noqa: F401 — nickname ensemble agents diff --git a/metamon/rl/self_play/README.md b/metamon/rl/self_play/README.md deleted file mode 100644 index 75dbbc13ad..0000000000 --- a/metamon/rl/self_play/README.md +++ /dev/null @@ -1,31 +0,0 @@ -# Self-Play - -Utility to auto-manage a local ladder of agents for self-play data collection or bulk eval purposes. - -Specify the participating agents with a `.yaml` file. Here is an exmaple: - -```yaml -defaults: - team_set: competitive - battle_backend: metamon - # if a list, each agent launch will pick a value from the list at random - checkpoints: [null] - temperatures: [1.0] - num_agents: 1 # number of parallel copies to launch per agent - -agents: - # USERNAME: - # model_name: SomeModel - # checkpoints: [2] # override default - # num_agents: 3 # will launch USERNAME-1, USERNAME-2, USERNAME-3 - - PAC-MM-Kadabra: - model_name: Kadabra - - PAC-MM-SynRLV2: - model_name: SyntheticRLV2 -``` - -```bash -python launch_models.py --format gen2ou --gpus 0 1 --config earlygen_config.yaml -``` \ No newline at end of file diff --git a/metamon/rl/self_play/__main__.py b/metamon/rl/self_play/__main__.py deleted file mode 100644 index 5ce8ae76fc..0000000000 --- a/metamon/rl/self_play/__main__.py +++ /dev/null @@ -1,11 +0,0 @@ -""" -Run the self-play launcher as a module. - -Usage: - python -m metamon.rl.self_play --format gen9ou --gpus 0 1 --config metamon/rl/self_play/gen9ou_config.yaml --save_trajectories_to ./trajectories -""" - -from metamon.rl.self_play.launch_models import main - -if __name__ == "__main__": - main() diff --git a/metamon/rl/self_play/earlygen_config.yaml b/metamon/rl/self_play/earlygen_config.yaml deleted file mode 100644 index efcc4f704a..0000000000 --- a/metamon/rl/self_play/earlygen_config.yaml +++ /dev/null @@ -1,53 +0,0 @@ -defaults: - team_set: modern_replays_v2 - battle_backend: metamon - checkpoints: [null] - temperatures: [1.0, 1.2, 1.3, 1.5, 1.75, 2.0, 2.25] - num_agents: 1 - -agents: - SynRLV0: - model_name: SyntheticRLV0 - checkpoints: [null] - num_agents: 1 - - SynRLV1: - model_name: SyntheticRLV1 - checkpoints: [null] - num_agents: 1 - - SynRLV1_PlusPlus: - model_name: SyntheticRLV1_PlusPlus - checkpoints: [null] - num_agents: 1 - - PAC-MM-SynRLV2: - model_name: SyntheticRLV2 - checkpoints: [null, 32, 36, 34] - num_agents: 5 - - PAC-MM-Kadabra: - model_name: Kadabra - - PAC-MM-SmallILFA: - model_name: SmallILFA - checkpoints: [2] - num_agents: 2 - - PAC-MM-Minikazam: - model_name: Minikazam - - PAC-MM-Alakazam: - model_name: Alakazam2 - checkpoints: [40, 48, 36, 32] - num_agents: 2 - - PAC-MM-Wildcard: - model_name: Alakazam3 - checkpoints: [10, 12, 20, 22, 14] - num_agents: 4 - - PAC-MM-Mystery: - model_name: Alakazam4 - checkpoints: [30, 40, 48, 50, 52, 32] - num_agents: 4 diff --git a/metamon/rl/train.py b/metamon/rl/train.py index 8fb4b19e3f..b9dc8a749b 100644 --- a/metamon/rl/train.py +++ b/metamon/rl/train.py @@ -15,16 +15,20 @@ RewardFunction, ) from metamon.tokenizer import get_tokenizer -from metamon.data import ParsedReplayDataset, SelfPlayDataset, MetamonDataset from metamon.rl.metamon_to_amago import ( MetamonAMAGOExperiment, MetamonAMAGODataset, make_baseline_env, make_placeholder_env, ) +from metamon.rl.dataset_config import ( + load_dataset_config, + save_dataset_config, + flatten_config, + build_dataset, +) from metamon import baselines - WANDB_PROJECT = os.environ.get("METAMON_WANDB_PROJECT") WANDB_ENTITY = os.environ.get("METAMON_WANDB_ENTITY") EVAL_OPPONENTS = [ @@ -78,6 +82,12 @@ def add_cli(parser): default=100, help="Number of epochs to train for. In offline RL model, an epoch is an arbitrary interval (here: 25k) of training steps on a fixed dataset.", ) + parser.add_argument( + "--ckpt_interval", + type=int, + default=2, + help="Save a checkpoint every N epochs.", + ) parser.add_argument( "--batch_size_per_gpu", type=int, @@ -115,47 +125,10 @@ def add_cli(parser): help="Number of workers for the data loader.", ) parser.add_argument( - "--parsed_replay_dir", - type=str, - default=None, - help="Path to the parsed replay directory. Defaults to the official huggingface version.", - ) - parser.add_argument( - "--replay_weight", - type=float, - default=1.0, - help="Sampling weight for the human parsed replay dataset (metamon-parsed-replays). Will be renormalized with other weights.", - ) - parser.add_argument( - "--self_play_subsets", - type=str, - nargs="+", - default=None, - help="Official self-play dataset (metamon-parsed-pile) subsets to include (e.g., 'pac-base', 'pac-exploratory'). If not provided, self-play data is not used.", - ) - parser.add_argument( - "--self_play_weights", - type=float, - nargs="+", - default=None, - help="Sampling weights for each self-play subset. Must match length of --self_play_subsets.", - ) - parser.add_argument( - "--custom_replay_dir", + "--dataset_config", type=str, - default=None, - help="Path to an optional custom parsed replay dataset (e.g., additional self-play data you've collected).", - ) - parser.add_argument( - "--custom_replay_weight", - type=float, - default=0.25, - help="Sampling weight for the custom dataset (if provided). Will be renormalized with other weights.", - ) - parser.add_argument( - "--use_cached_filenames", - action="store_true", - help="Use cached filename index for faster startup when reusing an identical training set.", + required=True, + help="Path to a dataset config YAML file. See metamon/rl/configs/datasets/ for examples.", ) parser.add_argument( "--async_env_mp_context", @@ -170,154 +143,10 @@ def add_cli(parser): default=[1, 2, 3, 4, 9], help="Generations (of OU) to play against heuristics between training epochs. Win rates usually saturate at 90%%+ quickly, so this is mostly a sanity-check. Reduce gens to save time on launch! Use `--eval_gens` (no arguments) to disable evaluation.", ) - parser.add_argument( - "--formats", - nargs="+", - default=None, - help="Showdown battle formats to include in the dataset. Defaults to all supported formats.", - ) parser.add_argument("--log", action="store_true", help="Log to wandb.") return parser -def create_offline_dataset( - obs_space: TokenizedObservationSpace, - action_space: ActionSpace, - reward_function: RewardFunction, - parsed_replay_dir: Optional[str] = None, - replay_weight: float = 1.0, - self_play_subsets: Optional[List[str]] = None, - self_play_weights: Optional[List[float]] = None, - custom_replay_dir: Optional[str] = None, - custom_replay_weight: float = 0.25, - verbose: bool = True, - formats: Optional[List[str]] = None, - use_cached_filenames: bool = False, -) -> amago.loading.RLDataset: - """ - Create a mixed offline RL dataset from multiple sources. - - Args: - obs_space: Tokenized observation space - action_space: Action space - reward_function: Reward function - parsed_replay_dir: Path to parsed replays (None = download from HuggingFace) - replay_weight: Sampling weight for parsed replays - self_play_subsets: List of self-play subsets to include (e.g., ["pac-base", "pac-exploratory"]) - self_play_weights: Sampling weights for each self-play subset (must match length of self_play_subsets) - custom_replay_dir: Path to custom replay directory - custom_replay_weight: Sampling weight for custom replays - verbose: Print dataset loading progress - formats: Battle formats to include - use_cached_filenames: Use cached filename index for faster startup - - Returns: - AMAGO RLDataset (possibly a MixtureOfDatasets) - """ - formats = formats or metamon.config.SUPPORTED_BATTLE_FORMATS - - # Validate self-play weights - if self_play_subsets is not None: - if self_play_weights is None: - # Default to equal weights - self_play_weights = [1.0] * len(self_play_subsets) - elif len(self_play_weights) != len(self_play_subsets): - raise ValueError( - f"--self_play_weights ({len(self_play_weights)}) must match " - f"--self_play_subsets ({len(self_play_subsets)})" - ) - - # Common dataset kwargs - dset_kwargs = { - "observation_space": obs_space, - "action_space": action_space, - "reward_function": reward_function, - "max_seq_len": None, # amago handles sequence lengths - "formats": formats, - "verbose": verbose, - "use_cached_filenames": use_cached_filenames, - } - - # Collect all datasets and weights - datasets = [] - weights = [] - dataset_info = [] # For pretty printing - - # 1. Parsed Replays (human battles) - if replay_weight > 0: - parsed_dset = ParsedReplayDataset(dset_root=parsed_replay_dir, **dset_kwargs) - datasets.append( - MetamonAMAGODataset( - dset_name="Parsed Replays (Human)", - parsed_replay_dset=parsed_dset, - ) - ) - weights.append(replay_weight) - dataset_info.append(("Parsed Replays (Human)", len(parsed_dset), replay_weight)) - - # 2. Self-Play Datasets - if self_play_subsets is not None: - for subset, weight in zip(self_play_subsets, self_play_weights): - if weight > 0: - selfplay_dset = SelfPlayDataset(subset=subset, **dset_kwargs) - datasets.append( - MetamonAMAGODataset( - dset_name=f"Self-Play ({subset})", - parsed_replay_dset=selfplay_dset, - ) - ) - weights.append(weight) - dataset_info.append( - (f"Self-Play ({subset})", len(selfplay_dset), weight) - ) - - # 3. Custom Replay Directory - if custom_replay_dir is not None and custom_replay_weight > 0: - custom_dset = MetamonDataset(dset_root=custom_replay_dir, **dset_kwargs) - datasets.append( - MetamonAMAGODataset( - dset_name="Custom Replays", - parsed_replay_dset=custom_dset, - ) - ) - weights.append(custom_replay_weight) - dataset_info.append(("Custom Replays", len(custom_dset), custom_replay_weight)) - - if not datasets: - raise ValueError( - "No datasets configured! Provide at least one of: parsed replays, self-play subsets, or custom replay dir." - ) - - # Renormalize weights to sum to 1 - total_weight = sum(weights) - normalized_weights = [w / total_weight for w in weights] - - # Print pretty summary - print("\n" + "=" * 70) - print("TRAINING DATASET SUMMARY") - print("=" * 70) - print(f"{'Dataset':<35} {'Files':>12} {'Weight':>10} {'Norm Weight':>12}") - print("-" * 70) - total_files = 0 - for (name, num_files, raw_weight), norm_weight in zip( - dataset_info, normalized_weights - ): - total_files += num_files - print(f"{name:<35} {num_files:>12,} {raw_weight:>10.2f} {norm_weight:>11.1%}") - print("-" * 70) - print(f"{'TOTAL':<35} {total_files:>12,} {total_weight:>10.2f} {'100.0%':>12}") - print("=" * 70 + "\n") - - # Create final dataset - if len(datasets) == 1: - return datasets[0] - else: - return amago.loading.MixtureOfDatasets( - datasets=datasets, - sampling_weights=normalized_weights, - ) - - def create_offline_rl_trainer( ckpt_dir: str, run_name: str, @@ -339,6 +168,7 @@ def create_offline_rl_trainer( wandb_project: str = WANDB_PROJECT, wandb_entity: str = WANDB_ENTITY, manual_gin_overrides: Optional[dict] = None, + ckpt_interval: int = 2, ): """ Convenience function that creates an AMAGO experiment with default arguments @@ -348,6 +178,7 @@ def create_offline_rl_trainer( config = { "MetamonTstepEncoder.tokenizer": obs_space.tokenizer, "MetamonPerceiverTstepEncoder.tokenizer": obs_space.tokenizer, + "MetamonGroupedTstepEncoderV2.tokenizer": obs_space.tokenizer, } if manual_gin_overrides is not None: config.update(manual_gin_overrides) @@ -381,21 +212,17 @@ def create_offline_rl_trainer( ## required ## run_name=run_name, ckpt_base_dir=ckpt_dir, - # max_seq_len = should be set in the gin file dataset=amago_dataset, - # tstep_encoder_type = should be set in the gin file - # traj_encoder_type = should be set in the gin file - # agent_type = should be set in the gin file - val_timesteps_per_epoch=val_timesteps_per_epoch, # per actor + val_timesteps_per_epoch=val_timesteps_per_epoch, ## environment ## make_train_env=partial(make_placeholder_env, obs_space, action_space), make_val_env=make_envs, env_mode="async", async_env_mp_context=async_env_mp_context, parallel_actors=len(make_envs), - # no exploration exploration_wrapper_type=None, - sample_actions=True, + sample_actions_train=True, + sample_actions_val=True, force_reset_train_envs_every=None, ## logging ## log_to_wandb=log, @@ -408,13 +235,12 @@ def create_offline_rl_trainer( dloader_workers=dloader_workers, ## learning schedule ## epochs=epochs, - # entirely offline RL start_learning_at_epoch=0, start_collecting_at_epoch=float("inf"), train_timesteps_per_epoch=0, train_batches_per_epoch=steps_per_epoch * grad_accum, val_interval=1, - ckpt_interval=2, + ckpt_interval=ckpt_interval, ## optimization ## batch_size=batch_size_per_gpu, batches_per_update=grad_accum, @@ -443,6 +269,7 @@ def create_offline_rl_trainer( print( f" Run: {args.run_name} | Model: {args.model_gin_config} | Training: {args.train_gin_config}" ) + print(f" Dataset config: {args.dataset_config}") print() # agent input/output/rewards @@ -452,21 +279,20 @@ def create_offline_rl_trainer( reward_function = get_reward_function(args.reward_function) action_space = get_action_space(args.action_space) - # metamon dataset - amago_dataset = create_offline_dataset( + # load dataset config and build dataset + dataset_config = load_dataset_config(args.dataset_config) + amago_dataset = build_dataset( + config=dataset_config, obs_space=obs_space, action_space=action_space, reward_function=reward_function, - parsed_replay_dir=args.parsed_replay_dir, - replay_weight=args.replay_weight, - self_play_subsets=args.self_play_subsets, - self_play_weights=args.self_play_weights, - custom_replay_dir=args.custom_replay_dir, - custom_replay_weight=args.custom_replay_weight, - formats=args.formats, - use_cached_filenames=args.use_cached_filenames, ) + # auto-save effective config to checkpoint directory + config_save_path = os.path.join(args.save_dir, args.run_name, "dataset_config.yaml") + save_dataset_config(flatten_config(dataset_config), config_save_path) + print(f" Dataset config saved to: {config_save_path}\n") + # quick-setup for an offline RL experiment experiment = create_offline_rl_trainer( ckpt_dir=args.save_dir, @@ -486,10 +312,10 @@ def create_offline_rl_trainer( log=args.log, wandb_project=WANDB_PROJECT, wandb_entity=WANDB_ENTITY, + ckpt_interval=args.ckpt_interval, ) experiment.start() if args.ckpt is not None: - # resume training from a checkpoint experiment.load_checkpoint(args.ckpt) experiment.learn() wandb.finish() diff --git a/pyproject.toml b/pyproject.toml index 95a99573fc..8a348b9df8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "metamon" -version = "1.5.1" +version = "1.6.0" description = "Baselines and Datasets for Pokémon Showdown RL" readme = { file = "README.md", content-type = "text/markdown" } authors = [ @@ -31,11 +31,12 @@ dependencies = [ "tqdm", "lz4", "termcolor", + "rich", "huggingface_hub", "datasets", "ratarmountcore", "poke-env @ git+https://github.com/UT-Austin-RPL/poke-env.git", - "amago @ git+https://github.com/UT-Austin-RPL/amago.git@v3.1" + "amago @ git+https://github.com/UT-Austin-RPL/amago@v3.4.0", ] [project.urls] @@ -45,3 +46,7 @@ Repository = "https://github.com/UT-Austin-RPL/metamon" [tool.setuptools.packages.find] include = ["metamon*"] + +[tool.black] +line-length = 88 +target-version = ["py310"] diff --git a/server/config.js b/server/config.js deleted file mode 100644 index 84eb329cdc..0000000000 --- a/server/config.js +++ /dev/null @@ -1,755 +0,0 @@ -'use strict'; - -/** - * The server port - the port to run Pokemon Showdown under - * - * @type {number} - */ -exports.port = 8000; - -/** - * The server address - the address at which Pokemon Showdown should be hosting - * This should be kept set to 0.0.0.0 unless you know what you're doing. - * - * @type {string} - */ -exports.bindaddress = '0.0.0.0'; - -/** - * workers - the number of networking child processes to spawn - * This should be no greater than the number of threads available on your - * server's CPU. If you're not sure how many you have, you can check from a - * terminal by running: - * - * $ node -e "console.log(require('os').cpus().length)" - * - * Using more workers than there are available threads will cause performance - * issues. Keeping a couple threads available for use for OS-related work and - * other PS processes will likely give you the best performance, if your - * server's CPU is capable of multithreading. If you don't know what any of - * this means or you are unfamiliar with PS' networking code, leave this set - * to 1. - */ -exports.workers = 12; - -/** - * wsdeflate - compresses WebSocket messages - * Toggles use of the Sec-WebSocket-Extension permessage-deflate extension. - * This compresses messages sent and received over a WebSocket connection - * using the zlib compression algorithm. As a caveat, message compression - * may make messages take longer to procress. - * @type {AnyObject?} - */ -exports.wsdeflate = null; - -/* -// example: -exports.wsdeflate = { - level: 5, - memLevel: 8, - strategy: 0, - noContextTakeover: true, - requestNoContextTakeover: true, - maxWindowBits: 15, - requestMaxWindowBits: 15, -}; */ - -/** - * ssl - support WSS, allowing you to access through HTTPS - * The client requires port 443, so if you use a different port here, - * it will need to be forwarded to 443 through iptables rules or - * something. - * @type {{port: number, options: {key: string, cert: string}} | null} - */ -exports.ssl = null; - -/* -// example: -exports.ssl = { - port: 443, - options: { - key: './config/ssl/privkey.pem', - cert: './config/ssl/fullchain.pem', - }, -}; -*/ - -/* -Main's SSL deploy script from Let's Encrypt looks like: - cp /etc/letsencrypt/live/sim.psim.us/privkey.pem ~user/Pokemon-Showdown/config/ssl/ - cp /etc/letsencrypt/live/sim.psim.us/fullchain.pem ~user/Pokemon-Showdown/config/ssl/ - chown user:user ~user/Pokemon-Showdown/config/ssl/privkey.pem - chown user:user ~user/Pokemon-Showdown/config/ssl/fullchain.pem -*/ - -/** - * proxyip - proxy IPs with trusted X-Forwarded-For headers - * This can be either false (meaning not to trust any proxies) or an array - * of strings. Each string should be either an IP address or a subnet given - * in CIDR notation. You should usually leave this as `false` unless you - * know what you are doing - * @type {false | string[]}. - */ -exports.proxyip = false; - -/** - * Various debug options - * - * ofe[something] - * ============================================================================ - * - * Write heapdumps if that processs run out of memory. - * - * If you wish to enable this, you will need to install node-oom-heapdump: - * - * $ npm install --no-save node-oom-heapdump - * - * We don't install it by default because it's super flaky and frequently - * crashes the installation process. - * - * You might also want to signal processes to put them in debug mode, for - * access to on-demand heapdumps. - * - * kill -s SIGUSR1 [pid] - * - * debug[something]processes - * ============================================================================ - * - * Attach a `debug` property to `ProcessWrapper`, allowing you to see the last - * message it received before it hit an infinite loop. - * - * For example: - * - * >> ProcessManager.processManagers[4].processes[0].debug - * << "{"tar":"spe=60,all,!lc,!nfe","cmd":"dexsearch","canAll":true,"message":"/ds spe=60,all,!lc,!nfe"}" - */ -exports.ofemain = false; -exports.ofesockets = false; -exports.debugsimprocesses = true; -exports.debugvalidatorprocesses = true; -exports.debugdexsearchprocesses = true; - -/** - * Pokemon of the Day - put a pokemon's name here to make it Pokemon of the Day - * The PotD will always be in the #2 slot (not #1 so it won't be a lead) - * in every Random Battle team. - * - * @type {ID} - */ -exports.potd = ''; - -/** - * crash guard - write errors to log file instead of crashing - * This is normally not recommended - if Node wants to crash, the - * server needs to be restarted - * However, most people want the server to stay online even if there is a - * crash, so this option is provided - */ -exports.crashguard = true; - -/** - * login server data - don't forget the http:// and the trailing slash - * This is the URL of the user database and ladder mentioned earlier. - * Don't change this setting - there aren't any other login servers right now - */ -exports.loginserver = 'http://play.pokemonshowdown.com/'; -exports.loginserverkeyalgo = "RSA-SHA1"; -exports.loginserverpublickeyid = 4; -exports.loginserverpublickey = `-----BEGIN PUBLIC KEY----- -MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAzfWKQXg2k8c92aiTyN37 -dl76iW0aeAighgzeesdar4xZT1A9yzLpj2DgR8F8rh4R32/EVOPmX7DCf0bYWeh3 -QttP0HVKKKfsncJZ9DdNtKj1vWdUTklH8oeoIZKs54dwWgnEFKzb9gxqu+z+FJoQ -vPnvfjCRUPA84O4kqKSuZT2qiWMFMWNQPXl87v+8Atb+br/WXvZRyiLqIFSG+ySn -Nwx6V1C8CA1lYqcPcTfmQs+2b4SzUa8Qwkr9c1tZnXlWIWj8dVvdYtlo0sZZBfAm -X71Rsp2vwEleSFKV69jj+IzAfNHRRw+SADe3z6xONtrJOrp+uC/qnLNuuCfuOAgL -dnUVFLX2aGH0Wb7ZkriVvarRd+3otV33A8ilNPIoPb8XyFylImYEnoviIQuv+0VW -RMmQlQ6RMZNr6sf9pYMDhh2UjU11++8aUxBaso8zeSXC9hhp7mAa7OTxts1t3X57 -72LqtHHEzxoyLj/QDJAsIfDmUNAq0hpkiRaXb96wTh3IyfI/Lqh+XmyJuo+S5GSs -RhlSYTL4lXnj/eOa23yaqxRihS2MT9EZ7jNd3WVWlWgExIS2kVyZhL48VA6rXDqr -Ko0LaPAMhcfETxlFQFutoWBRcH415A/EMXJa4FqYa9oeXWABNtKkUW0zrQ194btg -Y929lRybWEiKUr+4Yw2O1W0CAwEAAQ== ------END PUBLIC KEY----- -`; - -/** - * routes - where Pokemon Showdown is hosted. - * Don't change this setting - there aren't any other options right now - */ -exports.routes = { - root: 'pokemonshowdown.com', - client: 'play.pokemonshowdown.com', - dex: 'dex.pokemonshowdown.com', - replays: 'replay.pokemonshowdown.com', -}; - -/** - * crashguardemail - if the server has been running for more than an hour - * and crashes, send an email using these settings, rather than locking down - * the server. Uncomment this definition if you want to use this feature; - * otherwise, all crashes will lock down the server. If you wish to enable - * this setting, you will need to install nodemailer, as it is not installed - * by default: - * $ npm install nodemailer - * @type {AnyObject?} - */ -exports.crashguardemail = null; -/* exports.crashguardemail = { - options: { - host: 'mail.example.com', - port: 465, - secure: true, - auth: { - user: 'example@domain.com', - pass: 'password' - } - }, - from: 'crashlogger@example.com', - to: 'admin@example.com', - subject: 'Pokemon Showdown has crashed!' -}; */ - -/** - * basic name filter - removes characters used for impersonation - * The basic name filter removes Unicode characters that can be used for impersonation, - * like the upside-down exclamation mark (looks like an i), the Greek omicron (looks - * like an o), etc. Disable only if you need one of the alphabets it disables, such as - * Greek or Cyrillic. - */ -exports.disablebasicnamefilter = false; - -/** - * allowrequestingties - enables the use of `/offerdraw` and `/acceptdraw` - */ -exports.allowrequestingties = true; - -/** - * report joins and leaves - shows messages like " joined" - * Join and leave messages are small and consolidated, so there will never - * be more than one line of messages. - * If this setting is set to `true`, it will override the client-side - * /hidejoins configuration for users. - * This feature can lag larger servers - turn this off if your server is - * getting more than 80 or so users. - */ -exports.reportjoins = true; - -/** - * report joins and leaves periodically - sends silent join and leave messages in batches - * This setting will only be effective if `reportjoins` is set to false, and users will - * only be able to see the messages if they have the /showjoins client-side setting enabled. - * Set this to a positive amount of milliseconds if you want to enable this feature. - */ -exports.reportjoinsperiod = 0; - -/** - * report battles - shows messages like "OU battle started" in the lobby - * This feature can lag larger servers - turn this off if your server is - * getting more than 160 or so users. - * @type {boolean | string[] | string} - */ -exports.reportbattles = true; - -/** - * report joins and leaves in battle - shows messages like " joined" in battle - * Set this to false on large tournament servers where battles get a lot of joins and leaves. - * Note that the feature of turning this off is deprecated. - */ -exports.reportbattlejoins = true; - -/** - * notify staff when users have a certain amount of room punishments. - * Setting this to a number greater than zero will notify staff for everyone with - * the required amount of room punishments. - * Set this to 0 to turn the monitor off. - */ -exports.monitorminpunishments = 3; - -/** - * Turns off all time-based throttles - rename, challenges, laddering, etc. - */ -exports.nothrottle = true; - -/** - * Removes all ip-based alt checking. - */ -exports.noipchecks = false; - -/** - * controls the behavior of the /battlesearch command - * - * valid values are: - * - true: disables battlesearch entirely - * - false: enables the node.js /battlesearch - * (uses either node fs or ripgrep for searching) - * - 'psbattletools': defaults to the psbattletools /battlesearch (normally available as /alternatebattlesearch) - * (uses psbattletools, which must be installed, for searching) - * - * @type {boolean | 'psbattletools'} - */ -exports.nobattlesearch = false; - -/** - * allow punishmentmonitor to lock users with multiple roombans. - * When set to `true`, this feature will automatically lock any users with three or more - * active roombans, and notify the staff room. - * Note that this requires punishmentmonitor to be enabled, and therefore requires the `monitorminpunishments` - * option to be set to a number greater than zero. If `monitorminpunishments` is set to a value greater than 3, - * the autolock will only apply to people who pass this threshold. - */ -exports.punishmentautolock = false; - -/** - * restrict sending links to autoconfirmed users only. - * If this is set to `true`, only autoconfirmed users can send links to either chatrooms or other users, except for staff members. - * This option can be used if your server has trouble with spammers mass PMing links to users, or trolls sending malicious links. - */ -exports.restrictLinks = false; - -/** - * whitelist - prevent users below a certain group from doing things - * For the modchat settings, false will allow any user to participate, while a string - * with a group symbol will restrict it to that group and above. The string - * 'autoconfirmed' is also supported for chatmodchat and battlemodchat, to restrict - * chat to autoconfirmed users. - * This is usually intended to be used as a whitelist feature - set these to '+' and - * voice every user you want whitelisted on the server. - -/** - * chat modchat - default minimum group for speaking in chatrooms; changeable with /modchat - * @type {false | string} - */ -exports.chatmodchat = false; -/** - * battle modchat - default minimum group for speaking in battles; changeable with /modchat - * @type {false | AuthLevel} - */ -exports.battlemodchat = false; -/** - * PM modchat - minimum group for sending private messages or challenges to other users - * @type {false | AuthLevel} - */ -exports.pmmodchat = false; -/** - * ladder modchat - minimum group for laddering - * @type {false | GroupSymbol} - */ -exports.laddermodchat = false; - -/** - * forced timer - force the timer on for all battles - * Players will be unable to turn it off. - * This setting can also be turned on with the command /forcetimer. - * - * @type {boolean} - */ -exports.forcetimer = false; - -/** - * force register ELO - unregistered users cannot search for ladder battles - * in formats where their ELO is at or above this value. - * @type {false | number} - */ -exports.forceregisterelo = false; - -/** - * backdoor - allows Pokemon Showdown system operators to provide technical - * support for your server - * This backdoor gives system operators (such as Zarel) console admin - * access to your server, which allow them to provide tech support. This - * can be useful in a variety of situations: if an attacker attacks your - * server and you are not online, if you need help setting up your server, - * etc. If you do not trust Pokemon Showdown with admin access, you should - * disable this feature. - */ -exports.backdoor = true; - -/** - * List of IPs and user IDs with dev console (>> and >>>) access. - * The console is incredibly powerful because it allows the execution of - * arbitrary commands on the local computer (as the user running the - * server). If an account with the console permission were compromised, - * it could possibly be used to take over the server computer. As such, - * you should only specify a small range of trusted IPs and users here, - * or none at all. By default, only localhost can use the dev console. - * In addition to connecting from a valid IP, a user must *also* have - * the `console` permission in order to use the dev console. - * Setting this to an empty array ([]) will disable the dev console. - */ -exports.consoleips = ['127.0.0.1']; - -/** - * Whether to watch the config file for changes. If this is enabled, - * then the config.js file will be reloaded when it is changed. - * This can be used to change some settings using a text editor on - * the server. - */ -exports.watchconfig = true; - -/** - * logchat - whether to log chat rooms. - */ -exports.logchat = false; - -/** - * logchallenges - whether to log challenge battles. Useful for tournament servers. - */ -exports.logchallenges = false; - -/** - * loguserstats - how often (in milliseconds) to write user stats to the - * lobby log. This has no effect if `logchat` is disabled. - */ -exports.loguserstats = 1000 * 60 * 10; // 10 minutes - -/** - * validatorprocesses - the number of processes to use for validating teams - * simulatorprocesses - the number of processes to use for handling battles - * You should leave both of these at 1 unless your server has a very large - * amount of traffic (i.e. hundreds of concurrent battles). - */ -exports.validatorprocesses = 1; -exports.simulatorprocesses = 28; - -/** - * inactiveuserthreshold - how long a user must be inactive before being pruned - * from the `users` array. The default is 1 hour. - */ -exports.inactiveuserthreshold = 1000 * 60 * 60; - -/** - * autolockdown - whether or not to automatically kill the server when it is - * in lockdown mode and the final battle finishes. This is potentially useful - * to prevent forgetting to restart after a lockdown where battles are finished. - * - * @type {boolean} - */ -exports.autolockdown = true; - -/** - * noguestsecurity - purely for development servers: allows logging in without - * a signed token: simply send `/trn [USERNAME]`. This allows using PS without - * a login server. - * - * Logging in this way will make you considered an unregistered user and grant - * no authority. You cannot log into a trusted (g+/r%) user account this way. - */ -exports.noguestsecurity = false; - -/** - * tourroom - specify a room to receive tournament announcements (defaults to - * the room 'tournaments'). - * tourannouncements - announcements are only allowed in these rooms - * tourdefaultplayercap - a set cap of how many players can be in a tournament - * ratedtours - toggles tournaments being ladder rated (true) or not (false) - */ -exports.tourroom = ''; -/** @type {string[]} */ -exports.tourannouncements = [/* roomids */]; -exports.tourdefaultplayercap = 0; -exports.ratedtours = false; - -/** - * appealurl - specify a URL containing information on how users can appeal - * disciplinary actions on your section. You can also leave this blank, in - * which case users won't be given any information on how to appeal. - */ -exports.appealurl = ''; - -/** - * repl - whether repl sockets are enabled or not - * replsocketprefix - the prefix for the repl sockets to be listening on - * replsocketmode - the file mode bits to use for the repl sockets - */ -exports.repl = true; -exports.replsocketprefix = './logs/repl/'; -exports.replsocketmode = 0o600; - -/** - * disablehotpatchall - disables `/hotpatch all`. Generally speaking, there's a - * pretty big need for /hotpatch all - convenience. The only advantage any hotpatch - * forms other than all is lower RAM use (which is only a problem for Main because - * Main is huge), and to do pinpoint hotpatching (like /nohotpatch). - */ -exports.disablehotpatchall = false; - -/** - * forcedpublicprefixes - user ID prefixes which will be forced to battle publicly. - * Battles involving user IDs which begin with one of the prefixes configured here - * will be unaffected by various battle privacy commands such as /modjoin, /hideroom - * or /ionext. - * @type {string[] | undefined} - */ -exports.forcedpublicprefixes = []; - -/** - * startuphook - function to call when the server is fully initialized and ready - * to serve requests. - */ -exports.startuphook = function () {}; - -/** - * lastfmkey - the API key to let users use the last.fm commands from The Studio's - * chat plugin. - */ -exports.lastfmkey = ''; - -/** - * chatlogreader - the search method used for searching chatlogs. - * @type {'ripgrep' | 'fs'} - */ -exports.chatlogreader = 'fs'; -/** - * permissions and groups: - * Each entry in `grouplist` is a seperate group. Some of the members are "special" - * while the rest is just a normal permission. - * The order of the groups determines their ranking. - * The special members are as follows: - * - symbol: Specifies the symbol of the group (as shown in front of the username) - * - id: Specifies an id for the group. - * - name: Specifies the human-readable name for the group. - * - root: If this is true, the group can do anything. - * - inherit: The group uses the group specified's permissions if it cannot - * find the permission in the current group. Never make the graph - * produced using this member have any cycles, or the server won't run. - * - jurisdiction: The default jurisdiction for targeted permissions where one isn't - * explictly specified. "Targeted permissions" are permissions - * that might affect another user, such as `ban' or `promote'. - * 's' is a special group where it means the user itself only - * and 'u' is another special group where it means all groups - * lower in rank than the current group. - * - roomonly: forces the group to be a per-room moderation rank only. - * - globalonly: forces the group to be a global rank only. - * All the possible permissions are as follows: - * - console: Developer console (>>). - * - lockdown: /lockdown and /endlockdown commands. - * - hotpatch: /hotpatch, /crashfixed and /savelearnsets commands. - * - ignorelimits: Ignore limits such as chat message length. - * - promote: Promoting and demoting. Will only work if the target user's current - * group and target group are both in jurisdiction. - * - room: /roompromote to (eg. roomvoice) - * - makeroom: Create/delete chatrooms, and set modjoin/roomdesc/privacy - * - editroom: Editing properties of rooms - * - editprivacy: Set modjoin/privacy only for battles - * - globalban: Banning and unbanning from the entire server. - * - ban: Banning and unbanning in rooms. - * - mute: Muting and unmuting. - * - lock: locking (ipmute) and unlocking. - * - receivemutedpms: Receive PMs from muted users. - * - forcerename: /fr command. - * - ip: IP checking. - * - alts: Alt checking. - * - modlog: view the moderator logs. - * - show: Show command output to other users. - * - showmedia: Show images and videos to other users. - * - declare: /declare command. - * - announce: /announce command. - * - modchat: Set modchat. - * - potd: Set PotD. - * - forcewin: /forcewin command. - * - battlemessage: /a command. - * - tournaments: creating tournaments (/tour new, settype etc.) - * - gamemoderation: /tour dq, autodq, end etc. - * - gamemanagement: enable/disable games, minigames, and tournaments. - * - minigame: make minigames (hangman, polls, etc.). - * - game: make games. - */ -exports.grouplist = [ - { - symbol: '&', - id: "admin", - name: "Administrator", - inherit: '@', - jurisdiction: 'u', - globalonly: true, - - console: true, - bypassall: true, - lockdown: true, - promote: '&u', - roomowner: true, - roombot: true, - roommod: true, - roomdriver: true, - forcewin: true, - declare: true, - addhtml: true, - rangeban: true, - makeroom: true, - editroom: true, - editprivacy: true, - potd: true, - disableladder: true, - gdeclare: true, - gamemanagement: true, - exportinputlog: true, - tournaments: true, - }, - { - symbol: '#', - id: "owner", - name: "Room Owner", - inherit: '@', - jurisdiction: 'u', - roomonly: true, - - roombot: true, - roommod: true, - roomdriver: true, - roomprizewinner: true, - editroom: true, - declare: true, - addhtml: true, - gamemanagement: true, - tournaments: true, - }, - { - symbol: '\u2605', - id: "host", - name: "Host", - inherit: '@', - jurisdiction: 'u', - roomonly: true, - - declare: true, - modchat: 'a', - gamemanagement: true, - forcewin: true, - tournaments: true, - joinbattle: true, - }, - { - symbol: '@', - id: "mod", - name: "Moderator", - inherit: '%', - jurisdiction: 'u', - - globalban: true, - ban: true, - modchat: 'a', - roomvoice: true, - roomwhitelist: true, - forcerename: true, - ip: true, - alts: '@u', - game: true, - }, - { - symbol: '%', - id: "driver", - name: "Driver", - inherit: '+', - jurisdiction: 'u', - globalGroupInPersonalRoom: '@', - - announce: true, - warn: '\u2605u', - kick: true, - mute: '\u2605u', - lock: true, - forcerename: true, - timer: true, - modlog: true, - alts: '%u', - bypassblocks: 'u%@&~', - receiveauthmessages: true, - gamemoderation: true, - jeopardy: true, - joinbattle: true, - minigame: true, - modchat: true, - hiderank: true, - }, - { - symbol: '\u00a7', - id: "sectionleader", - name: "Section Leader", - inherit: '+', - jurisdiction: 'u', - }, - { - // Bots are ranked below Driver/Mod so that Global Bots can be kept out - // of modjoin % rooms (namely, Staff). - // (They were previously above Driver/Mod so they can have game management - // permissions drivers don't, but these permissions can be manually given.) - symbol: '*', - id: "bot", - name: "Bot", - inherit: '%', - jurisdiction: 'u', - - addhtml: true, - tournaments: true, - declare: true, - bypassafktimer: true, - gamemanagement: true, - - ip: false, - globalban: false, - lock: false, - forcerename: false, - alts: false, - }, - { - symbol: '\u2606', - id: "player", - name: "Player", - inherit: '+', - battleonly: true, - - roomvoice: true, - modchat: true, - editprivacy: true, - gamemanagement: true, - joinbattle: true, - nooverride: true, - }, - { - symbol: '+', - id: "voice", - name: "Voice", - inherit: ' ', - - altsself: true, - makegroupchat: true, - joinbattle: true, - show: true, - showmedia: true, - exportinputlog: true, - importinputlog: true, - }, - { - symbol: '^', - id: "prizewinner", - name: "Prize Winner", - roomonly: true, - }, - { - symbol: 'whitelist', - id: "whitelist", - name: "Whitelist", - inherit: ' ', - roomonly: true, - altsself: true, - show: true, - showmedia: true, - exportinputlog: true, - importinputlog: true, - }, - { - symbol: ' ', - ipself: true, - }, - { - name: 'Locked', - id: 'locked', - symbol: '\u203d', - punishgroup: 'LOCK', - }, - { - name: 'Muted', - id: 'muted', - symbol: '!', - punishgroup: 'MUTE', - }, -]; diff --git a/tools/analyze_moveset_trends.py b/tools/analyze_moveset_trends.py new file mode 100644 index 0000000000..e491aff915 --- /dev/null +++ b/tools/analyze_moveset_trends.py @@ -0,0 +1,205 @@ +#!/usr/bin/env python +""" +Analyze and plot move popularity trends for a Pokemon over time. + +Usage: + python tools/analyze_moveset_trends.py --pokemon snorlax --format gen1ou --top_k 9 + python tools/analyze_moveset_trends.py --pokemon exeggutor --format gen1ou --top_k 6 + python tools/analyze_moveset_trends.py --pokemon tauros --format gen9ou --top_k 8 +""" + +import argparse +import datetime +from collections import defaultdict + +import matplotlib.pyplot as plt +import matplotlib.cm as cm + +from metamon.backend.team_prediction.usage_stats import ( + get_usage_stats, + DEFAULT_USAGE_RANK, +) + + +def get_monthly_dates(start_year: int, end_year: int) -> list[datetime.date]: + """Generate first-of-month dates from start_year to end_year (inclusive).""" + dates = [] + for year in range(start_year, end_year + 1): + for month in range(1, 13): + dates.append(datetime.date(year, month, 1)) + return dates + + +def main(args): + format_name = args.format + pokemon_name = args.pokemon + top_k = args.top_k + start_year = args.start_year + end_year = args.end_year + rank = args.rank + output_file = ( + args.output or f"{pokemon_name}_{format_name}_rank{rank}_move_trends.png" + ) + + print( + f"Analyzing {pokemon_name} in {format_name} ({start_year}-{end_year}, rank={rank})" + ) + + # Step 1: Load all-time stats to find top K moves + print("Loading all-time stats to determine top moves...") + all_time_stats = get_usage_stats( + format_name, + start_date=datetime.date(start_year, 1, 1), + end_date=datetime.date(end_year, 12, 1), + rank=rank, + ) + + try: + pokemon_data = all_time_stats[pokemon_name] + except KeyError: + print(f"Error: Pokemon '{pokemon_name}' not found in {format_name} usage stats") + return + + all_moves = pokemon_data.get("moves", {}) + # Filter out "Other" and "Nothing" + filtered_moves = { + k: v for k, v in all_moves.items() if k not in ("Other", "Nothing") + } + # Sort by usage and take top K + sorted_moves = sorted(filtered_moves.items(), key=lambda x: x[1], reverse=True) + top_moves = [move for move, _ in sorted_moves[:top_k]] + + print(f"Top {top_k} moves (all-time): {top_moves}") + + # Step 2: Track these moves month by month + print("Loading monthly stats...") + monthly_dates = get_monthly_dates(start_year, end_year) + move_trends = defaultdict(list) + valid_dates = [] + + for date in monthly_dates: + try: + monthly_stats = get_usage_stats( + format_name, + start_date=date, + end_date=date, + rank=rank, + ) + pokemon_monthly = monthly_stats[pokemon_name] + moves = pokemon_monthly.get("moves", {}) + valid_dates.append(date) + for move in top_moves: + # Use 0 if move not present in this month's data + move_trends[move].append(moves.get(move, 0.0)) + except (KeyError, FileNotFoundError) as e: + # Skip months where data is missing + print(f" Skipping {date}: {e}") + continue + + if not valid_dates: + print("Error: No valid monthly data found") + return + + print(f"Collected data for {len(valid_dates)} months") + + # Step 3: Plot + fig, ax = plt.subplots(figsize=(14, 7)) + + # White background with grid + ax.set_facecolor("white") + ax.grid(True, linestyle="-", alpha=0.3, color="gray") + + # Generate colors from a pastel colormap + cmap = cm.get_cmap("tab20", len(top_moves)) + + # Plot each move + for i, move in enumerate(top_moves): + color = cmap(i) + ax.plot( + valid_dates, + move_trends[move], + label=move, + color=color, + linewidth=3.5, + marker="o", + markersize=8, + alpha=0.9, + ) + + title = f"{pokemon_name.upper()} Move Trends in {format_name.upper()} ({start_year}-{end_year}, rank={rank})" + ax.set_title(title, fontsize=18, fontweight="bold", family="monospace", pad=15) + + ax.set_xlabel("Date", fontsize=14, fontweight="bold") + ax.set_ylabel("Usage Rate", fontsize=14, fontweight="bold") + ax.tick_params(axis="both", labelsize=12) + ax.set_ylim(0, 1.05) + + # Legend outside plot + ax.legend( + loc="center left", + bbox_to_anchor=(1.02, 0.5), + frameon=True, + facecolor="white", + edgecolor="lightgray", + fontsize=12, + ) + + # Format x-axis dates + fig.autofmt_xdate(rotation=45) + + plt.tight_layout() + + # Save plot + plt.savefig(output_file, dpi=150, bbox_inches="tight", facecolor="white") + print(f"Saved plot to {output_file}") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser( + description="Analyze and plot move popularity trends for a Pokemon over time" + ) + parser.add_argument( + "--pokemon", + type=str, + required=True, + help="Pokemon name to analyze (e.g., snorlax, exeggutor)", + ) + parser.add_argument( + "--format", + type=str, + default="gen1ou", + help="Format to analyze (e.g., gen1ou, gen9ou)", + ) + parser.add_argument( + "--top_k", + type=int, + default=9, + help="Number of top moves to track (default: 9)", + ) + parser.add_argument( + "--start_year", + type=int, + default=2015, + help="Start year for analysis (default: 2015)", + ) + parser.add_argument( + "--end_year", + type=int, + default=2025, + help="End year for analysis (default: 2025)", + ) + parser.add_argument( + "--rank", + type=int, + default=DEFAULT_USAGE_RANK, + help=f"Usage stats rank/baseline (default: {DEFAULT_USAGE_RANK})", + ) + parser.add_argument( + "--output", + "-o", + type=str, + default=None, + help="Output filename for the plot (default: {pokemon}_{format}_rank{rank}_move_trends.png)", + ) + args = parser.parse_args() + main(args) diff --git a/tools/analyze_team_logs.py b/tools/analyze_team_logs.py new file mode 100644 index 0000000000..b23e66685d --- /dev/null +++ b/tools/analyze_team_logs.py @@ -0,0 +1,619 @@ +import argparse +import csv +import os +import re +import sys +from glob import glob +from collections import defaultdict, Counter +from itertools import combinations +import textwrap +import math + +try: + import numpy as np + from sklearn.linear_model import LogisticRegression +except Exception as _e: + np = None + LogisticRegression = None + + +def canonical_team_name(team_file_path: str) -> str: + """Derive a team name from the last 3 path parts. + + Example: /.../teams/competitive/gen1ou/team_1026.gen1ou_team + -> competitive-gen1ou-team_1026 + """ + if team_file_path is None or team_file_path == "": + return "UNKNOWN" + parts = team_file_path.strip().split("/") + if len(parts) >= 3: + last_three = parts[-3:] + folder_a, folder_b, filename = last_three + # strip extension at first '.' + base = filename.split(".")[0] + return f"{folder_a}-{folder_b}-{base}" + # fallback: use filename stem only + filename = os.path.basename(team_file_path) + base = filename.split(".")[0] + return base + + +def last_three_parts(team_file_path: str): + parts = (team_file_path or "").strip().split("/") + if len(parts) >= 3: + return parts[-3], parts[-2], parts[-1] + return None, None, None + + +def resolve_team_file(cache_dir: str, team_file_path: str) -> str | None: + """Resolve team file under METAMON_CACHE_DIR/teams/ using last three parts. + + Fallbacks: original path if exists, else None. + """ + a, b, filename = last_three_parts(team_file_path) + if cache_dir: + candidate = os.path.join( + cache_dir, "teams", *(p for p in [a, b] if p), filename or "" + ) + if filename and os.path.isfile(candidate): + return candidate + if team_file_path and os.path.isfile(team_file_path): + return team_file_path + return None + + +_POKEMON_LINE_RE = re.compile(r"^\s*([^@\n]+?)(?:\s*@|\s*$)") + + +def parse_showdown_team_species(path: str) -> list[str]: + """Parse a Showdown team export file and return list of species names. + + Extracts the leading species token on each mon block's first line, + stripping gender and form parentheses, keeping hyphenated forms. + """ + try: + with open(path, "r") as f: + text = f.read() + except Exception: + return [] + # Split on blank lines into mon blocks + blocks = [b for b in re.split(r"\n\s*\n", text) if b.strip()] + species: list[str] = [] + for block in blocks: + first_line = block.strip().splitlines()[0] + m = _POKEMON_LINE_RE.match(first_line) + if not m: + continue + name = m.group(1).strip() + # strip gender/level/etc in parentheses, but keep hyphen forms + if "(" in name: + name = name.split("(", 1)[0].strip() + if name: + species.append(name) + return species + + +def read_rows_from_csv(path: str): + """Yield rows from a CSV, skipping header if present. + + Rows are lists of strings. Handles files that may be partially written + or missing trailing columns. Skips empty/bad rows. + """ + try: + with open(path, newline="") as f: + reader = csv.reader(f) + first = True + for row in reader: + if not row or all(cell.strip() == "" for cell in row): + continue + if first: + first = False + # header detection + if row[0].strip().lower() in {"player username", "player_username"}: + continue + yield row + except Exception as e: + print(f"Warning: failed to read {path}: {e}", file=sys.stderr) + + +def aggregate_win_rates(paths: list[str], cache_dir: str | None): + by_team = defaultdict(lambda: {"wins": 0, "games": 0, "paths": set()}) + by_pokemon = defaultdict(lambda: {"wins": 0, "games": 0}) + files_seen = 0 + for p in paths: + for row in read_rows_from_csv(p): + # Expected columns (may be missing end columns): + # 0: Player Username + # 1: Team File (full path) + # 2: Opponent Username + # 3: Result (WIN/LOSS) + # 4: Turn Count + # 5: Battle ID (optional) + if len(row) < 4: + continue + team_file = row[1].strip() + result = row[3].strip().upper() + team_name = canonical_team_name(team_file) + agg = by_team[team_name] + agg["games"] += 1 + if result == "WIN": + agg["wins"] += 1 + # per-Pokemon aggregation + cache_root = cache_dir or os.environ.get("METAMON_CACHE_DIR", "") + resolved = resolve_team_file(cache_root, team_file) + if resolved: + try: + agg["paths"].add(resolved) + except Exception: + pass + mons = parse_showdown_team_species(resolved) + for mon in mons: + pstats = by_pokemon[mon] + pstats["games"] += 1 + if result == "WIN": + pstats["wins"] += 1 + files_seen += 1 + ranked = [] + for name, stats in by_team.items(): + games = stats["games"] + wins = stats["wins"] + losses = max(0, games - wins) + win_rate = wins / games if games > 0 else 0.0 + ranked.append((name, games, wins, losses, win_rate)) + ranked.sort(key=lambda x: (x[4], x[1]), reverse=True) # by win_rate then games + ranked_pokemon = [] + for name, stats in by_pokemon.items(): + games = stats["games"] + wins = stats["wins"] + losses = max(0, games - wins) + win_rate = wins / games if games > 0 else 0.0 + ranked_pokemon.append((name, games, wins, losses, win_rate)) + ranked_pokemon.sort(key=lambda x: (x[4], x[1]), reverse=True) + # Map team name -> list of candidate file paths + team_paths = {name: sorted(list(by_team[name]["paths"])) for name, *_ in ranked} + return ranked, ranked_pokemon, files_seen, team_paths + + +def collect_samples(paths: list[str], cache_dir: str | None): + """Collect per-battle samples with parsed species and outcome. + + Returns list of dicts: {"species": set[str], "y": int, "team_path": str, "team_name": str, + "format": str} + """ + samples = [] + cache_root = cache_dir or os.environ.get("METAMON_CACHE_DIR", "") + for p in paths: + for row in read_rows_from_csv(p): + if len(row) < 4: + continue + team_file = row[1].strip() + result = row[3].strip().upper() + y = 1 if result == "WIN" else 0 + resolved = resolve_team_file(cache_root, team_file) + if not resolved: + # still keep the row for outcome-only analyses, but species unknown + samples.append( + { + "species": set(), + "y": y, + "team_path": team_file, + "team_name": canonical_team_name(team_file), + "format": last_three_parts(team_file)[1] or "", + } + ) + continue + mons = set(parse_showdown_team_species(resolved)) + a, b, _ = last_three_parts(resolved) + samples.append( + { + "species": mons, + "y": y, + "team_path": resolved, + "team_name": canonical_team_name(resolved), + "format": b or "", + } + ) + return samples + + +def _build_design_matrix( + samples, + min_games_species: int, + add_top_pairs: int, + include_format: bool, +): + """Build X, y, feature_names from samples. + + - Filters species by min_games occurrence + - Adds top-K frequent pair features (presence of both) + - Optionally includes format fixed effects + """ + species_counter = Counter() + pair_counter = Counter() + for s in samples: + species = list(s["species"]) if s["species"] else [] + species_counter.update(species) + if add_top_pairs > 0 and len(species) >= 2: + pair_counter.update(tuple(sorted(p)) for p in combinations(species, 2)) + + keep_species = sorted( + [sp for sp, c in species_counter.items() if c >= min_games_species] + ) + species_to_idx = {sp: i for i, sp in enumerate(keep_species)} + + keep_pairs = [] + if add_top_pairs > 0: + keep_pairs = [pair for pair, _ in pair_counter.most_common(add_top_pairs)] + pair_to_idx = {pair: i for i, pair in enumerate(keep_pairs)} + + formats = [] + if include_format: + formats = sorted({s["format"] for s in samples if s["format"]}) + fmt_to_idx = {f: i for i, f in enumerate(formats)} + + n = len(samples) + d_species = len(keep_species) + d_pairs = len(keep_pairs) + d_format = len(formats) + d_total = d_species + d_pairs + d_format + X = np.zeros((n, d_total), dtype=np.float32) + y = np.zeros((n,), dtype=np.int64) + + for i, s in enumerate(samples): + y[i] = int(s["y"]) if s["y"] in (0, 1) else 0 + sp_set = s["species"] + for sp in sp_set: + j = species_to_idx.get(sp) + if j is not None: + X[i, j] = 1.0 + if d_pairs: + for pair, jrel in pair_to_idx.items(): + a, b = pair + if a in sp_set and b in sp_set: + X[i, d_species + jrel] = 1.0 + if d_format: + f = s["format"] + jfmt = fmt_to_idx.get(f) + if jfmt is not None: + X[i, d_species + d_pairs + jfmt] = 1.0 + + feature_names = [] + feature_names.extend([f"mon:{sp}" for sp in keep_species]) + feature_names.extend([f"pair:{a}&{b}" for (a, b) in keep_pairs]) + feature_names.extend([f"format:{f}" for f in formats]) + return X, y, feature_names + + +def fit_presence_logistic( + samples, + min_games_species: int, + add_top_pairs: int, + include_format: bool, + penalty: str, + C: float, + max_iter: int, +): + if np is None or LogisticRegression is None: + print("sklearn/numpy not available; skipping logistic regression.") + return None + X, y, feature_names = _build_design_matrix( + samples, + min_games_species=min_games_species, + add_top_pairs=add_top_pairs, + include_format=include_format, + ) + if X.shape[1] == 0: + print("No features after filtering; skipping logistic regression.") + return None + solver = "liblinear" if penalty == "l1" else "lbfgs" + if penalty == "l1": + # liblinear handles l1 but not multinomial; our y is binary + model = LogisticRegression( + penalty="l1", C=C, solver=solver, max_iter=max_iter, fit_intercept=True + ) + else: + model = LogisticRegression( + penalty="l2", C=C, solver=solver, max_iter=max_iter, fit_intercept=True + ) + model.fit(X, y) + coefs = model.coef_.reshape(-1) + intercept = float(model.intercept_.reshape(())) + odds = np.exp(coefs) + results = list(zip(feature_names, coefs.tolist(), odds.tolist())) + # sort by odds ratio descending + results.sort(key=lambda t: t[2], reverse=True) + return { + "feature_importances": results, + "intercept": intercept, + "num_samples": int(X.shape[0]), + "num_features": int(X.shape[1]), + } + + +def print_logit_results(res, top_k: int, bottom_k: int): + if not res: + return + feats = res["feature_importances"] + print("\n" + "=" * 80) + print("Presence logistic regression (win ~ mons [+ pairs] [+ format])") + print( + f"Samples: {res['num_samples']}, Features: {res['num_features']}, Intercept OR: {math.exp(res['intercept']):.3f}" + ) + print("=" * 80) + if top_k > 0: + print("Top features by odds ratio:\n") + print(f"{'Feature':40} {'Coef':>9} {'OddsRatio':>10}") + print("-" * 64) + for name, coef, orat in feats[:top_k]: + print(f"{name:40} {coef:9.3f} {orat:10.3f}") + + +def print_best_worst_side_by_side(best_list, worst_list): + """Print best and worst team summaries side-by-side for quick comparison. + + Each side shows: Team, Games, Wins, Losses, WinRate. + """ + # Determine widths + name_w = 44 + left_header = f"{'Best Team':{name_w}} {'G':>4} {'W':>4} {'L':>4} {'WR':>6}" + right_header = f"{'Worst Team':{name_w}} {'G':>4} {'W':>4} {'L':>4} {'WR':>6}" + sep = "-" * (len(left_header) + 4 + len(right_header)) + print("Best vs Worst (side-by-side):\n") + print(left_header + " " + right_header) + print(sep) + rows = max(len(best_list), len(worst_list)) + for i in range(rows): + if i < len(best_list): + b_name, b_g, b_w, b_l, b_wr = best_list[i] + left = f"{b_name:{name_w}} {b_g:4d} {b_w:4d} {b_l:4d} {b_wr:6.3f}" + else: + left = f"{'':{name_w}} {'':>4} {'':>4} {'':>4} {'':>6}" + if i < len(worst_list): + w_name, w_g, w_w, w_l, w_wr = worst_list[i] + right = f"{w_name:{name_w}} {w_g:4d} {w_w:4d} {w_l:4d} {w_wr:6.3f}" + else: + right = f"{'':{name_w}} {'':>4} {'':>4} {'':>4} {'':>6}" + print(left + " " + right) + print() + + +def expand_inputs(inputs: list[str]) -> list[str]: + paths: list[str] = [] + for p in inputs: + if os.path.isdir(p): + # add all csv files under this directory (non-recursive) + paths.extend(sorted(glob(os.path.join(p, "*.csv")))) + else: + # allow glob patterns + expanded = glob(p) + if expanded: + paths.extend(sorted(expanded)) + else: + paths.append(p) + # drop duplicates while preserving order + seen = set() + out = [] + for p in paths: + if p not in seen: + out.append(p) + seen.add(p) + return out + + +def _read_team_text(team_path: str) -> str: + try: + with open(team_path, "r") as tf: + return tf.read().rstrip("\n") + except Exception as e: + return f"" + + +def print_team_contents_columns( + team_rows, + team_paths: dict[str, list[str]], + title: str, + columns: int = 2, + col_width: int = 60, +): + """Print full team files in a multi-column layout. + + team_rows: list of (name, games, wins, losses, win_rate) + team_paths: mapping name -> [paths] + """ + space_between = 4 + total_width = columns * col_width + (columns - 1) * space_between + print(title) + print("-" * total_width) + # Build blocks (list of list[str]) for each team + blocks = [] + for name, games, wins, losses, wr in team_rows: + paths_for_team = team_paths.get(name, []) + header = f"{name} | G={games} W={wins} L={losses} WR={wr:.3f}" + if paths_for_team: + team_path = paths_for_team[0] + header2 = f"File: {team_path}" + content = _read_team_text(team_path) + else: + header2 = "File: " + content = "" + # wrap lines to column width + lines = [] + for h in (header, header2): + lines.extend(textwrap.wrap(h, width=col_width) or [h]) + for line in content.splitlines(): + wrapped = textwrap.wrap(line, width=col_width) + lines.extend(wrapped if wrapped else [""]) + blocks.append(lines) + # Print in rows of `columns` + for i in range(0, len(blocks), columns): + row_blocks = blocks[i : i + columns] + max_h = max(len(b) for b in row_blocks) + for h in range(max_h): + parts = [] + for b in row_blocks: + cell = b[h] if h < len(b) else "" + parts.append(f"{cell:<{col_width}}") + print((" " * space_between).join(parts)) + print() + + +def main(): + parser = argparse.ArgumentParser( + description="Analyze win rates by team from team log CSVs." + ) + parser.add_argument( + "inputs", + nargs="+", + help="CSV file(s), directories, or glob patterns (e.g., team_logs/*.csv)", + ) + parser.add_argument( + "--top", + type=int, + default=50, + help="Show top N teams by win rate (deprecated in favor of --best-teams)", + ) + parser.add_argument( + "--best-teams", type=int, default=None, help="Show best K teams by win rate" + ) + parser.add_argument( + "--worst-teams", type=int, default=0, help="Also show worst K teams by win rate" + ) + parser.add_argument( + "--top-pokemon", type=int, default=50, help="Show top N Pokemon by win rate" + ) + parser.add_argument( + "--min-games-pokemon", + type=int, + default=10, + help="Minimum games to include a Pokemon in ranking", + ) + parser.add_argument( + "--cache-dir", + type=str, + default=os.environ.get("METAMON_CACHE_DIR", ""), + help="Path to METAMON_CACHE_DIR (for resolving team files)", + ) + parser.add_argument( + "--no-team-content", + action="store_true", + help="Do not print team file contents in best/worst team sections", + ) + # modeling options + parser.add_argument( + "--logit", action="store_true", help="Fit a logistic regression presence model" + ) + parser.add_argument( + "--logit-penalty", + type=str, + choices=["l1", "l2"], + default="l2", + help="Regularization penalty for logistic regression", + ) + parser.add_argument( + "--logit-C", + type=float, + default=1.0, + help="Inverse regularization strength for logistic regression", + ) + parser.add_argument( + "--logit-iter", + type=int, + default=200, + help="Max iterations for logistic regression", + ) + parser.add_argument( + "--logit-min-games", + type=int, + default=25, + help="Minimum battles required to include a Pokemon as a feature", + ) + parser.add_argument( + "--logit-top-pairs", + type=int, + default=0, + help="Add top-K frequent pair presence features (0 disables)", + ) + parser.add_argument( + "--logit-include-format", + action="store_true", + help="Include format fixed effects in the model", + ) + parser.add_argument( + "--logit-top-features", + type=int, + default=25, + help="Show top-K features by odds ratio", + ) + parser.add_argument( + "--logit-bottom-features", + type=int, + default=25, + help="Show bottom-K features by odds ratio", + ) + args = parser.parse_args() + + paths = expand_inputs(args.inputs) + if not paths: + print("No input files found.") + return 1 + + ranked, ranked_pokemon, files_seen, team_paths = aggregate_win_rates( + paths, cache_dir=args.cache_dir + ) + print("=" * 80) + print(f"Analyzed {files_seen} file(s), {len(ranked)} team(s) found.") + print("=" * 80 + "\n") + best_k = args.best_teams if args.best_teams is not None else args.top + if best_k and best_k > 0: + # Prepare best and worst lists for side-by-side view (names-only summary) + best_rows = ranked[:best_k] + worst_rows = [] + if args.worst_teams and args.worst_teams > 0 and ranked: + worst_rows = list(reversed(ranked[-args.worst_teams :])) + if best_rows or worst_rows: + print_best_worst_side_by_side(best_rows, worst_rows) + if not args.no_team_content: + print_team_contents_columns( + best_rows, team_paths, title="Best teams (full contents, columns):\n" + ) + if args.worst_teams and args.worst_teams > 0 and ranked: + if not args.no_team_content: + worst_slice = list(reversed(ranked[-args.worst_teams :])) + print_team_contents_columns( + worst_slice, team_paths, title="Worst teams (full contents, columns):\n" + ) + # Pokemon ranking + if ranked_pokemon: + print("\nPokemon rankings (filtered):\n") + print( + f"{'Pokemon':28} {'Games':>5} {'Wins':>5} {'Losses':>6} {'WinRate':>7}" + ) + print("-" * 60) + shown = 0 + for name, games, wins, losses, wr in ranked_pokemon: + if games < args.min_games_pokemon: + continue + print(f"{name:28} {games:5d} {wins:5d} {losses:6d} {wr:7.3f}") + shown += 1 + if shown >= args.top_pokemon: + break + # logistic regression analysis + if args.logit: + samples = collect_samples(paths, cache_dir=args.cache_dir) + res = fit_presence_logistic( + samples, + min_games_species=args.logit_min_games, + add_top_pairs=args.logit_top_pairs, + include_format=args.logit_include_format, + penalty=args.logit_penalty, + C=args.logit_C, + max_iter=args.logit_iter, + ) + print_logit_results( + res, top_k=args.logit_top_features, bottom_k=args.logit_bottom_features + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/compare_team_sets.py b/tools/compare_team_sets.py new file mode 100644 index 0000000000..0dff4fc9cc --- /dev/null +++ b/tools/compare_team_sets.py @@ -0,0 +1,500 @@ +#!/usr/bin/env python3 +""" +Compare N team sets for a given battle format. + +Produces: + 1. A summary table (all sets as columns, at a configurable presence threshold). + 2. Threshold-sweep plots — how many species / moves / unique sets survive + as a function of minimum team-presence threshold. + 3. Rank-frequency (log-log) plots for species and moves. + 4. Pairwise Jaccard heatmap across all sets for species, moves, and sets. + 5. Per-set "exclusives" — what is unique to each set vs. all others. + +Team-file loading is parallelised across workers; each file is parsed +independently so large sets scale linearly. + +Usage +----- + python tools/compare_team_sets.py \\ + --format gen1ou \\ + --sets competitive elite_sets_filled modern_replays_v2 \\ + --workers 8 \\ + --save tools/team_set_comparison.png +""" + +from __future__ import annotations + +import argparse +import math +import multiprocessing as mp +import os +from collections import Counter +from typing import Dict, List, Optional, Tuple + +import matplotlib.pyplot as plt +import matplotlib.colors as mcolors +import numpy as np +from tqdm import tqdm + +import metamon +from metamon.env.wrappers import get_metamon_teams +from metamon.backend.team_prediction.team import PokemonSet +from metamon.backend.team_prediction.team import TeamSet as BackendTeamSet + +# ─────────────────────────────────────────────────────────────────── # +# Constants +# ─────────────────────────────────────────────────────────────────── # + +_SKIP_MOVES = {PokemonSet.MISSING_MOVE, PokemonSet.NO_MOVE, ""} +_SKIP_ITEMS = {PokemonSet.MISSING_ITEM, PokemonSet.NO_ITEM, ""} +_SKIP_ABILITIES = {PokemonSet.MISSING_ABILITY, PokemonSet.NO_ABILITY, ""} + +THRESHOLDS: List[float] = sorted( + set( + [0.1, 0.2, 0.5] + + list(np.arange(1.0, 21.0, 1.0)) + + list(np.arange(25.0, 55.0, 5.0)) + ) +) +TABLE_THRESHOLD = 1.0 # % presence used for summary table + +# Perceptually distinct palette — cycles if more sets than colours +_PALETTE = [ + "#4878a8", # steel blue + "#e07040", # burnt orange + "#4a9a6a", # muted green + "#7b62a8", # dusty violet + "#c43c3c", # crimson + "#2ca089", # teal-green + "#d4a853", # gold + "#b55a82", # muted rose + "#8b6e4e", # warm brown + "#5b5ea6", # indigo +] + +MARKERS = ["o", "s", "^", "v", "D", "P", "X", "h", "*", "p"] + + +def _color(i: int) -> str: + return _PALETTE[i % len(_PALETTE)] + + +def _marker(i: int) -> str: + return MARKERS[i % len(MARKERS)] + + +# ─────────────────────────────────────────────────────────────────── # +# Parallel loading +# ─────────────────────────────────────────────────────────────────── # + + +def _parse_file(args: Tuple[str, str]) -> Optional[dict]: + """Worker: parse one team file; return per-file counters or None on error.""" + path, battle_format = args + try: + team = BackendTeamSet.from_showdown_file(path, battle_format) + except Exception: + return None + + species_ctr: Counter = Counter() + move_ctr: Counter = Counter() + item_ctr: Counter = Counter() + ability_ctr: Counter = Counter() + set_ctr: Counter = Counter() + + valid = [p for p in team.pokemon if p.name != PokemonSet.MISSING_NAME] + for p in valid: + species_ctr[p.name] += 1 + for m in p.moves: + if m not in _SKIP_MOVES: + move_ctr[m] += 1 + if p.item not in _SKIP_ITEMS: + item_ctr[p.item] += 1 + if p.ability not in _SKIP_ABILITIES: + ability_ctr[p.ability] += 1 + set_ctr[p.set_key] += 1 + + team_key = frozenset(p.set_key for p in valid) + return { + "species": species_ctr, + "moves": move_ctr, + "items": item_ctr, + "abilities": ability_ctr, + "sets": set_ctr, + "team_key": team_key, + } + + +def load_set(set_name: str, battle_format: str, workers: int) -> dict: + """Load all team files for one set in parallel; return aggregated stats.""" + wrapper = get_metamon_teams(battle_format, set_name) + file_args = [(p, battle_format) for p in wrapper.team_files] + + species_ctr: Counter = Counter() + move_ctr: Counter = Counter() + item_ctr: Counter = Counter() + ability_ctr: Counter = Counter() + set_ctr: Counter = Counter() + team_ctr: Counter = Counter() + n_teams = 0 + n_errors = 0 + + chunksize = max(1, len(file_args) // (workers * 4)) + with mp.Pool(workers) as pool: + for result in tqdm( + pool.imap_unordered(_parse_file, file_args, chunksize=chunksize), + total=len(file_args), + desc=f" {set_name}", + leave=False, + ): + if result is None: + n_errors += 1 + continue + n_teams += 1 + species_ctr.update(result["species"]) + move_ctr.update(result["moves"]) + item_ctr.update(result["items"]) + ability_ctr.update(result["abilities"]) + set_ctr.update(result["sets"]) + team_ctr[result["team_key"]] += 1 + + if n_errors: + print(f" [{set_name}] {n_errors} file(s) skipped due to parse errors") + + return { + "name": set_name, + "n_teams": n_teams, + "species": species_ctr, + "moves": move_ctr, + "items": item_ctr, + "abilities": ability_ctr, + "sets": set_ctr, + "teams": team_ctr, + } + + +# ─────────────────────────────────────────────────────────────────── # +# Derived statistics +# ─────────────────────────────────────────────────────────────────── # + + +def _above_threshold(counter: Counter, n_teams: int, pct: float) -> int: + """Count entries whose team-presence rate ≥ pct %.""" + min_count = pct / 100.0 * n_teams + return sum(1 for v in counter.values() if v >= min_count) + + +def _shannon_entropy(counter: Counter) -> float: + total = sum(counter.values()) + if total == 0: + return 0.0 + return -sum((v / total) * math.log2(v / total) for v in counter.values() if v > 0) + + +def _jaccard(keys_a, keys_b) -> float: + a, b = set(keys_a), set(keys_b) + if not a and not b: + return 1.0 + return len(a & b) / len(a | b) + + +def _threshold_curve(counter: Counter, n_teams: int) -> Tuple[List[float], List[int]]: + return THRESHOLDS, [_above_threshold(counter, n_teams, t) for t in THRESHOLDS] + + +def _rank_freq(counter: Counter) -> Tuple[np.ndarray, np.ndarray]: + counts = sorted(counter.values(), reverse=True) + return np.arange(1, len(counts) + 1), np.array(counts, dtype=float) + + +# ─────────────────────────────────────────────────────────────────── # +# Console output +# ─────────────────────────────────────────────────────────────────── # + + +def print_table(stats_list: List[dict]) -> None: + T = TABLE_THRESHOLD + names = [s["name"] for s in stats_list] + + def row(label, fn): + return [label] + [fn(s) for s in stats_list] + + rows = [ + row("Teams", lambda s: f"{s['n_teams']:,}"), + row("Unique species", lambda s: str(len(s["species"]))), + row( + f"Species ≥{T}% presence", + lambda s: str(_above_threshold(s["species"], s["n_teams"], T)), + ), + row("Unique moves", lambda s: str(len(s["moves"]))), + row( + f"Moves ≥{T}% presence", + lambda s: str(_above_threshold(s["moves"], s["n_teams"], T)), + ), + row("Unique items", lambda s: str(len(s["items"]))), + row("Unique abilities", lambda s: str(len(s["abilities"]))), + row("Unique sets {sp,mv,it,ab}", lambda s: f"{len(s['sets']):,}"), + row( + f"Sets ≥{T}% presence", + lambda s: str(_above_threshold(s["sets"], s["n_teams"], T)), + ), + row("Unique full teams", lambda s: f"{len(s['teams']):,}"), + row( + "Species entropy (bits)", lambda s: f"{_shannon_entropy(s['species']):.2f}" + ), + row("Set entropy (bits)", lambda s: f"{_shannon_entropy(s['sets']):.2f}"), + ] + + col_w = [max(len(r[i]) for r in rows) for i in range(len(names) + 1)] + col_w[0] = max(col_w[0], 28) + for i, name in enumerate(names): + col_w[i + 1] = max(col_w[i + 1], len(name)) + + header_parts = [f"{'Metric':<{col_w[0]}}"] + [ + f"{n:>{col_w[i+1]}}" for i, n in enumerate(names) + ] + header = " ".join(header_parts) + sep = "─" * len(header) + + print() + print(sep) + print(header) + print(sep) + for r in rows: + parts = [f"{r[0]:<{col_w[0]}}"] + [ + f"{r[i+1]:>{col_w[i+1]}}" for i in range(len(names)) + ] + print(" ".join(parts)) + print(sep) + print() + + +def print_pairwise_jaccard(stats_list: List[dict]) -> None: + """Print pairwise Jaccard similarity tables for species and sets.""" + names = [s["name"] for s in stats_list] + n = len(names) + name_w = max(len(nm) for nm in names) + + for dim_key, dim_label in [("species", "Species"), ("sets", "Sets")]: + print(f"Jaccard ({dim_label})") + header = " " * (name_w + 2) + " ".join(f"{nm:>{name_w}}" for nm in names) + print(header) + print("─" * len(header)) + for i, si in enumerate(stats_list): + row_vals = [] + for j, sj in enumerate(stats_list): + j_val = _jaccard(si[dim_key], sj[dim_key]) + row_vals.append(f"{j_val:.2f}".rjust(name_w)) + print(f"{names[i]:<{name_w}} " + " ".join(row_vals)) + print() + + +def _fmt_set_key(k) -> str: + if isinstance(k, tuple): + species, moves, item, ability = k + move_str = ", ".join(sorted(moves)) if moves else "—" + item_str = f" @ {item}" if item not in _SKIP_ITEMS else "" + return f"{species}{item_str} [{move_str}]" + return str(k) + + +def print_exclusives(stats_list: List[dict], top_n: int = 12) -> None: + """For each set, print top species / sets that appear nowhere else.""" + all_keys = { + dim: [set(s[dim]) for s in stats_list] for dim in ("species", "moves", "sets") + } + + for dim, label in [("species", "Species"), ("moves", "Moves"), ("sets", "Sets")]: + print(f"\n{'─'*60}") + print(f" {label} unique to each set (not present in any other)") + print(f"{'─'*60}") + for i, s in enumerate(stats_list): + others = set().union( + *[all_keys[dim][j] for j in range(len(stats_list)) if j != i] + ) + exclusive = set(s[dim]) - others + if not exclusive: + print(f" {s['name']}: (none)") + continue + top = sorted(exclusive, key=lambda k: s[dim][k], reverse=True)[:top_n] + print(f" {s['name']} ({len(exclusive)} exclusive {label.lower()}):") + for k in top: + print(f" {_fmt_set_key(k):<68} {s[dim][k]:>5}×") + + +# ─────────────────────────────────────────────────────────────────── # +# Plotting +# ─────────────────────────────────────────────────────────────────── # + + +def plot( + stats_list: List[dict], battle_format: str, save_path: Optional[str] = None +) -> None: + n = len(stats_list) + names = [s["name"] for s in stats_list] + + # Layout: 3 columns + # row 0: threshold sweeps (species | moves | sets) + # row 1: rank-freq log-log (species | moves) + species Jaccard heatmap + fig = plt.figure(figsize=(18, 11)) + gs = fig.add_gridspec(2, 3, hspace=0.38, wspace=0.32) + axes_sweep = [fig.add_subplot(gs[0, c]) for c in range(3)] + axes_rf = [fig.add_subplot(gs[1, c]) for c in range(2)] + ax_heat = fig.add_subplot(gs[1, 2]) + + sweep_cfg = [ + ("species", "Unique Species"), + ("moves", "Unique Moves"), + ("sets", "Unique Sets\n{species, moves, item, ability}"), + ] + rf_cfg = [ + ("species", "Species occurrence count"), + ("moves", "Move occurrence count"), + ] + + # ── threshold sweeps ─────────────────────────────────────── # + for ax, (key, ylabel) in zip(axes_sweep, sweep_cfg): + for i, s in enumerate(stats_list): + xs, ys = _threshold_curve(s[key], s["n_teams"]) + ax.plot( + xs, + ys, + color=_color(i), + marker=_marker(i), + ms=4, + lw=2, + label=s["name"], + ) + ax.set_xlabel("Min. team-presence threshold (%)", fontsize=10) + ax.set_ylabel(ylabel, fontsize=10) + ax.set_xscale("log") + ax.xaxis.set_major_formatter(plt.FuncFormatter(lambda x, _: f"{x:g}")) + ax.grid(True, alpha=0.22, linewidth=0.5) + ax.legend(fontsize=8, framealpha=0.92) + + # ── rank-frequency ────────────────────────────────────────── # + for ax, (key, ylabel) in zip(axes_rf, rf_cfg): + for i, s in enumerate(stats_list): + r, f = _rank_freq(s[key]) + ax.plot(r, f, color=_color(i), lw=1.8, label=s["name"]) + ax.set_xscale("log") + ax.set_yscale("log") + ax.set_xlabel("Rank", fontsize=10) + ax.set_ylabel(ylabel, fontsize=10) + ax.grid(True, alpha=0.22, linewidth=0.5, which="both") + ax.legend(fontsize=8, framealpha=0.92) + + # ── pairwise Jaccard heatmap (species) ───────────────────── # + J = np.array( + [ + [_jaccard(si["species"], sj["species"]) for sj in stats_list] + for si in stats_list + ] + ) + im = ax_heat.imshow(J, vmin=0, vmax=1, cmap="Blues", aspect="auto") + ax_heat.set_xticks(range(n)) + ax_heat.set_yticks(range(n)) + ax_heat.set_xticklabels(names, rotation=30, ha="right", fontsize=8) + ax_heat.set_yticklabels(names, fontsize=8) + for i in range(n): + for j in range(n): + ax_heat.text( + j, + i, + f"{J[i, j]:.2f}", + ha="center", + va="center", + fontsize=9, + color="black" if J[i, j] < 0.6 else "white", + ) + fig.colorbar(im, ax=ax_heat, shrink=0.82, label="Jaccard (species)") + ax_heat.set_title("Pairwise Species Overlap", fontsize=10) + + title_sets = " · ".join(names) + fig.suptitle( + f"Team-Set Comparison — {battle_format.upper()}\n{title_sets}", + fontsize=13, + fontweight="bold", + y=1.01, + ) + + if save_path: + fig.savefig(save_path, dpi=160, bbox_inches="tight") + print(f"\nSaved → {save_path}") + else: + plt.show() + plt.close(fig) + + +# ─────────────────────────────────────────────────────────────────── # +# CLI +# ─────────────────────────────────────────────────────────────────── # + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Compare N Metamon team sets for a given battle format.", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument( + "--format", default="gen1ou", help="Battle format (e.g. gen1ou)" + ) + parser.add_argument( + "--sets", + nargs="+", + default=["competitive", "elite_sets_filled"], + metavar="SET", + help="Two or more team-set names to compare", + ) + parser.add_argument( + "--workers", + type=int, + default=max(1, mp.cpu_count() // 2), + help="Parallel workers for file parsing (default: half of CPU count)", + ) + _here = os.path.dirname(os.path.abspath(__file__)) + parser.add_argument( + "--save", + nargs="?", + const=os.path.join(_here, "team_set_comparison.png"), + default=None, + metavar="PATH", + help="Save figure to PATH (default:

Early Gen OU Local GXE