コンテンツにスキップ

2026-06-28

デッキ内カードIDと静的特徴量の埋め込みを分離

Set Transformer deck context のカード表現を見直した。

これまでは deck card id と CardStaticFeatures 由来の feature token を同じ embedding table に寄せていたため、「カード個体の identity」と「Pokemon / HP / attack_count / retreat などの汎化特徴」が混ざりやすかった。

変更後:

  • card_id_embedding
  • raw card id を直接 index として引く。
  • そのカード自体の identity を覚える。
  • card_feature_embedding
  • CardStaticFeatures を token 化した feature token を平均 pool する。
  • カード種別や HP などの汎化しやすい特徴を覚える。
  • card_feature_encoder
  • concat(card_id_vec, feature_vec)Linear(2d→d) → ReLU → LayerNorm で deck card vector に戻す。

互換性:

  • 既存 checkpoint からの warm-start では、shape が一致する state/action/policy/value などの重みだけを読む。
  • deck card branch の新規 weight と shape が変わった card_feature_encoder は初期化から始まる。
  • load_state_dict(strict=False) だけでは shape mismatch で落ちるため、runtime loader も load_compatible_state_dict を使うようにした。

進展の少ない教師方策の重みを下げる

v12 の teacher search は AlphaGo Zero 風に NN + ISMCTS を使う方針だが、teacher が弱い局面の policy target をそのまま imitation すると、value では負けを学んでも policy prior が悪い手を模倣してしまう。

そのため、学習時の policy loss に限って低品質 teacher の重みを下げるオプションを追加した。

追加:

  • --low-progress-policy-weight
  • configs/v12/train-policy.yaml では low-progress-policy-weight: 0.3

低品質 teacher の判定:

  • record の your_index 側がサイドを 1 枚も取れていない
  • かつ以下のいずれか
  • 攻撃回数が 0
  • その player が勝っていない
  • no-progress END がある

重要な点:

  • value target は下げない。
  • 負けた/停滞した局面から「これは悪い」を学ぶ信号は残す。
  • policy target だけを下げる。
  • 弱い search policy を強く模倣しないため。
  • サイドを取れている負けデータは低品質扱いしない。
  • 負けていても、攻撃やサイド取得へ進んだ手順は学習価値があるため。
  • 古い JSONL は game_prizes_taken が無いので低品質判定しない。
  • 後方互換を優先する。

実装:

  • src/pca/training/dataset.py
  • is_low_progress_record
  • record_player_stat
  • record_policy_weight(..., low_progress_policy_weight=...)
  • src/pca/training/selfplay.py
  • self-play record meta に以下を保存
    • game_attack_ready_counts
    • game_prizes_taken
    • game_final_prize_counts
    • game_no_progress_pass_counts
  • src/pca/training/train.py
  • CLI / checkpoint config に low_progress_policy_weight を追加
  • tests/test_training.py
  • 低品質 teacher policy だけ下がることをテスト
  • サイド取得済みの負け record は下げないことをテスト

想定コマンド:

PYTHONPATH=src uv run python -m pca.training.train \
  --config configs/v12/train-policy.yaml

たね切れ・ベンチ不在への対策

ルールベース AI との対戦で、Active card must be the ID of a Pokémon card.reason=3 の種切れ負けが目立った。

修正:

  • Belief hidden active sampling で Pokemon card id だけを active 候補にする。
  • Basic Energy を Pokemon と誤判定しないよう CardStaticFeatures.is_pokemon を修正。
  • ISMCTS candidate pruning で、空ベンチ時にベンチへ出せる Pokemon 候補を落とさない。
  • v12_prize_race value に empty-bench seed-out risk と小さい bench safety bonus を追加。
  • reason=3 の種切れ負けは、value target には負けとして残し、policy imitation は low-progress 扱いで弱める。
  • evaluation summary に pokemon_out_wins/losses/finishes を追加し、normal win から除外する。
  • self-play log に reason=pokemon_out と parent total の pokemon_outs を出すようにした。

