File size: 8,007 Bytes
caea08d 04b615f caea08d 04b615f caea08d 04b615f caea08d 04b615f caea08d 04b615f caea08d | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 | """Module 5 (TRL OpenEnv Wordle) style rollout for ShopManagerEng.
Two public symbols:
* ``rollout_once(...)`` — plays a single multi-turn jewelry-shop episode
against an already-connected sync env client and returns the per-episode
signals TRL/GRPO needs.
* ``build_rollout_func(...)`` — closure factory that returns the
``rollout_func(prompts, trainer=None)`` callable handed to ``GRPOTrainer``.
The pattern (canonical for OpenEnv + TRL >= 0.17):
sync_env = env.sync(); sync_env.connect() # one persistent WS
trainer = GRPOTrainer(..., rollout_func=rollout_func)
trainer.train()
"""
from __future__ import annotations
import re
from typing import Any, Callable, Dict, List, Optional
try:
from .parse_action import parse_model_text_to_action
from .prompts import build_user_prompt
except ImportError:
from training.parse_action import parse_model_text_to_action
from training.prompts import build_user_prompt
# Set of valid task ids supported by openenv.yaml; first one is the default.
VALID_TASKS = ("market_timing", "demand_crafter", "profit_negotiator")
_TASK_RE = re.compile(r"\[TASK=(\w+)\]")
def extract_task_id(prompt_text: str, default: str = VALID_TASKS[0]) -> str:
"""Pull the [TASK=...] tag the dataset embeds, or fall back to the default."""
m = _TASK_RE.search(prompt_text or "")
if not m:
return default
candidate = m.group(1)
return candidate if candidate in VALID_TASKS else default
def _apply_chat_template(tokenizer, messages, model_name: str = "") -> str:
"""Apply chat template, opting out of Qwen3 'thinking' mode when applicable."""
template_kwargs: Dict[str, Any] = {
"add_generation_prompt": True,
"tokenize": False,
}
# Qwen3 family supports the `enable_thinking` switch — disable it for short
# action outputs. Other models silently ignore unknown kwargs in newer
# transformers; older ones may raise, hence the lower() guard.
if "qwen3" in (model_name or "").lower():
template_kwargs["enable_thinking"] = False
return tokenizer.apply_chat_template(messages, **template_kwargs)
def rollout_once(
*,
trainer,
sync_env,
tokenizer,
dataset_prompt: str,
system_prompt: str,
max_turns: int,
model_name: str = "",
) -> Dict[str, Any]:
"""Play one full jewelry-shop episode and return per-episode signals.
Returns the dict shape TRL's GRPO loop expects: ``prompt_ids``,
``completion_ids``, ``logprobs`` for **a single** vLLM forward (the **last
environment turn** in the episode) plus reward signals for reward
functions.
We **do not** concatenate multiple turns into one list. In ``GRPOTrainer``,
each batch row is ``cat(prompt_ids, completion_ids)``; vLLM's per-token
``logprobs`` must be for **that** exact sequence, or the importance-sampling
ratio (vLLM vs reference forward) collapses. Multi-turn play still runs in
the environment; the policy gradient is applied to the **last** action's
tokens, while ``total_reward`` remains the full episode return for GRPO
group advantages.
"""
# Late import: trl.experimental.openenv only exists for trl >= 0.17.
from trl.experimental.openenv import generate_rollout_completions
task_id = extract_task_id(dataset_prompt)
result = sync_env.reset(task_id=task_id)
obs = result.observation
# One (prompt_ids, completion_ids, logprobs) per vLLM call; last turn only
# is returned to TRL (see module docstring).
turn_traces: List[Dict[str, Any]] = []
history: List[str] = []
last_reward = 0.0
phase_rewards = {"market": 0.0, "warehouse": 0.0, "showroom": 0.0}
for turn in range(1, max_turns + 1):
if result.done:
break
user_prompt = build_user_prompt(turn, obs, last_reward, history)
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
]
prompt_text = _apply_chat_template(tokenizer, messages, model_name=model_name)
rollout_outputs = generate_rollout_completions(trainer, [prompt_text])[0]
p_ids = rollout_outputs["prompt_ids"]
c_ids = rollout_outputs["completion_ids"]
lps = rollout_outputs["logprobs"]
p_list = p_ids.tolist() if hasattr(p_ids, "tolist") else list(p_ids)
c_list = c_ids.tolist() if hasattr(c_ids, "tolist") else list(c_ids)
turn_traces.append(
{
"prompt_ids": p_list,
"completion_ids": c_list,
"logprobs": [float(x) for x in lps],
}
)
completion_text = rollout_outputs.get("text") or tokenizer.decode(
rollout_outputs["completion_ids"], skip_special_tokens=True
)
current_phase = obs.phase
action, raw_action_str = parse_model_text_to_action(current_phase, completion_text)
result = sync_env.step(action)
obs = result.observation
step_reward = float(result.reward or 0.0)
last_reward = step_reward
if current_phase in phase_rewards:
phase_rewards[current_phase] += step_reward
history.append(
f"Step {turn} ({current_phase}): {raw_action_str!r} -> reward {step_reward:+.2f}"
)
total_reward = float(getattr(obs, "cumulative_reward", sum(phase_rewards.values())))
total_reward = max(0.0, min(total_reward, 1.0))
if not turn_traces:
raise ValueError(
"rollout_once produced no vLLM turns (max_turns too low or env ended "
"before the first action)."
)
last = turn_traces[-1]
return {
"prompt_ids": last["prompt_ids"],
"completion_ids": last["completion_ids"],
"logprobs": last["logprobs"],
"total_reward": total_reward,
"market_reward": float(phase_rewards["market"]),
"warehouse_reward": float(phase_rewards["warehouse"]),
"showroom_reward": float(phase_rewards["showroom"]),
}
def build_rollout_func(
*,
sync_env,
tokenizer,
system_prompt: str,
max_turns: int = 15,
model_name: str = "",
) -> Callable[..., Dict[str, List]]:
"""Return ``rollout_func(prompts, trainer=None)`` closing over the env client.
A fresh episode is run for each prompt; the same persistent ``sync_env``
is reused across all prompts (single WebSocket session — matches Module 5).
"""
def rollout_func(prompts: List[str], trainer=None) -> Dict[str, List]:
episode_prompt_ids: List[List[int]] = []
episode_completion_ids: List[List[int]] = []
episode_logprobs: List[List[float]] = []
total_rewards: List[float] = []
market_rewards: List[float] = []
warehouse_rewards: List[float] = []
showroom_rewards: List[float] = []
for prompt_text in prompts:
ep = rollout_once(
trainer=trainer,
sync_env=sync_env,
tokenizer=tokenizer,
dataset_prompt=prompt_text,
system_prompt=system_prompt,
max_turns=max_turns,
model_name=model_name,
)
episode_prompt_ids.append(ep["prompt_ids"])
episode_completion_ids.append(ep["completion_ids"])
episode_logprobs.append(ep["logprobs"])
total_rewards.append(ep["total_reward"])
market_rewards.append(ep["market_reward"])
warehouse_rewards.append(ep["warehouse_reward"])
showroom_rewards.append(ep["showroom_reward"])
return {
"prompt_ids": episode_prompt_ids,
"completion_ids": episode_completion_ids,
"logprobs": episode_logprobs,
"total_reward": total_rewards,
"market_reward": market_rewards,
"warehouse_reward": warehouse_rewards,
"showroom_reward": showroom_rewards,
}
return rollout_func
|