Delta-Vector commited on
Commit
66ff7ba
·
verified ·
1 Parent(s): 34184c5

Upload folder using huggingface_hub

Browse files
__pycache__/roleplay_selfplay.cpython-312.pyc ADDED
Binary file (18.8 kB). View file
 
pyproject.toml ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [project]
2
+ name = "roleplay-selfplay"
3
+ version = "0.1.0"
4
+ description = "Roleplay self-play environment with CAI critique-and-revision"
5
+ tags = ["multi-turn", "roleplay", "self-play", "train", "eval"]
6
+ requires-python = ">=3.10"
7
+ dependencies = [
8
+ "verifiers>=0.1.6",
9
+ "datasets",
10
+ "httpx",
11
+ "huggingface_hub",
12
+ ]
13
+
14
+ [build-system]
15
+ requires = ["hatchling"]
16
+ build-backend = "hatchling.build"
17
+
18
+ [tool.hatch.build]
19
+ include = ["roleplay_selfplay.py", "pyproject.toml"]
20
+
21
+ [tool.verifiers.eval]
22
+ num_examples = 5
23
+ rollouts_per_example = 1
roleplay_selfplay.py ADDED
@@ -0,0 +1,466 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Roleplay Self-Play RL Environment (MultiTurnEnv-based).
3
+
4
+ Architecture:
5
+ 1. Dataset provides system prompt + first user turn (the "seed")
6
+ 2. Standard rollout loop: model generates Character A (assistant), env generates Character B (user)
7
+ 3. All turns judged by external judge model for reward signal
8
+ 4. Model only trained on Character A turns (standard GRPO with logprobs)
9
+ """
10
+
11
+ import asyncio
12
+ import logging
13
+ import random
14
+ import re
15
+ from typing import Any
16
+
17
+ import httpx
18
+ from datasets import load_dataset
19
+ from openai import AsyncOpenAI
20
+
21
+ import verifiers as vf
22
+ from verifiers.envs.multiturn_env import MultiTurnEnv
23
+ from verifiers.types import Info, Messages, SamplingArgs, State
24
+
25
+ logger = logging.getLogger("roleplay_selfplay")
26
+
27
+
28
+ # ---------------------------------------------------------------------------
29
+ # Utility helpers
30
+ # ---------------------------------------------------------------------------
31
+
32
+
33
+ def strip_think_tags(text: str) -> str:
34
+ if not text:
35
+ return text
36
+ cleaned = re.sub(r"<think>.*?</think>", "", text, flags=re.DOTALL | re.IGNORECASE)
37
+ cleaned = re.sub(r"<think>.*$", "", cleaned, flags=re.DOTALL | re.IGNORECASE)
38
+ return cleaned.strip()
39
+
40
+
41
+ def count_words(text: str) -> int:
42
+ if not text:
43
+ return 0
44
+ cleaned = re.sub(r"```.*?```", "", text, flags=re.DOTALL)
45
+ return len([w for w in re.split(r"\s+", cleaned.strip()) if w])
46
+
47
+
48
+ def detect_lists(text: str) -> tuple[bool, dict[str, Any]]:
49
+ if not text:
50
+ return False, {}
51
+ text_clean = re.sub(r"```.*?```", "", text, flags=re.DOTALL)
52
+ bullet_items = re.findall(r"^\s*[-*\u2022\u25cf]\s+.+$", text_clean, re.MULTILINE)
53
+ numbered_items = re.findall(r"^\s*\d+[.)]\s+.+$", text_clean, re.MULTILINE)
54
+ has = len(bullet_items) >= 3 or len(numbered_items) >= 3
55
+ return has, {"bullet_count": len(bullet_items), "numbered_count": len(numbered_items)}
56
+
57
+
58
+ def detect_structured_markers(text: str) -> tuple[bool, dict[str, Any]]:
59
+ if not text:
60
+ return False, {}
61
+ markers = 0
62
+ examples: list[str] = []
63
+ xml = re.findall(r"<([a-zA-Z_]\w{2,})>.*?</\1>", text, re.DOTALL)
64
+ if xml:
65
+ markers += 1
66
+ examples.append(f"XML: {xml[0]}")
67
+ brackets = re.findall(r"\[([A-Z_]{3,})\]", text)
68
+ if brackets:
69
+ markers += 1
70
+ examples.append(f"Bracket: [{brackets[0]}]")
71
+ return markers > 0, {"total_markers": markers, "examples": examples}
72
+
73
+
74
+ # ---------------------------------------------------------------------------
75
+ # Turn-level judge prompt (symmetric -- no user/assistant labels)
76
+ # ---------------------------------------------------------------------------
77
+
78
+ TURN_JUDGE_PROMPT = """You are evaluating a single turn from a roleplay conversation.
79
+ Score ONLY the turn inside <evaluate_turn> tags. The context is for reference.
80
+
81
+ <conversation_context>
82
+ {context}
83
+ </conversation_context>
84
+
85
+ <evaluate_turn>
86
+ {turn_text}
87
+ </evaluate_turn>
88
+
89
+ How good is this turn as a piece of collaborative roleplay writing?
90
+
91
+ IMPORTANT -- Length is NOT quality:
92
+ - A short, punchy turn that lands perfectly is BETTER than a long, meandering one.
93
+ - Do NOT give higher scores to longer turns.
94
+
95
+ Score 1-9:
96
+ 1-2 = Great -- pulls you in, makes the scene better
97
+ 3-4 = Solid -- does its job well
98
+ 5-6 = Mediocre -- functional but flat or generic
99
+ 7-8 = Weak -- filler, repetitive, or disconnected
100
+ 9 = Broken -- incoherent, off-topic, or format garbage
101
+
102
+ <rationale>[1-2 sentences]</rationale>
103
+ <score>[1-9]</score>"""
104
+
105
+
106
+ # ---------------------------------------------------------------------------
107
+ # Generation prompt for Character B (used in env_response)
108
+ # ---------------------------------------------------------------------------
109
+
110
+ GENERATE_USER_TURN_SYSTEM = """You are playing a CHARACTER in a roleplay conversation.
111
+ You are NOT an AI assistant. You are the other character in this scene.
112
+
113
+ Rules:
114
+ - You are a CHARACTER, not a helpful assistant. Act like a person in this scene.
115
+ - Have your own goals, reactions, emotions, and agency
116
+ - You can disagree, push back, be surprised, get angry, be curious, etc.
117
+ - Advance the scene -- introduce complications, reveal information, change dynamics
118
+ - Use vivid sensory and emotional detail
119
+ - Write natural narrative prose (no bullet points, no structured markers)
120
+ - Keep the turn roughly 50-200 words
121
+ - Write ONLY your character's turn, nothing else"""
122
+
123
+
124
+ # ---------------------------------------------------------------------------
125
+ # Environment
126
+ # ---------------------------------------------------------------------------
127
+
128
+
129
+ class RoleplaySelfPlayEnv(MultiTurnEnv):
130
+ """
131
+ Roleplay self-play environment using MultiTurnEnv for training compatibility.
132
+
133
+ The standard rollout loop generates Character A (assistant) turns with logprobs.
134
+ env_response() generates Character B (user) turns via separate API calls.
135
+ An external judge model scores all turns for the reward signal.
136
+ """
137
+
138
+ def __init__(
139
+ self,
140
+ dataset,
141
+ judge_client: AsyncOpenAI,
142
+ judge_model: str,
143
+ judge_temperature: float = 0.8,
144
+ judge_min_p: float = 0.05,
145
+ selfplay_min_turns: int = 4,
146
+ selfplay_max_turns: int = 10,
147
+ list_penalty_threshold: float = 0.5,
148
+ list_penalty_multiplier: float = 0.1,
149
+ **kwargs,
150
+ ):
151
+ # Placeholder rubric; replaced below with bound method
152
+ super().__init__(max_turns=-1, dataset=dataset, rubric=vf.Rubric(), **kwargs)
153
+
154
+ self.judge_client = judge_client
155
+ self.judge_model = judge_model
156
+ self.judge_temperature = judge_temperature
157
+ self.judge_min_p = judge_min_p
158
+ self.selfplay_min_turns = selfplay_min_turns
159
+ self.selfplay_max_turns = selfplay_max_turns
160
+ self.list_penalty_threshold = list_penalty_threshold
161
+ self.list_penalty_multiplier = list_penalty_multiplier
162
+
163
+ # Replace rubric with bound method that accesses self.judge_client directly
164
+ self.rubric = vf.Rubric(funcs=[self._judge_reward], weights=[1.0])
165
+
166
+ # Set by rollout() for use in env_response()
167
+ self._rollout_client: AsyncOpenAI | None = None
168
+ self._rollout_model: str = ""
169
+
170
+ async def _judge_reward(
171
+ self, completion: Messages, state: State, prompt: Messages, **kwargs,
172
+ ) -> float:
173
+ """Judge all conversation turns and return mean reward."""
174
+ # Reconstruct flat turn list from prompt + completion
175
+ turns: list[dict[str, str]] = []
176
+ for msg in (prompt if isinstance(prompt, list) else []):
177
+ if isinstance(msg, dict) and msg.get("role") not in ("system", None):
178
+ turns.append({"content": msg["content"], "side": "B"})
179
+ for msg in (completion if isinstance(completion, list) else []):
180
+ if isinstance(msg, dict):
181
+ side = "A" if msg.get("role") == "assistant" else "B"
182
+ turns.append({"content": msg["content"], "side": side})
183
+
184
+ if not turns:
185
+ return 0.0
186
+
187
+ # Judge all turns concurrently
188
+ tasks = [
189
+ _judge_single_turn(
190
+ self.judge_client, self.judge_model,
191
+ self.judge_temperature, self.judge_min_p,
192
+ i, t["content"], turns,
193
+ )
194
+ for i, t in enumerate(turns)
195
+ ]
196
+ results = await asyncio.gather(*tasks, return_exceptions=True)
197
+
198
+ rewards = [r if not isinstance(r, Exception) else 0.0 for r in results]
199
+ mean_reward = sum(rewards) / len(rewards) if rewards else 0.0
200
+
201
+ # Global list penalty
202
+ turns_with_lists = sum(1 for t in turns if detect_lists(t["content"])[0])
203
+ list_ratio = turns_with_lists / len(turns) if turns else 0
204
+ if list_ratio > self.list_penalty_threshold:
205
+ mean_reward *= self.list_penalty_multiplier
206
+
207
+ return mean_reward
208
+
209
+ async def setup_state(self, state: State, **kwargs) -> State:
210
+ # Random total conversation turns, then compute assistant turn count
211
+ total = random.randint(self.selfplay_min_turns, self.selfplay_max_turns)
212
+ # state["turn"] counts assistant (model) turns only
213
+ # total includes seed user turn: 1 seed + N assistant + (N-1) user = 2N
214
+ # So assistant turns = total // 2
215
+ state["target_model_turns"] = max(2, total // 2)
216
+ return state
217
+
218
+ async def is_completed(self, messages: Messages, state: State, **kwargs) -> bool:
219
+ if await self.prompt_too_long(state):
220
+ return True
221
+ return state["turn"] >= state.get("target_model_turns", 3)
222
+
223
+ async def env_response(
224
+ self, messages: Messages, state: State, **kwargs
225
+ ) -> tuple[Messages, State]:
226
+ """Generate Character B (user) turn using the same model."""
227
+ client = self._rollout_client
228
+ model = self._rollout_model
229
+
230
+ # Extract system prompt from the original prompt
231
+ system_prompt = ""
232
+ for msg in state["prompt"]:
233
+ if isinstance(msg, dict) and msg.get("role") == "system":
234
+ system_prompt = msg["content"]
235
+ break
236
+
237
+ # Build conversation context for Character B generation
238
+ convo_text = f"[Roleplay System Prompt]: {system_prompt}\n\n"
239
+ turn_idx = 0
240
+ for msg in state["prompt"]:
241
+ if isinstance(msg, dict) and msg.get("role") != "system":
242
+ turn_idx += 1
243
+ convo_text += f"[Turn {turn_idx}]:\n{msg['content']}\n\n"
244
+ for msg in state["completion"]:
245
+ if isinstance(msg, dict):
246
+ turn_idx += 1
247
+ convo_text += f"[Turn {turn_idx}]:\n{msg['content']}\n\n"
248
+
249
+ gen_messages: list[dict[str, str]] = [
250
+ {"role": "system", "content": GENERATE_USER_TURN_SYSTEM},
251
+ {
252
+ "role": "user",
253
+ "content": (
254
+ f"Here is the conversation so far:\n\n{convo_text}\n\n"
255
+ "Write the next turn:"
256
+ ),
257
+ },
258
+ ]
259
+
260
+ try:
261
+ response = await client.chat.completions.create(
262
+ model=model,
263
+ messages=gen_messages, # type: ignore
264
+ temperature=0.9,
265
+ max_tokens=1024,
266
+ )
267
+ turn_text = strip_think_tags(response.choices[0].message.content or "")
268
+ except Exception as e:
269
+ logger.error(f"Failed to generate Character B turn: {e}")
270
+ turn_text = "*continues the scene*"
271
+
272
+ if not turn_text or len(turn_text.strip()) < 5:
273
+ turn_text = "*continues the scene*"
274
+
275
+ return [{"role": "user", "content": turn_text}], state
276
+
277
+ async def rollout(
278
+ self,
279
+ client: AsyncOpenAI,
280
+ model: str,
281
+ prompt: Messages,
282
+ completion: Messages | None = None,
283
+ answer: str = "",
284
+ state: State = {},
285
+ task: str = "default",
286
+ info: Info | None = None,
287
+ example_id: int = 0,
288
+ sampling_args: SamplingArgs | None = None,
289
+ **kwargs,
290
+ ) -> tuple[Messages, State]:
291
+ """Capture client/model for env_response, then delegate to MultiTurnEnv loop."""
292
+ self._rollout_client = client
293
+ self._rollout_model = model
294
+ return await super().rollout(
295
+ client, model, prompt, completion, answer,
296
+ state, task, info, example_id, sampling_args, **kwargs,
297
+ )
298
+
299
+
300
+ # ---------------------------------------------------------------------------
301
+ # Judging helper
302
+ # ---------------------------------------------------------------------------
303
+
304
+
305
+ async def _judge_single_turn(
306
+ judge_client: AsyncOpenAI,
307
+ judge_model: str,
308
+ judge_temperature: float,
309
+ judge_min_p: float,
310
+ turn_index: int,
311
+ turn_text: str,
312
+ all_turns: list[dict[str, str]],
313
+ ) -> float:
314
+ """Judge a single turn and return a reward in [0, 1]."""
315
+ context = ""
316
+ for j in range(turn_index):
317
+ context += f"[Turn {j + 1}]:\n{all_turns[j]['content']}\n\n"
318
+
319
+ prompt_text = TURN_JUDGE_PROMPT.format(
320
+ context=context.strip() if context.strip() else "(This is the opening turn)",
321
+ turn_text=turn_text,
322
+ )
323
+
324
+ try:
325
+ response = await judge_client.chat.completions.create(
326
+ model=judge_model,
327
+ messages=[{"role": "user", "content": prompt_text}], # type: ignore
328
+ temperature=judge_temperature,
329
+ max_tokens=2048,
330
+ extra_body={"min_p": judge_min_p} if judge_min_p else {},
331
+ )
332
+ judge_text = response.choices[0].message.content or ""
333
+
334
+ score_match = re.search(
335
+ r"<score>\s*([1-9])\s*</score>", judge_text, re.IGNORECASE,
336
+ )
337
+ if score_match:
338
+ score = int(score_match.group(1))
339
+ else:
340
+ fallback = re.search(r"<score>\s*([1-9])", judge_text, re.IGNORECASE)
341
+ score = int(fallback.group(1)) if fallback else 9
342
+
343
+ reward = 1.0 - (score / 10.0)
344
+
345
+ # Length bias control
346
+ wc = count_words(turn_text)
347
+ if wc < 15:
348
+ reward *= 0.3
349
+ elif wc < 40:
350
+ reward *= 0.7 + 0.3 * ((wc - 15) / 25)
351
+ elif wc <= 250:
352
+ pass
353
+ elif wc <= 400:
354
+ reward *= 1.0 - 0.4 * ((wc - 250) / 150)
355
+ else:
356
+ reward *= max(0.2, 0.6 - 0.4 * ((wc - 400) / 200))
357
+
358
+ # Structured marker penalty
359
+ has_markers, _ = detect_structured_markers(turn_text)
360
+ if has_markers:
361
+ reward = 0.0
362
+
363
+ return reward
364
+ except Exception as e:
365
+ logger.error(f"Judge failed for turn {turn_index}: {e}")
366
+ return 0.0
367
+
368
+
369
+ # ---------------------------------------------------------------------------
370
+ # Entry point
371
+ # ---------------------------------------------------------------------------
372
+
373
+
374
+ def load_environment(
375
+ dataset_name: str = "Delta-Vector/Hydrus-UnsafeRLHF",
376
+ dataset_split: str = "train",
377
+ judge_model: str = "default",
378
+ judge_base_url: str = "https://reduces-bone-benefit-endangered.trycloudflare.com/v1",
379
+ judge_temperature: float = 0.8,
380
+ judge_min_p: float = 0.05,
381
+ judge_timeout: float = 1200.0,
382
+ max_concurrent_scoring: int = 32,
383
+ selfplay_min_turns: int = 4,
384
+ selfplay_max_turns: int = 10,
385
+ list_penalty_threshold: float = 0.5,
386
+ list_penalty_multiplier: float = 0.1,
387
+ **kwargs,
388
+ ) -> RoleplaySelfPlayEnv:
389
+ """Load the roleplay self-play environment."""
390
+
391
+ dataset = load_dataset(dataset_name, split=dataset_split)
392
+
393
+ def transform_example(example: dict, idx: int) -> dict:
394
+ system_prompt = ""
395
+ first_user_msg = ""
396
+
397
+ if "conversations" in example:
398
+ for c in example["conversations"]:
399
+ role = c.get("from", c.get("role", ""))
400
+ content = c.get("value", c.get("content", ""))
401
+ if role == "system":
402
+ system_prompt = content
403
+ elif role in ("human", "user") and not first_user_msg:
404
+ first_user_msg = content
405
+ break
406
+ elif "prompt" in example:
407
+ prompt_content = example["prompt"]
408
+ if isinstance(prompt_content, str):
409
+ first_user_msg = prompt_content
410
+ elif isinstance(prompt_content, list):
411
+ for m in prompt_content:
412
+ if m.get("role") == "system":
413
+ system_prompt = m.get("content", "")
414
+ elif m.get("role") == "user" and not first_user_msg:
415
+ first_user_msg = m.get("content", "")
416
+ else:
417
+ raise ValueError("Dataset must have 'prompt' or 'conversations' column")
418
+
419
+ if not system_prompt:
420
+ system_prompt = "You are a creative and engaging roleplay partner."
421
+ if not first_user_msg:
422
+ first_user_msg = "*A stranger approaches.*"
423
+
424
+ messages: list[dict[str, str]] = [
425
+ {"role": "system", "content": system_prompt},
426
+ {"role": "user", "content": first_user_msg},
427
+ ]
428
+
429
+ return {
430
+ "prompt": messages,
431
+ "info": {
432
+ "original_system_prompt": system_prompt,
433
+ "seed_user_turn": first_user_msg,
434
+ },
435
+ }
436
+
437
+ columns_to_remove = list(dataset.column_names)
438
+ dataset = dataset.map(
439
+ transform_example, with_indices=True, remove_columns=columns_to_remove,
440
+ )
441
+
442
+ http_client = httpx.AsyncClient(
443
+ limits=httpx.Limits(
444
+ max_connections=max_concurrent_scoring,
445
+ max_keepalive_connections=max_concurrent_scoring,
446
+ ),
447
+ timeout=judge_timeout,
448
+ )
449
+ judge_client = AsyncOpenAI(
450
+ base_url=judge_base_url,
451
+ api_key="dummy-key",
452
+ http_client=http_client,
453
+ )
454
+
455
+ return RoleplaySelfPlayEnv(
456
+ dataset=dataset,
457
+ judge_client=judge_client,
458
+ judge_model=judge_model,
459
+ judge_temperature=judge_temperature,
460
+ judge_min_p=judge_min_p,
461
+ selfplay_min_turns=selfplay_min_turns,
462
+ selfplay_max_turns=selfplay_max_turns,
463
+ list_penalty_threshold=list_penalty_threshold,
464
+ list_penalty_multiplier=list_penalty_multiplier,
465
+ **kwargs,
466
+ )