確認:

PYTHONPATH=src python -m unittest tests.test_encoder tests.test_training

ドキュメントの更新

現行仕様を 1 ファイルにまとめた。

追加:

  • docs/architecture/current-method.md

更新:

  • docs/documentation-guide.md
  • docs/architecture/belief-guided-neural-ismcts.md
  • docs/research/2026-06-24-current-training-strategy.md
  • docs/research/2026-06-21-belief-guided-neural-ismcts.md
  • docs/research/2026-06-22-ismcts-distillation-results.md
  • docs/PLAN.md

現在の本線として明示したこと:

  • NN-only ではなく、Policy/Value Net と ISMCTS を組み合わせる。
  • rollout は使わず、leaf で NN value + progress value を使う。
  • Belief Model は hidden state sampler の prior。
  • oracle policy target は実験用で、v12 本線では使わない。
  • deck context / Set Transformer は実装済みで v7 系では使っていた。v10/v11 では teacher search 安定化のため一時的に無効化したが、現在のデッキを明示的に理解させるため v12 学習 default で再度有効化する。

Policy/Value モデル構造のレビューメモ

current-method.md を読み物として整理する過程で、ActionConditionedPolicyValueNet (src/pca/models/policy_value.py) と features encoder (src/pca/features/encoder.py) を改めて精読した。設計は AlphaGo Zero 系 + action-conditioned policy としてオーソドックスで、致命傷はないが、後で詰まりそうな箇所をメモしておく。優先度順:

1. Hash bucket TOKEN_BUCKETS = 8192 が小さい疑い

  • トークン化は blake2b("prefix:value") % 8191 + 1 の feature hashing (encoder.py:27-29)。
  • card_id:* だけで数百〜1000、それ以外に card_* 静的特徴・zoneplayerlog_typephaseturnturn_action_countselect_* 等の prefix が数十あるため、ユニーク文字列数は数千〜1 万と推定される。
  • 8192 buckets だと衝突確率が無視できないオーダー。Embedding は衝突したカード ID と無関係 prefix を「無理やり共有」する形になる。
  • TODO: set([token(p, v) for ...]) で実衝突率を測る 1 行スクリプトを書き、必要なら TOKEN_BUCKETS = 32768 〜 65536 への引き上げを検討。

2. State encoder に positional encoding が無い

  • state_encoderEmbedding → TransformerEncoder → mean pool のみで positional embedding を足していない (policy_value.py:165-171)。
  • これは token("zone", ...) / token("player", ...) などの prefix トークンを delimiter とし、state を bag-of-tokens として扱う設計。
  • ベンチ位置自体には効果がないので順序不変で問題ないが、「ベンチが何体いるか」のような離散カウントは self-attention 経由でしか復元できない。
  • 改善余地: bench_count:N のような summary token を追加する案。
  • TODO: 「state は順序不変な集合として設計している」旨を current-method.md の Policy/Value Model 節に 1 行明記する。

3. Action path が極端に asymmetric

  • State path は Transformer × num_layers なのに対し、Action path は Embedding → mean pool → Linear → ReLU → LayerNorm のみ (policy_value.py:104-108, 237-238)。
  • forward が action 数 A に比例して増えるのを避ける compute トレードオフとしては合理的。
  • ただし option_type / target / card_id / ... を mean pool で潰すため、「カード A を target B にプレイ」「カード B を target A にプレイ」が同じベクトルになる可能性がある。
  • 改善余地: 重要組み合わせを prefix 設計で 1 トークン化 (例 play_target:A→B)。実測コストは小さい。

4. State-Action 相互作用が浅い (concat + 2 層 MLP)

  • policy_head = Linear(2d→d) → ReLU → Linear(d→1) のみで state と action を混ぜている (policy_value.py:130-134, 240)。
  • AlphaZero 系として標準だが、「同じ手でも盤面で価値が大きく変わる」ポケカ的状況では表現力不足になりやすい。
  • 改善余地: 軽量な順に (a) bilinear 項 state^T W action 追加、(b) policy_head を 3-4 層化、(c) action → state の cross-attention。

