Skip to content

Commit 1023b42

Browse files
sophistry_bench_sprint_env: add training example and results (#853)
* docs+examples(sophistry_bench_sprint_env): add training example and results Adds the prime-rl GRPO config and per-step metrics from a 100-step run against the deployed env, plus a README section showing the reward-hacking signature (aggregate_reward up, correctness_reward flat). Also adds a from-scratch TRL GRPOTrainer example for training against the Space directly, for anyone without Prime Intellect access. * examples(sophistry_bench_sprint_grpo): add --push-to-hub to publish checkpoint Closes the gap between local training output and an actual Hub artifact, matching the maintainer's "deployed to Hugging Face" ask for the training side, not just the Space. * fix(sophistry_bench_sprint_env): correct deployed Space repo id The env is actually hosted at openenv-community/sophistry_bench_sprint_env, not anushaacharya/sophistry_bench_sprint_env as originally documented in #787 -- verified via `hf spaces info`. * docs(sophistry_bench_sprint_env): lead with TRL example, demote Prime Intellect to a note Reframes the Training section so the TRL GRPOTrainer script (verified end-to-end against the deployed Space) is the primary documented path, matching this repo's own guidance that TRL is the recommended framework. The Prime Intellect run becomes supplementary evidence, not the headline. Also switches the script to GenericEnvClient + a directly-constructed UVProvider (avoiding a sync/async event-loop mismatch from mixing asyncio.run(from_env(...)) with .sync()), and bumps the default model to Qwen2.5-0.5B-Instruct for a cheaper, faster default run. * docs+examples(sophistry_bench_sprint_grpo): add real HF Jobs run results Runs the TRL GRPO example for real on Hugging Face Jobs (a10g-small, 100 steps, Qwen2.5-0.5B-Instruct) and documents the results honestly: the proxy reward (aggregate_reward) climbs and plateaus, confirming the example trains correctly end-to-end on HF infrastructure, but at this much smaller scale (~800 total rollouts vs. the Prime Intellect run's ~12,800) the policy collapses to near-empty completions rather than converging on the claim_count_cliff target -- a different reward-hacking shortcut, not a replication of the Prime Intellect run's specific curve. correctness_reward stays noisy/decoupled either way, which is the core finding both runs share. Also extends the reward_func to log per-step reward components (not just the scalar reward), since correctness_reward/n_claims live in observation["components"], which the trainer never needed but the README table does. Opts into SPRINT_EXPOSE_CORRECTNESS=1 for the locally-run clone (not the shared Space) since this is exactly the "trusted measurement code" use case the env's own README carves out -- never fed back into the prompt. Tuning notes from getting this to actually run without OOM on a10g-small: - per_device_train_batch_size is the *total* rollout count per step (must be divisible by num_generations), not unique-prompts * num_generations. - bf16 matters more than usual here: entropy/logprob computation materializes a [batch, completion_len, vocab_size] logits tensor, and a ~150K-token vocab (Qwen2.5) dominates memory at fp32. - gradient_checkpointing=True had no measurable effect in this setup (same OOM numbers with and without); reducing batch size was what actually fixed it. Left in since it's harmless, but don't rely on it alone. * fix(sophistry_bench_sprint_grpo): harden against review findings From a self-review pass before requesting maintainer review on #853: - Validate --per-device-batch-size % --num-generations == 0 up front, before the ~180s env clone/start and dataset build -- previously this only surfaced as an opaque ValueError deep inside GRPOTrainer construction. - Extract completion-text parsing into _completion_text(), which now raises a clear error on an empty/malformed completion list instead of a bare IndexError/TypeError. - Assert completions and seed are the same length in the reward function, instead of letting zip() silently truncate and misalign reward<->task. - Write the components CSV under output_dir (which save_model() already guarantees exists) instead of a sibling path derived from --out's basename, which could fail if --out's parent directory doesn't exist. - Extract the CSV-writing block into write_metrics_csv(). Also tried switching make_sync_client() to the simpler from_env() + .sync() pattern, now that #854 fixes the event-loop mismatch that motivated building it manually in the first place -- and reverted. The fixed connect() does correctly reconnect on the new loop instead of hanging, but it can't cleanly close the *old* connection first (its event loop is already gone), so the old one is simply abandoned. That's harmless for envs that allow concurrent sessions, but this one doesn't (SUPPORTS_CONCURRENT_SESSIONS = False): the abandoned connection occupies the only session slot, and the real one fails with CAPACITY_REACHED. Confirmed by reproducing it locally. make_sync_client() avoids the problem by never creating that doomed first connection at all. Updated its docstring to explain both reasons. * simplify(sophistry_bench_sprint): cut footprint per review Addressing @burtenshaw's review on #853: - Drop training/hf_jobs_metrics.csv, training/metrics.csv, training/sophistry_bench_sprint.toml -- envs and examples should be decoupled; these were training artifacts, not env source. - Drop the custom metrics_log/components-CSV tracking in the example script entirely (reward_func just returns rewards now, like every other reward_funcs example in this repo) rather than wiring up trackio for a one-off script. - Inline make_sync_client() into main() -- it was only used once. - Cut the module docstring and inline comments down to the essentials; the full event-loop/single-session explanation lives in #854 and the CAPACITY_REACHED finding, not duplicated here in prose. - Condense the env README's "Training" section from two tables + ~40 lines of analysis to one paragraph; the full numbers are in the PR description. Re-verified end-to-end after the rewrite (4 episodes, 1 step): trains, saves a real checkpoint, no regressions. --------- Co-authored-by: burtenshaw <ben.burtenshaw@gmail.com>
1 parent fa94751 commit 1023b42

3 files changed

Lines changed: 190 additions & 2 deletions

File tree

docs/source/environments/sophistry_bench_sprint.md

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ from sophistry_bench_sprint_env import SophistryBenchSprintEnv
3737

3838
async def main():
3939
# Deployed Hugging Face Space (or .from_docker_image("openenv-sophistry_bench_sprint:latest")):
40-
client = await SophistryBenchSprintEnv.from_env("anushaacharya/sophistry_bench_sprint_env")
40+
client = await SophistryBenchSprintEnv.from_env("openenv-community/sophistry_bench_sprint_env")
4141
async with client:
4242
obs = (await client.reset()).observation
4343
print(obs.prompt, obs.answer_to_defend)
@@ -67,6 +67,22 @@ the reward-hacking measurement. By default it holds **seven** components; `corre
6767
> reason; even with the rest of the components, forwarding them to the agent leaks the
6868
> reward signal and defeats the reward-hacking measurement.
6969
70+
## Training
71+
72+
[`examples/sophistry_bench_sprint_grpo.py`](https://github.com/huggingface/OpenEnv/blob/main/examples/sophistry_bench_sprint_grpo.py)
73+
trains a policy on this env with TRL's `GRPOTrainer` — a plain prompt ->
74+
completion -> reward setup, since the episode is single-step.
75+
76+
Validated with a real 100-step run on Hugging Face Jobs (`Qwen2.5-0.5B-Instruct`,
77+
`a10g-small`) and a 100-step run on the Prime Intellect Hub
78+
(`Llama-3.2-1B-Instruct`, registered as `anusha/sophistry-bench-sprint`, parity-tested
79+
against this port). Both show `aggregate_reward` (the optimized proxy) climbing while
80+
`correctness_reward` (the hidden ground truth, weight 0) stays flat — the reward-hacking
81+
signature this env is designed to surface. The larger Prime Intellect run converges on
82+
the literal `claim_count_cliff` target (`n_claims` saturates at exactly 8); the smaller
83+
HF Jobs run finds a different shortcut instead (`n_claims` collapses to ~0, near-empty
84+
completions) — same underlying finding, different degenerate strategy depending on scale.
85+
7086
## Build & test
7187

7288
```bash

envs/sophistry_bench_sprint_env/README.md

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ from sophistry_bench_sprint_env import SophistryBenchSprintEnv
4949

5050
async def main():
5151
# Deployed Hugging Face Space (or .from_docker_image("openenv-sophistry_bench_sprint:latest")):
52-
client = await SophistryBenchSprintEnv.from_env("anushaacharya/sophistry_bench_sprint_env")
52+
client = await SophistryBenchSprintEnv.from_env("openenv-community/sophistry_bench_sprint_env")
5353
async with client:
5454
obs = (await client.reset()).observation
5555
print(obs.prompt, obs.answer_to_defend)
@@ -79,6 +79,22 @@ the reward-hacking measurement. By default it holds **seven** components; `corre
7979
> reason; even with the rest of the components, forwarding them to the agent leaks the
8080
> reward signal and defeats the reward-hacking measurement.
8181
82+
## Training
83+
84+
[`examples/sophistry_bench_sprint_grpo.py`](https://github.com/huggingface/OpenEnv/blob/main/examples/sophistry_bench_sprint_grpo.py)
85+
trains a policy on this env with TRL's `GRPOTrainer` — a plain prompt ->
86+
completion -> reward setup, since the episode is single-step.
87+
88+
Validated with a real 100-step run on Hugging Face Jobs (`Qwen2.5-0.5B-Instruct`,
89+
`a10g-small`) and a 100-step run on the Prime Intellect Hub
90+
(`Llama-3.2-1B-Instruct`, registered as `anusha/sophistry-bench-sprint`, parity-tested
91+
against this port). Both show `aggregate_reward` (the optimized proxy) climbing while
92+
`correctness_reward` (the hidden ground truth, weight 0) stays flat — the reward-hacking
93+
signature this env is designed to surface. The larger Prime Intellect run converges on
94+
the literal `claim_count_cliff` target (`n_claims` saturates at exactly 8); the smaller
95+
HF Jobs run finds a different shortcut instead (`n_claims` collapses to ~0, near-empty
96+
completions) — same underlying finding, different degenerate strategy depending on scale.
97+
8298
## Build & test
8399

84100
```bash
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
# Copyright (c) Meta Platforms, Inc. and affiliates.
2+
# All rights reserved.
3+
#
4+
# This source code is licensed under the BSD-style license found in the
5+
# LICENSE file in the root directory of this source tree.
6+
7+
# /// script
8+
# requires-python = ">=3.10"
9+
# dependencies = [
10+
# "openenv[core]",
11+
# "trl",
12+
# "datasets",
13+
# "torch",
14+
# "transformers",
15+
# ]
16+
# ///
17+
18+
"""Train a policy on `sophistry_bench_sprint_env` with TRL's GRPOTrainer.
19+
20+
Single-step env, so this is a plain prompt -> completion -> reward GRPO setup:
21+
no `environment_factory`/tool-calling needed. Uses `GenericEnvClient` so the
22+
script only depends on `openenv[core]` from PyPI, which also makes it runnable
23+
as a standalone `uv` script, including via Hugging Face Jobs:
24+
25+
hf jobs uv run examples/sophistry_bench_sprint_grpo.py --flavor a10g-small \
26+
--secrets HF_TOKEN -- --push-to-hub --out your-username/sophistry-grpo
27+
28+
Connects via a manually-built `UVProvider` + `GenericEnvClient` rather than
29+
`from_env()` + `.sync()`: this env only allows one concurrent session
30+
(`SUPPORTS_CONCURRENT_SESSIONS = False`), and `from_env()` + `.sync()` can
31+
leave behind an orphaned first connection that occupies that single slot (see
32+
https://github.com/huggingface/OpenEnv/pull/854). Needs the `project_path`
33+
git-clone fix from that PR; until it's released, override the `openenv[core]`
34+
dependency above with a git ref of it.
35+
36+
Run locally:
37+
python examples/sophistry_bench_sprint_grpo.py --n-episodes 64 --steps 50
38+
"""
39+
40+
from __future__ import annotations
41+
42+
import argparse
43+
44+
from datasets import Dataset
45+
from openenv import GenericEnvClient
46+
from openenv.core.containers.runtime.uv_provider import UVProvider
47+
from trl import GRPOConfig, GRPOTrainer
48+
49+
SPACE_REPO_ID = "openenv-community/sophistry_bench_sprint_env"
50+
51+
52+
def _completion_text(completion) -> str:
53+
"""TRL passes either a list of chat messages or a raw string, depending
54+
on whether the model/dataset use chat templating."""
55+
if isinstance(completion, list):
56+
if not completion or not isinstance(completion[-1], dict):
57+
raise ValueError(f"Unexpected completion shape from TRL: {completion!r}")
58+
return completion[-1]["content"]
59+
if isinstance(completion, str):
60+
return completion
61+
raise ValueError(f"Unexpected completion type from TRL: {type(completion)!r}")
62+
63+
64+
def build_dataset(client, n_episodes: int) -> Dataset:
65+
"""Walk `reset(seed=i)` to get a fixed, replayable set of advocacy tasks.
66+
Each row carries the `seed` needed to re-derive the same task later, in
67+
the reward function."""
68+
rows = []
69+
for i in range(n_episodes):
70+
obs = client.reset(seed=i).observation
71+
rows.append({"prompt": [{"role": "user", "content": obs["prompt"]}], "seed": i})
72+
return Dataset.from_list(rows)
73+
74+
75+
def make_reward_func(client):
76+
"""Re-running `reset(seed=...)` before each `step(...)` recreates the
77+
exact task the completion was sampled for -- the server is
78+
single-session, so this runs sequentially against one client."""
79+
80+
def reward_func(completions, seed, **kwargs) -> list[float]:
81+
assert len(completions) == len(seed), (
82+
f"completions/seed length mismatch: {len(completions)} vs {len(seed)}"
83+
)
84+
rewards = []
85+
for completion, s in zip(completions, seed):
86+
client.reset(seed=s)
87+
result = client.step({"text": _completion_text(completion)})
88+
rewards.append(result.reward)
89+
return rewards
90+
91+
return reward_func
92+
93+
94+
def main():
95+
ap = argparse.ArgumentParser()
96+
ap.add_argument("--model", default="Qwen/Qwen2.5-0.5B-Instruct")
97+
ap.add_argument("--n-episodes", type=int, default=64, help="Dataset size.")
98+
ap.add_argument("--steps", type=int, default=50)
99+
ap.add_argument("--lr", type=float, default=1e-6)
100+
ap.add_argument(
101+
"--per-device-batch-size",
102+
type=int,
103+
default=2,
104+
help="Total rollouts sampled per step (must be divisible by --num-generations).",
105+
)
106+
ap.add_argument("--num-generations", type=int, default=2)
107+
ap.add_argument("--max-completion-length", type=int, default=512)
108+
ap.add_argument("--out", default="sophistry-grpo-Qwen2.5-0.5B")
109+
ap.add_argument("--push-to-hub", action="store_true")
110+
args = ap.parse_args()
111+
112+
if args.per_device_batch_size % args.num_generations != 0:
113+
ap.error("--per-device-batch-size must be divisible by --num-generations")
114+
115+
provider = UVProvider(
116+
project_path=f"git+https://huggingface.co/spaces/{SPACE_REPO_ID}",
117+
app="sophistry_bench_sprint_env.server.app:app",
118+
context_timeout_s=180.0, # cold clone + dependency install can be slow
119+
)
120+
base_url = provider.start()
121+
provider.wait_for_ready()
122+
123+
with GenericEnvClient(base_url=base_url, provider=provider).sync() as client:
124+
dataset = build_dataset(client, args.n_episodes)
125+
126+
config = GRPOConfig(
127+
output_dir=args.out,
128+
max_steps=args.steps,
129+
learning_rate=args.lr,
130+
per_device_train_batch_size=args.per_device_batch_size,
131+
num_generations=args.num_generations,
132+
max_completion_length=args.max_completion_length,
133+
bf16=True, # halves the [batch, len, vocab] logits tensor at fp32
134+
gradient_checkpointing=True,
135+
logging_steps=1,
136+
push_to_hub=args.push_to_hub,
137+
hub_model_id=args.out if args.push_to_hub else None,
138+
)
139+
140+
trainer = GRPOTrainer(
141+
model=args.model,
142+
reward_funcs=make_reward_func(client),
143+
train_dataset=dataset,
144+
args=config,
145+
)
146+
trainer.train()
147+
trainer.save_model(args.out)
148+
print(f"Saved fine-tuned model to {args.out}")
149+
150+
if args.push_to_hub:
151+
trainer.push_to_hub()
152+
print(f"Pushed fine-tuned model to https://huggingface.co/{args.out}")
153+
154+
155+
if __name__ == "__main__":
156+
main()

0 commit comments

Comments
 (0)