sre-arena / training /opponent_loader.py
blitz1809's picture
feat(7a): OpponentModel β€” frozen PEFT checkpoint loader with blind generation
2812792
Raw
History Blame Contribute Delete
6.91 kB
"""OpponentModel β€” frozen PEFT checkpoint loader for alternating best-response training.
Loads a prior-generation LoRA checkpoint as a frozen opponent during training.
GPU imports (transformers, peft, bitsandbytes) are deferred inside
``from_checkpoint`` so this module is importable on CPU-only machines.
"""
from __future__ import annotations
import logging
logger = logging.getLogger(__name__)
try:
from ..models import AttackerObservation, DefenderObservation
from .prompts import build_attacker_prompt, build_defender_prompt
from .action_parser import parse_attacker_action, parse_defender_action
from .scripted_attacker import ScriptedAttacker
from .scripted_defender import ScriptedDefender
except ImportError:
from models import AttackerObservation, DefenderObservation
from training.prompts import build_attacker_prompt, build_defender_prompt
from training.action_parser import parse_attacker_action, parse_defender_action
from training.scripted_attacker import ScriptedAttacker
from training.scripted_defender import ScriptedDefender
_VALID_ROLES = frozenset({"attacker", "defender"})
class OpponentModel:
"""Frozen opponent backed by a (possibly PEFT-wrapped) HuggingFace model.
Use ``from_checkpoint`` to load from a LoRA checkpoint directory.
Use ``__init__`` directly when passing pre-loaded mocks (e.g. in tests).
"""
def __init__(self, model, tokenizer, role: str) -> None:
"""Store model/tokenizer, call eval(), validate role.
Args:
model: A HuggingFace-compatible model object with ``.generate()``
and ``.parameters()``.
tokenizer: A HuggingFace-compatible tokenizer object.
role: Either ``"attacker"`` or ``"defender"``.
Raises:
ValueError: If ``role`` is not ``"attacker"`` or ``"defender"``.
"""
if role not in _VALID_ROLES:
raise ValueError(
f"role must be one of {sorted(_VALID_ROLES)!r}, got {role!r}"
)
self._model = model
self._tokenizer = tokenizer
self._role = role
self._model.eval()
logger.debug("OpponentModel initialised (role=%s)", role)
@classmethod
def from_checkpoint(
cls,
base_model_name: str,
checkpoint_path: str,
role: str,
tokenizer,
) -> "OpponentModel":
"""Load a 4-bit quantised base model and wrap it with a PEFT LoRA adapter.
GPU-specific imports are deferred here so the module stays importable
on CPU-only machines (e.g. during unit tests or dashboard runs).
Args:
base_model_name: HuggingFace model ID, e.g. ``"Qwen/Qwen2.5-7B-Instruct"``.
checkpoint_path: Local path or HF repo ID for the LoRA adapter.
role: ``"attacker"`` or ``"defender"``.
tokenizer: Pre-loaded tokenizer (avoids double-loading).
Returns:
An ``OpponentModel`` instance ready for inference.
"""
if role not in _VALID_ROLES:
raise ValueError(
f"role must be one of {sorted(_VALID_ROLES)!r}, got {role!r}"
)
import torch # noqa: F401 β€” deferred GPU import
from transformers import AutoModelForCausalLM, BitsAndBytesConfig
from peft import PeftModel
bnb_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=torch.float16,
bnb_4bit_use_double_quant=True,
)
logger.info(
"Loading base model %s with 4-bit quantisation…", base_model_name
)
base = AutoModelForCausalLM.from_pretrained(
base_model_name,
quantization_config=bnb_config,
device_map="auto",
)
logger.info("Wrapping with PEFT adapter from %s…", checkpoint_path)
model = PeftModel.from_pretrained(base, checkpoint_path)
return cls(model, tokenizer, role)
# ── Inference ────────────────────────────────────────────────────────────
def generate_action(self) -> dict:
"""Generate an action dict from a static, blind (no observation) prompt.
Returns:
A dict that is the ``model_dump()`` of the parsed action, or the
scripted fallback if parsing fails.
"""
import torch
prompt = self._build_prompt()
inputs = self._tokenizer(prompt, return_tensors="pt")
device = next(iter(self._model.parameters())).device
inputs = {k: v.to(device) for k, v in inputs.items()}
input_len = inputs["input_ids"].shape[1]
with torch.no_grad():
outputs = self._model.generate(
**inputs,
max_new_tokens=128,
do_sample=False,
pad_token_id=self._tokenizer.eos_token_id,
)
new_tokens = outputs[0][input_len:]
text = self._tokenizer.decode(new_tokens, skip_special_tokens=True)
logger.debug("OpponentModel raw output (role=%s): %r", self._role, text)
return self._parse_or_fallback(text)
# ── Private helpers ───────────────────────────────────────────────────────
def _build_prompt(self) -> str:
"""Build a minimal static observation prompt for blind generation."""
if self._role == "attacker":
obs = AttackerObservation(episode_step=0, steps_remaining=50)
return build_attacker_prompt(obs)
else:
obs = DefenderObservation(episode_step=0)
return build_defender_prompt(obs)
def _parse_or_fallback(self, text: str) -> dict:
"""Parse LLM output into a dict; return scripted fallback on failure.
Args:
text: Raw decoded text from the model.
Returns:
Parsed action dict, or scripted fallback dict.
"""
if self._role == "attacker":
action, err = parse_attacker_action(text)
if err or action is None:
logger.debug("Attacker parse failed (%s); using scripted fallback", err)
return ScriptedAttacker(seed=42).act().model_dump(
exclude={"delay_ms", "metadata"}
)
return action.model_dump(exclude={"delay_ms", "metadata"})
else:
action, err = parse_defender_action(text)
if err or action is None:
logger.debug("Defender parse failed (%s); using scripted fallback", err)
return ScriptedDefender(seed=42).act().model_dump()
return action.model_dump()