5. Deck context fusion の mode 間 asymmetry

  • mean モード: state_vec = context_fusion(concat(state_vec, deck_vec)) (置き換え)
  • set_transformer モード: state_vec = state_vec + deck_gate * context_fusion(...) (residual + 学習可能ゲート, gate 初期 0)
  • (policy_value.py:191-199)
  • 意図的 (v7 系から v12 への移行で壊れないよう set_transformer 側だけ gate を入れた) だが、コメントが無いので将来読む人が混乱する可能性。
  • TODO: コード側に意図コメントを 1 行入れるか、mean 側も residual + gate に揃える。

6. set_transformer fusion の残差後に LayerNorm が無い

  • state_vec + deck_gate * fusion の直後に norm が無いため、deck_gate が大きくなると分散が安定しない可能性 (policy_value.py:199)。
  • 実害は小さいが、self.context_norm(state_vec + ...) 1 行で予防できる。

7. State encoder の集約が素朴な mean pool

  • Set Transformer 側は学習可能 pool_token + MultiheadAttention pooling なのに、state 側は masked mean pool。
  • State の方が情報密度が高いので、こちらに attention pool / [CLS] token を入れる方が筋が良い可能性。

8. デフォルト d_model=128, nhead=4, num_layers=2

  • 軽量 baseline 設定。NN forward が self-play 最大のボトルネックである事情とは整合するが、表現力の天井としては低め。
  • d_model=256, num_layers=3-4 への引き上げは v12 以降で要検討。

検証順序の提案

  1. (1) hash 衝突実測 → 数行のスクリプトで結論が出る。最小コストで一番大きい改善余地。
  2. (2)(5) ドキュメント追記 → 後続実装者の事故を減らす目的でゼロコスト。
  3. (3) action token 構造化 → encoder 側の prefix 設計変更で済む。retrain 必要。
  4. (4) bilinear 追加 / (6) LayerNorm 追加 / (7) attention pool → 順にモデル改造。1 つずつ A/B して効果測定。
  5. (8) モデルサイズアップ → forward コスト増のため、cache / batch / remote 推論の改善と組で検討。

状態・行動・履歴からカードを参照する分岐

前の整理では、deck context だけが card_id_embedding + card_feature_embedding の構造化 card vector を使い、state / action / history は flat token 経路で処理されていた。これだと、盤面や合法手に含まれるカードの identity と静的特徴を deck と同じ粒度で扱えない。

修正:

  • EncodedObservation と self-play JSONL に以下を追加。
  • state_card_ids
  • action_card_ids
  • history_card_ids
  • ActionConditionedPolicyValueNetcard_ref_context_mode を追加。
  • auto / none / card_ref
  • training default は auto
  • card-reference branch は deck と同じ card vector 化を使う。
  • raw card id: card_id_embedding
  • 静的特徴: card_feature_embedding
  • 合成: card_feature_encoder
  • state は Set Transformer、history/action は card id list の mean pool で読み、各 branch に residual gate で合流する。
  • state_card_gate
  • history_card_gate
  • action_card_gate
  • gate 初期値は --init-card-ref-gate で指定し、標準は 0.0。
  • 旧 checkpoint から warm-start した直後は既存挙動を壊さない。

意図:

  • deck だけでなく、盤面・履歴・合法手に出ているカードも card id と静的特徴を同じ方法で扱えるようにする。
  • flat token 経路は残す。
  • zone / option type / select context / log type など、カードがどの文脈で現れたかを表すため。
  • TokenVocab による card id token 共有も残す。
  • card, option.cardId, log.cardId の同じ card id は同じ token id に解決される。

確認予定:

PYTHONPATH=src python -m unittest tests.test_training tests.test_encoder

Structured object context への置き換え

card-reference branch は「カード id と静的特徴」を state / action / history に入れられるが、state_card_ids のような集合表現だけでは「どの Pokemon にどの Energy が付いているか」「どの option がどの target を指しているか」が弱い。

