ALF-qwen3B-privilege / code /eval_student.py
TDSMike's picture
Publish privileged ALFWorld Qwen2.5-3B teacher, structured hints, reproduction code, and evaluation audit
8f4f748 verified
Raw History Blame Contribute Delete
14 kB
"""Multi-GPU closed-loop ALFWorld evaluation from immutable best checkpoints."""
from __future__ import annotations
import argparse
from collections import Counter, defaultdict
import heapq
import json
import os
from pathlib import Path
import random
import shutil
import sys
import uuid
PROJECT = Path("/share/shenmingxiang/closing-square")
WS = PROJECT / "verl_workspace"
sys.path.insert(0, str(WS / "third_party" / "Agent-R1"))
sys.path.insert(0, str(PROJECT))
import pandas as pd
import torch
import torch.distributed as dist
from peft import PeftModel
from transformers import AutoModelForCausalLM, AutoTokenizer
from recipes.alfworld.env.alfworld_wrapper import AlfworldTextworldEnv
from src.prompts import student_prompt, teacher_prompt
from src.prepare_structured_hints import build_hint_fields, render_hint, _load_traj
DEFAULT_MODEL = PROJECT / "models" / "Qwen2.5-3B-Instruct"
DATA_ROOT = WS / "data" / "alfworld"
def set_seed(seed: int) -> None:
random.seed(seed)
torch.manual_seed(seed)
torch.cuda.manual_seed_all(seed)
@torch.no_grad()
def constrained_action(model, tokenizer, prompt: str, actions: list[str], device, max_prompt_length: int) -> str:
old_side = tokenizer.truncation_side
tokenizer.truncation_side = "left"
encoded = tokenizer(
prompt, return_tensors="pt", add_special_tokens=True,
truncation=True, max_length=max_prompt_length,
)
tokenizer.truncation_side = old_side
input_ids = encoded["input_ids"].to(device)
attention_mask = encoded["attention_mask"].to(device)
prompt_length = input_ids.shape[1]
token_to_action: dict[tuple[int, ...], str] = {}
sequences: list[tuple[int, ...]] = []
for action in actions:
tokens = tuple(tokenizer(action, add_special_tokens=False)["input_ids"])
token_to_action.setdefault(tokens, action)
sequences.append(tokens + (tokenizer.eos_token_id,))
max_new_tokens = max(len(sequence) for sequence in sequences)
def allowed_tokens(_batch_id: int, generated: torch.Tensor) -> list[int]:
prefix = tuple(generated[prompt_length:].tolist())
allowed = {
sequence[len(prefix)]
for sequence in sequences
if len(sequence) > len(prefix) and sequence[: len(prefix)] == prefix
}
return sorted(allowed) if allowed else [tokenizer.eos_token_id]
generated = model.generate(
input_ids=input_ids,
attention_mask=attention_mask,
do_sample=False,
temperature=1.0,
top_p=1.0,
top_k=50,
repetition_penalty=1.0,
num_beams=1,
max_new_tokens=max_new_tokens,
eos_token_id=tokenizer.eos_token_id,
pad_token_id=tokenizer.pad_token_id,
prefix_allowed_tokens_fn=allowed_tokens,
use_cache=True,
)
continuation = generated[0, prompt_length:].tolist()
if tokenizer.eos_token_id in continuation:
continuation = continuation[: continuation.index(tokenizer.eos_token_id)]
key = tuple(continuation)
if key not in token_to_action:
raise RuntimeError(f"constrained decoding returned unknown token path: {continuation}")
return token_to_action[key]
def run_episode(
model,
tokenizer,
env,
item,
device,
max_steps: int,
max_prompt_length: int,
prompt_mode: str,
):
obs, info = env.reset_with_info(item["game"])
hint = item.get("H", "")
if prompt_mode == "teacher" and not hint:
expert_policy = [str(value) for value in info.get("policy_commands", [])]
hint = render_hint(
build_hint_fields(item["task_family"], _load_traj(item["game"]), expert_policy)
)
history: list[str] = []
done = False
steps = 0
repeated = 0
last_action = None
trajectory = []
for _ in range(max_steps):
admissible = [str(value) for value in info.get("admissible_commands", [])]
if done or not admissible:
break
if prompt_mode == "teacher":
prompt = teacher_prompt(item["goal"], str(obs), admissible, hint, history)
else:
prompt = student_prompt(item["goal"], str(obs), admissible, history)
action = constrained_action(model, tokenizer, prompt, admissible, device, max_prompt_length)
repeated += int(action == last_action)
next_obs, _, done, info = env.step(action)
trajectory.append(
{
"step": steps,
"observation": str(obs),
"admissible": admissible,
"action": action,
"response": str(next_obs),
"success": bool(info.get("success", False)) if info else False,
}
)
history.append(f"Action taken: {action}\nEnvironment response: {next_obs}")
obs = next_obs
last_action = action
steps += 1
return {
"success": bool(info.get("success", False)) if info else False,
"steps": steps,
"repeated_actions": repeated,
"H": hint or None,
"trajectory": trajectory,
}
def atomic_json(path: Path, value, token: str) -> None:
temp = path.parent / f".{path.name}.tmp.{token}"
with temp.open("w", encoding="utf-8") as handle:
json.dump(value, handle, ensure_ascii=False, indent=2)
handle.flush()
os.fsync(handle.fileno())
os.replace(temp, path)
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--name", required=True)
parser.add_argument("--adapter", default="none")
parser.add_argument("--model", type=Path, default=DEFAULT_MODEL)
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("--max_steps", type=int, default=50)
parser.add_argument("--max_prompt_length", type=int, default=1536)
parser.add_argument("--seed", type=int, default=42)
parser.add_argument("--limit_per_split", type=int, default=0)
parser.add_argument("--limit_per_family", type=int, default=0)
parser.add_argument(
"--dataset_mode", choices=("standard", "pilot", "sft_val"), default="standard"
)
parser.add_argument("--prompt_mode", choices=("student", "teacher"), default="student")
parser.add_argument(
"--hint_data",
type=Path,
default=WS / "data" / "privileged_plan_sft.jsonl",
)
args = parser.parse_args()
world_size = int(os.environ.get("WORLD_SIZE", "1"))
distributed = world_size > 1
if distributed:
dist.init_process_group("nccl")
rank = dist.get_rank()
local_rank = int(os.environ["LOCAL_RANK"])
torch.cuda.set_device(local_rank)
else:
rank = 0
local_rank = 0
is_main = rank == 0
device = torch.device("cuda", local_rank)
set_seed(args.seed)
items = []
if args.dataset_mode == "sft_val":
by_game = {}
with args.hint_data.open("r", encoding="utf-8") as source:
for line in source:
row = json.loads(line)
if row["sft_split"] != "val":
continue
by_game.setdefault(
row["game_id"],
{
"split": "sft_val",
"game": row["game_id"],
"goal": row["goal"],
"task_family": row["task_family"],
"H": row["H"],
},
)
by_family = defaultdict(list)
for item in by_game.values():
by_family[item["task_family"]].append(item)
rng = random.Random(args.seed)
for family in sorted(by_family):
family_items = sorted(by_family[family], key=lambda value: value["game"])
rng.shuffle(family_items)
if args.limit_per_family:
family_items = family_items[: args.limit_per_family]
items.extend(family_items)
else:
source_root = DATA_ROOT / "pilot" / "pick_and_place" if args.dataset_mode == "pilot" else DATA_ROOT
for split in ("valid_seen", "valid_unseen"):
frame = pd.read_parquet(source_root / f"{split}.parquet")
if args.limit_per_split:
frame = frame.head(args.limit_per_split)
for _, row in frame.iterrows():
extra = dict(row["extra_info"])
items.append(
{
"split": split,
"game": str(extra["game_relative_path"]),
"goal": str(extra.get("goal_text", "")),
"task_family": str(extra["task_family"]),
}
)
indexed_items = [(index, item) for index, item in enumerate(items) if index % world_size == rank]
if is_main:
print(
f"eval name={args.name} episodes={len(items)} world_size={world_size} "
f"adapter={args.adapter}",
flush=True,
)
tokenizer = AutoTokenizer.from_pretrained(args.model)
tokenizer.pad_token = tokenizer.eos_token
tokenizer.padding_side = "right"
base = AutoModelForCausalLM.from_pretrained(
args.model, torch_dtype=torch.bfloat16, low_cpu_mem_usage=True
).to(device)
if args.adapter.lower() != "none":
adapter = Path(args.adapter).resolve()
model = PeftModel.from_pretrained(base, adapter)
else:
adapter = None
model = base
model.eval().requires_grad_(False)
model.config.use_cache = True
token_holder = [f"{os.getpid()}.{uuid.uuid4().hex}" if is_main else ""]
if distributed:
dist.broadcast_object_list(token_holder, src=0)
run_token = token_holder[0]
part_dir = args.output.parent / f".{args.output.name}.parts.{run_token}"
if is_main:
args.output.parent.mkdir(parents=True, exist_ok=True)
part_dir.mkdir(parents=False, exist_ok=False)
if distributed:
dist.barrier()
env = AlfworldTextworldEnv(data_root=str(DATA_ROOT), max_episode_steps=args.max_steps)
temp_part = part_dir / f".rank_{rank:02d}.jsonl.tmp"
final_part = part_dir / f"rank_{rank:02d}.jsonl"
with temp_part.open("w", encoding="utf-8") as handle:
for local_index, (global_index, item) in enumerate(indexed_items, start=1):
try:
result = run_episode(
model, tokenizer, env, item, device, args.max_steps,
args.max_prompt_length, args.prompt_mode,
)
error = None
except Exception as exception:
result = {
"success": False,
"steps": 0,
"repeated_actions": 0,
"H": None,
"trajectory": [],
}
error = f"{type(exception).__name__}: {exception}"
finally:
env._close_env()
record = {"record_index": global_index, **item, **result, "error": error}
handle.write(json.dumps(record, ensure_ascii=False) + "\n")
if local_index % 5 == 0:
print(f"eval rank={rank} completed={local_index}/{len(indexed_items)}", flush=True)
handle.flush()
os.fsync(handle.fileno())
os.replace(temp_part, final_part)
if distributed:
dist.barrier()
if is_main:
def lines(path: Path):
with path.open("r", encoding="utf-8") as source:
for line in source:
value = json.loads(line)
yield value["record_index"], line, value
streams = [lines(path) for path in sorted(part_dir.glob("rank_*.jsonl"))]
temp_output = args.output.parent / f".{args.output.name}.tmp.{run_token}"
merged_records = []
with temp_output.open("w", encoding="utf-8") as output_handle:
for _, line, value in heapq.merge(*streams, key=lambda triple: triple[0]):
output_handle.write(line)
merged_records.append(value)
output_handle.flush()
os.fsync(output_handle.fileno())
os.replace(temp_output, args.output)
groups = defaultdict(lambda: Counter(total=0, success=0, errors=0, steps=0, repeated=0))
for record in merged_records:
for key in (record["split"], f"{record['split']}::{record['task_family']}", "pooled"):
groups[key]["total"] += 1
groups[key]["success"] += int(record["success"])
groups[key]["errors"] += int(record["error"] is not None)
groups[key]["steps"] += int(record["steps"])
groups[key]["repeated"] += int(record["repeated_actions"])
summary_groups = {}
for key, counts in sorted(groups.items()):
total = max(counts["total"], 1)
summary_groups[key] = {
**dict(counts),
"success_rate": counts["success"] / total,
"mean_steps": counts["steps"] / total,
"mean_repeated_actions": counts["repeated"] / total,
}
summary = {
"name": args.name,
"adapter": str(adapter) if adapter else None,
"base_model": str(args.model.resolve()),
"seed": args.seed,
"dataset_mode": args.dataset_mode,
"prompt_mode": args.prompt_mode,
"decoding": "greedy token-trie constrained to admissible commands",
"groups": summary_groups,
}
summary_path = args.output.with_suffix(args.output.suffix + ".summary.json")
atomic_json(summary_path, summary, run_token)
print(json.dumps(summary, ensure_ascii=False, indent=2), flush=True)
if distributed:
dist.barrier()
if is_main:
shutil.rmtree(part_dir)
if distributed:
dist.destroy_process_group()
if __name__ == "__main__":
main()