そのため、A/B 用の追加 branch ではなく、v12 本線として structured object 表現を追加し、structured data がある場合は unstructured card_ref_context_mode を無効化するようにした。

追加した object:

  • state_objects
  • Pokemon / attached energy / attached tool / pre-evolution / hand / discard / stadium など。
  • attached energy / tool / pre-evolution には parent_zone / parent_slot を持たせる。
  • action_objects
  • option の cardId、action type、target area / index、select context。
  • history_objects
  • log の cardId、fromArea、toArea、event type。

object row の構造:

[card_id, owner, zone, role, slot, parent_zone, parent_slot,
 hp_bucket, damage_bucket, energy_count, status, aux]

モデル側:

  • structured_context_mode = none | objects | auto
  • auto は JSONL に search.*_objects があれば objects
  • raw card id は card_id_embedding
  • static feature は card_feature_embedding
  • context fields は shared embedding。
  • structured_object_encoder(concat(card_vec, context_vec)) で object vector にする。
  • state は Set Transformer、history / action は object mean で合流。
  • init_structured_gate default は 0.05。
  • warm-start を大きく壊さない範囲で、structured object branch に最初から勾配を流すため。

後方互換:

  • 古い state_card_ids / action_card_ids / history_card_ids は残す。
  • ただし structured objects がある場合、resolve_model_configcard_ref_context_mode=none にする。

確認:

PYTHONPATH=src python -m unittest tests.test_training tests.test_encoder tests.test_cli_config tests.test_submission tests.test_vocab

P2-2: 決定論的 TokenVocab の実装と統合

refactor plan の Phase 2-2 を完了。

前提: P1-1 (../research/2026-06-28-token-collision-report.md) で「8192 buckets では 6768 文字列のうち 3818 (56%) が衝突」を確認。card_id が 1000+ ある以上、bucket を増やすだけでは衝突ゼロにならない。

実装

  • src/pca/features/vocab.py — TokenVocab クラス。card_id は int_as_offset (実値 = offset) で新カード追加時に既存 ID が動かない構造。card / log.cardId / option.cardId を 1 つの card_id block で共有。enum 系は 4 倍 headroom、末尾 1024 を未知 prefix 用 fallback hash に確保。
  • src/pca/features/encoder.pytoken() を process-wide active vocab 経由に変更。set_active_vocab(vocab) を entrypoint で 1 回呼ぶだけで 14 箇所の encode_observation 呼び出しが自動連動する設計 (call site の plumbing を回避)。
  • src/pca/cabt/card_db.pybuild_card_feature_token_table(card_db, vocab=None) で vocab パススルー。
  • Entrypoint 統合: selfplay (main + spawn worker)、train.resolve_model_config、belief_train、tournament、submission/main の 5 箇所で active vocab を install。checkpoint load 時は verify_vocab_digest で mismatch を警告。
  • 既存 checkpoint は load_compatible_state_dict の shape-skip で吸収 (vocab 関連 Embedding は random init から再学習が必要)。

数字

旧 hash 新 vocab
Embedding 行数 8192 18631
Embedding メモリ (d=128) 4.0 MB 9.1 MB
real collision string 3818 (56%) 0
利用率 56% (hash 運次第) 23.4% (= 4 倍の content drop を吸収する予約)

card_id block: 5000 枠予約 / 現状 1268 使用。card_id 5000 までの新カードを vocab 拡張なしで吸収可能。

テスト

  • tests/test_vocab.py (19 件) — block 操作 / JSON roundtrip / digest 安定性 / 追加成長 / card_id 共有 / active vocab context / 全 card db に対する real collision = 0 検証。
  • tests/test_encoder.py — vocab 経由の encode_observation 動作確認を 1 件追加。
  • 全 187 テスト pass。

次の運用

  • v12 self-play の次サイクルから vocab encoding に切り替わる。既存 v11 checkpoint からは shape mismatch で Embedding 関連が random init になるので、最初の数 epoch で warm-up 必要。
  • 新カード追加時は CSV を更新するだけ。vocab.size が自動的に拡張対応。card_id が 5000 を超える場合のみ TokenVocab.from_card_db(card_db, card_id_capacity=...) で再調整。
  • 計測: PYTHONPATH=src uv run python scripts/measure_token_collisions.py で hash + vocab 両方の collision stats と vocab レイアウトレポートが docs/research/2026-06-28-token-collision-report.md に再生成される。

メモ

# ismcts-simulations-per-determinizationを128で試した場合

[selfplay][game] id=8 result=p0_win reason=1 steps=93 turns=13 avg_step_turn=7.15
  board taken=6-0 remain=0-6 deck=44-16 hand=7-7 atk=6-0 ready=9-12 no_pass=0-0 mill=0-0
  perf elapsed=4592.0s speed=0.8g/h records/s=0.0
  search begin=47616 step=206787 end=93 obs_conv=93 full_obs=93 t=23.1/69.1s
  nn calls=46071 cache=2529/43447 total=4090.2s fwd=4032.5s deck=0.9s enc=44.6s tensor=10.0s
  ismcts dec=93 sim=47616 hidden=372/372 time=4589.1s tree_enc=206787/323.6s nodes=45978/160809 cand=5.9/10.5
  depth avg=4.34 max=5+ leaf=4.38/45883 term=3.32/1733 hist=0/2037/5046/9353/10827/20353+
  reach turn_advance=0.67 attack_reached=0.26 prize_reached=0.21
  root n=93 actions=4.7 atk_ready=21/93 atk_opts=1.2 atk_top prior/visit/sel=0.05/0.06/0.06 end_sel=0.06 agree=0.65 top_share prior/visit=0.44/0.52 H prior/visit=1.28/1.10
  root-actions sel play=0.24 attach=0.05 evolve=0.04 ability=0.05 retreat=0.03 discard=0.00 choice=0.45 other=0.00
  stops leaf=45883 term=1733 no_action=0 no_select=0 fail=0 max_depth=0
  prize-delta events=10048 taken=9278-2534 value=3034.80
  turns [6,2,9,16,3,5,5,10,2,17,4,12,2]
[selfplay][worker] id=2 done=1/4 global_game=8 result=p0_win reason=1 steps=93 records=93 finished=1/1
  board taken=6-0 remain=0-6 deck=44-16 atk=6-0 ready=9-12 no_pass=0-0 mill=?-?
  perf elapsed=4592.1s speed=0.8g/h progress=1/4(25.0%) remaining=3 eta=3h49m records/s=0.0
  search begin=47616 step=206787 end=93 obs_conv=93 full_obs=93 t=23.1/69.1s
  nn calls=46071 cache=2529/43447 total=4090.2s fwd=4032.5s deck=0.9s enc=44.6s tensor=10.0s
  ismcts dec=93 sim=47616 hidden=372/372 time=4589.1s tree_enc=206787/323.6s nodes=45978/160809 cand=5.9/10.5
  depth avg=4.34 max=5+ leaf=4.38/45883 term=3.32/1733 hist=0/2037/5046/9353/10827/20353+
  reach turn_advance=0.67 attack_reached=0.26 prize_reached=0.21
  root n=93 actions=4.7 atk_ready=21/93 atk_opts=1.2 atk_top prior/visit/sel=0.05/0.06/0.06 end_sel=0.06 agree=0.65 top_share prior/visit=0.44/0.52 H prior/visit=1.28/1.10
  root-actions sel play=0.24 attach=0.05 evolve=0.04 ability=0.05 retreat=0.03 discard=0.00 choice=0.45 other=0.00
  stops leaf=45883 term=1733 no_action=0 no_select=0 fail=0 max_depth=0
  prize-delta events=10048 taken=9278-2534 value=3034.80
  decks own=clefairy-ogerpon.csv opp=dragapult-blaziken.csv