Spaces:
Sleeping
Sleeping
Enhance training logic: Adjust reward shaping and implement lookahead bonus for improved agent performance
Browse files- CLAUDE.md +10 -0
- app/config.py +17 -3
- app/core/environment.py +2 -0
- app/core/graders.py +38 -2
- spaces/train/Dockerfile +2 -1
- spaces/train/app.py +71 -108
- training/train_grpo.py +166 -8
CLAUDE.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
| 1 |
# CLAUDE.md
|
| 2 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
## Project Overview
|
| 4 |
**Stocker** β multi-agent council RL environment for stock trading on
|
| 5 |
OpenEnv. Seven specialist LLM analysts vote each step; a moderator LLM
|
|
|
|
| 1 |
# CLAUDE.md
|
| 2 |
|
| 3 |
+
## Source-control rules (non-negotiable)
|
| 4 |
+
|
| 5 |
+
- **Never `git commit` or `git push` without an explicit, in-message instruction
|
| 6 |
+
from the user.** "Iterate the Dockerfile until green," "fix the bug," etc. do
|
| 7 |
+
NOT authorize commits. Make the edits, run tests, and stop.
|
| 8 |
+
- Show the user `git status --short` and `git diff --stat` so they can review
|
| 9 |
+
before committing manually.
|
| 10 |
+
- Auto mode does NOT relax this rule β code changes are fine, but writing to
|
| 11 |
+
the git index or pushing to remotes always requires explicit user say-so.
|
| 12 |
+
|
| 13 |
## Project Overview
|
| 14 |
**Stocker** β multi-agent council RL environment for stock trading on
|
| 15 |
OpenEnv. Seven specialist LLM analysts vote each step; a moderator LLM
|
app/config.py
CHANGED
|
@@ -8,10 +8,24 @@ class Settings(BaseSettings):
|
|
| 8 |
allow_origins: list[str] = ["*"]
|
| 9 |
port: int = 7860
|
| 10 |
|
|
|
|
|
|
|
|
|
|
| 11 |
transaction_cost_rate: float = 0.001
|
| 12 |
-
annual_inflation_rate: float = 0.05
|
| 13 |
-
reward_weight_performance: float = 0.7
|
| 14 |
-
reward_weight_inflation: float = 0.3
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
model_config = {"env_prefix": "STOCKER_", "case_sensitive": False}
|
| 17 |
|
|
|
|
| 8 |
allow_origins: list[str] = ["*"]
|
| 9 |
port: int = 7860
|
| 10 |
|
| 11 |
+
# Reward shaping. Bumped weights + inflation rate to push the moderator
|
| 12 |
+
# toward actually trading instead of converging to "always hold" β see
|
| 13 |
+
# docstring in app/core/graders.py for the full design.
|
| 14 |
transaction_cost_rate: float = 0.001
|
| 15 |
+
annual_inflation_rate: float = 0.10 # was 0.05 β sharper "cash decays" signal
|
| 16 |
+
reward_weight_performance: float = 0.8 # was 0.7
|
| 17 |
+
reward_weight_inflation: float = 0.5 # was 0.3 β aggressively penalize sitting in cash
|
| 18 |
+
|
| 19 |
+
# Lookahead bonus: at training step N, reward proportional to the price
|
| 20 |
+
# change between step N and N+lookahead_steps, times the agent's net
|
| 21 |
+
# exposure delta. Gives the single-step reward a directional signal.
|
| 22 |
+
lookahead_steps: int = 5
|
| 23 |
+
lookahead_weight: float = 0.5 # 0 disables the term entirely
|
| 24 |
+
|
| 25 |
+
# Multi-step rollout horizon used by training/train_grpo.reward_for_completion.
|
| 26 |
+
# After applying the model's action, the env is rolled forward this many
|
| 27 |
+
# steps with `hold` and the cumulative reward is what the agent sees.
|
| 28 |
+
rollout_horizon: int = 5
|
| 29 |
|
| 30 |
model_config = {"env_prefix": "STOCKER_", "case_sensitive": False}
|
| 31 |
|
app/core/environment.py
CHANGED
|
@@ -99,6 +99,8 @@ class StockerEnv:
|
|
| 99 |
ideal_pnl_pct_series=self._task.get("ideal_pnl_pct_series", []),
|
| 100 |
ideal_pnl_pct_total=float(self._task.get("ideal_pnl_pct_total", 0.0)),
|
| 101 |
settings=settings,
|
|
|
|
|
|
|
| 102 |
)
|
| 103 |
reward = result.score
|
| 104 |
|
|
|
|
| 99 |
ideal_pnl_pct_series=self._task.get("ideal_pnl_pct_series", []),
|
| 100 |
ideal_pnl_pct_total=float(self._task.get("ideal_pnl_pct_total", 0.0)),
|
| 101 |
settings=settings,
|
| 102 |
+
prices=self._prices,
|
| 103 |
+
position_after=self._position,
|
| 104 |
)
|
| 105 |
reward = result.score
|
| 106 |
|
app/core/graders.py
CHANGED
|
@@ -39,6 +39,8 @@ def compute_step_reward(
|
|
| 39 |
ideal_pnl_pct_series: list[float],
|
| 40 |
ideal_pnl_pct_total: float,
|
| 41 |
settings: Settings,
|
|
|
|
|
|
|
| 42 |
) -> RewardResult:
|
| 43 |
breakdown: dict[str, float] = {}
|
| 44 |
|
|
@@ -75,8 +77,10 @@ def compute_step_reward(
|
|
| 75 |
# Close to ideal β high reward decaying linearly toward 0.
|
| 76 |
performance_factor = 1.0 - gap / scale
|
| 77 |
else:
|
| 78 |
-
# Far behind ideal β punishment
|
| 79 |
-
|
|
|
|
|
|
|
| 80 |
|
| 81 |
breakdown["ideal_pnl_pct_at_step"] = round(ideal_at_step, 6)
|
| 82 |
breakdown["gap"] = round(gap, 6)
|
|
@@ -92,6 +96,38 @@ def compute_step_reward(
|
|
| 92 |
|
| 93 |
score = weighted_perf + weighted_inf
|
| 94 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
if invalid:
|
| 96 |
score -= INVALID_ACTION_PENALTY
|
| 97 |
breakdown["invalid_action_penalty"] = -INVALID_ACTION_PENALTY
|
|
|
|
| 39 |
ideal_pnl_pct_series: list[float],
|
| 40 |
ideal_pnl_pct_total: float,
|
| 41 |
settings: Settings,
|
| 42 |
+
prices: list[float] | None = None,
|
| 43 |
+
position_after: int = 0,
|
| 44 |
) -> RewardResult:
|
| 45 |
breakdown: dict[str, float] = {}
|
| 46 |
|
|
|
|
| 77 |
# Close to ideal β high reward decaying linearly toward 0.
|
| 78 |
performance_factor = 1.0 - gap / scale
|
| 79 |
else:
|
| 80 |
+
# Far behind ideal β sharper ramp into punishment so the agent
|
| 81 |
+
# actually feels under-performance (was -min(1, (gap-scale)/scale),
|
| 82 |
+
# now -min(1, 2*(gap-scale)/scale) β reaches -1.0 at half the gap).
|
| 83 |
+
performance_factor = -min(1.0, 2.0 * (gap - scale) / scale)
|
| 84 |
|
| 85 |
breakdown["ideal_pnl_pct_at_step"] = round(ideal_at_step, 6)
|
| 86 |
breakdown["gap"] = round(gap, 6)
|
|
|
|
| 96 |
|
| 97 |
score = weighted_perf + weighted_inf
|
| 98 |
|
| 99 |
+
# ββ Lookahead bonus ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 100 |
+
# Single-step reward is nearly invariant to action when the trade is at
|
| 101 |
+
# fair market value. The lookahead term gives a directional signal:
|
| 102 |
+
# "would buying here have been profitable over the next K days?"
|
| 103 |
+
# Active only when prices are passed (training path).
|
| 104 |
+
lookahead_bonus = 0.0
|
| 105 |
+
K = getattr(settings, "lookahead_steps", 0)
|
| 106 |
+
W = getattr(settings, "lookahead_weight", 0.0)
|
| 107 |
+
if prices and K > 0 and W > 0.0 and step_index + K < len(prices):
|
| 108 |
+
cur_price = prices[step_index]
|
| 109 |
+
future_price = prices[step_index + K]
|
| 110 |
+
if cur_price > 0:
|
| 111 |
+
future_pct = (future_price - cur_price) / cur_price
|
| 112 |
+
# Net exposure delta: position the trade ADDED. buy(q) = +q,
|
| 113 |
+
# sell(q) = -q (relative to starting). Hold = 0 β no bonus.
|
| 114 |
+
if action.side == "buy":
|
| 115 |
+
exposure = action.quantity
|
| 116 |
+
elif action.side == "sell":
|
| 117 |
+
exposure = -action.quantity
|
| 118 |
+
else:
|
| 119 |
+
exposure = 0
|
| 120 |
+
# Normalize by starting_cash / cur_price to keep magnitude sane
|
| 121 |
+
# across tickers / cash sizes.
|
| 122 |
+
shares_per_unit = max(starting_cash / max(cur_price, 1e-9), 1.0)
|
| 123 |
+
lookahead_bonus = W * future_pct * (exposure / shares_per_unit)
|
| 124 |
+
# Clip the lookahead bonus to [-1, 1] so it doesn't dominate.
|
| 125 |
+
lookahead_bonus = max(-1.0, min(1.0, lookahead_bonus))
|
| 126 |
+
breakdown["lookahead_pct"] = round(future_pct, 6)
|
| 127 |
+
breakdown["lookahead_exposure"] = exposure
|
| 128 |
+
breakdown["lookahead_bonus"] = round(lookahead_bonus, 6)
|
| 129 |
+
score += lookahead_bonus
|
| 130 |
+
|
| 131 |
if invalid:
|
| 132 |
score -= INVALID_ACTION_PENALTY
|
| 133 |
breakdown["invalid_action_penalty"] = -INVALID_ACTION_PENALTY
|
spaces/train/Dockerfile
CHANGED
|
@@ -57,7 +57,8 @@ RUN --mount=type=cache,target=/root/.cache/uv \
|
|
| 57 |
"torch==2.10.0" "torchvision==0.25.0" "torchaudio==2.10.0" \
|
| 58 |
"transformers==5.6.2" "peft==0.19.1" "trl==1.2.0" \
|
| 59 |
"accelerate==1.13.0" "bitsandbytes==0.49.2" "datasets==4.8.4" \
|
| 60 |
-
"tensorboard==2.20.0" "torchao==0.17.0" "huggingface_hub==1.12.0"
|
|
|
|
| 61 |
|
| 62 |
# βββ Layer 4: build-time smoke test ββββββββββββββββββββββββββββββββββββ
|
| 63 |
# Fails the build immediately if any pin combination is broken.
|
|
|
|
| 57 |
"torch==2.10.0" "torchvision==0.25.0" "torchaudio==2.10.0" \
|
| 58 |
"transformers==5.6.2" "peft==0.19.1" "trl==1.2.0" \
|
| 59 |
"accelerate==1.13.0" "bitsandbytes==0.49.2" "datasets==4.8.4" \
|
| 60 |
+
"tensorboard==2.20.0" "torchao==0.17.0" "huggingface_hub==1.12.0" \
|
| 61 |
+
"gradio==5.50.0"
|
| 62 |
|
| 63 |
# βββ Layer 4: build-time smoke test ββββββββββββββββββββββββββββββββββββ
|
| 64 |
# Fails the build immediately if any pin combination is broken.
|
spaces/train/app.py
CHANGED
|
@@ -1,14 +1,14 @@
|
|
| 1 |
-
"""Training Space UI β
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
| 5 |
|
| 6 |
Required Space secrets (Settings β Variables and secrets):
|
| 7 |
HF_TOKEN β write-access token for uploading results
|
| 8 |
API_BASE_URL β https://<endpoint>.endpoints.huggingface.cloud/v1
|
| 9 |
MODEL_NAME β ggml-org/gemma-4-26B-A4B-it-GGUF
|
| 10 |
-
RESULTS_REPO β Hydr473/stocker-results (defaults
|
| 11 |
-
USE_MOCK_SPECIALISTS=1 β fall back to MockLLMClient (skip endpoint
|
| 12 |
"""
|
| 13 |
from __future__ import annotations
|
| 14 |
|
|
@@ -19,9 +19,7 @@ import sys
|
|
| 19 |
import threading
|
| 20 |
from pathlib import Path
|
| 21 |
|
| 22 |
-
import
|
| 23 |
-
from fastapi import FastAPI
|
| 24 |
-
from fastapi.responses import FileResponse, HTMLResponse, PlainTextResponse
|
| 25 |
from huggingface_hub import HfApi
|
| 26 |
|
| 27 |
WORKDIR = Path(__file__).resolve().parent.parent.parent
|
|
@@ -40,6 +38,7 @@ def _stream_proc(cmd: list[str], env_extra: dict | None = None) -> None:
|
|
| 40 |
text=True,
|
| 41 |
cwd=str(WORKDIR),
|
| 42 |
env=env,
|
|
|
|
| 43 |
)
|
| 44 |
with open(LOG_PATH, "a") as lf:
|
| 45 |
assert proc.stdout
|
|
@@ -85,7 +84,8 @@ def _run_pipeline() -> None:
|
|
| 85 |
"--batch-size", "4",
|
| 86 |
"--grad-accum", "1",
|
| 87 |
"--lora-rank", "16",
|
| 88 |
-
"--lr", "5e-6"
|
|
|
|
| 89 |
env_extra=endpoint_env,
|
| 90 |
)
|
| 91 |
|
|
@@ -160,104 +160,25 @@ PHASE_LABELS = {
|
|
| 160 |
}
|
| 161 |
|
| 162 |
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
<meta name="viewport" content="width=device-width, initial-scale=1">
|
| 169 |
-
<style>
|
| 170 |
-
body { background: #0f172a; color: #e2e8f0; font-family: -apple-system, BlinkMacSystemFont, sans-serif; padding: 24px; max-width: 1100px; margin: 0 auto; }
|
| 171 |
-
h1 { color: #38bdf8; }
|
| 172 |
-
h2 { color: #38bdf8; border-top: 1px solid #334155; padding-top: 12px; margin-top: 24px; }
|
| 173 |
-
.status { background: #1e293b; padding: 14px 18px; border-radius: 8px; margin: 12px 0; font-size: 1.1em; border: 1px solid #334155; }
|
| 174 |
-
.btn { background: #2563eb; color: white; padding: 12px 28px; border: none; border-radius: 8px; cursor: pointer; font-size: 16px; font-weight: 600; }
|
| 175 |
-
.btn:hover { background: #1d4ed8; }
|
| 176 |
-
.btn:disabled { background: #475569; cursor: not-allowed; }
|
| 177 |
-
.logs { background: #020617; padding: 14px; border-radius: 8px; font-family: 'SF Mono', Menlo, monospace; font-size: 12px; max-height: 500px; overflow-y: auto; white-space: pre-wrap; border: 1px solid #1e293b; }
|
| 178 |
-
.plots img { max-width: 48%; border-radius: 8px; margin: 8px 1%; border: 1px solid #334155; }
|
| 179 |
-
.meta { color: #94a3b8; font-size: 0.9em; margin: 8px 0; }
|
| 180 |
-
</style>
|
| 181 |
-
</head>
|
| 182 |
-
<body>
|
| 183 |
-
<h1>β‘ Stocker β GRPO Training</h1>
|
| 184 |
-
<p class="meta">L4 GPU Β· pre-cache 26B specialist votes β baseline eval β GRPO E4B β post eval β compile β upload</p>
|
| 185 |
-
|
| 186 |
-
<div class="status" id="status">Loading statusβ¦</div>
|
| 187 |
-
<button class="btn" id="launchBtn" onclick="launch()">π Launch Pipeline</button>
|
| 188 |
-
|
| 189 |
-
<h2>Live logs</h2>
|
| 190 |
-
<div class="logs" id="logs">No logs yet.</div>
|
| 191 |
-
|
| 192 |
-
<h2>Plots</h2>
|
| 193 |
-
<div class="plots" id="plots">Plots appear here once training completes.</div>
|
| 194 |
-
|
| 195 |
-
<script>
|
| 196 |
-
async function launch() {
|
| 197 |
-
const r = await fetch('/launch', { method: 'POST' });
|
| 198 |
-
const d = await r.json();
|
| 199 |
-
if (d.status === 'started') document.getElementById('launchBtn').disabled = true;
|
| 200 |
-
}
|
| 201 |
-
async function poll() {
|
| 202 |
-
try {
|
| 203 |
-
const s = await (await fetch('/status')).json();
|
| 204 |
-
document.getElementById('status').textContent = s.label;
|
| 205 |
-
if (s.phase !== 'idle' && s.phase !== 'done') {
|
| 206 |
-
document.getElementById('launchBtn').disabled = true;
|
| 207 |
-
} else {
|
| 208 |
-
document.getElementById('launchBtn').disabled = false;
|
| 209 |
-
}
|
| 210 |
-
const txt = await (await fetch('/logs')).text();
|
| 211 |
-
const logsEl = document.getElementById('logs');
|
| 212 |
-
const wasNearBottom = logsEl.scrollHeight - logsEl.scrollTop - logsEl.clientHeight < 50;
|
| 213 |
-
logsEl.textContent = txt.slice(-12000) || 'No logs yet.';
|
| 214 |
-
if (wasNearBottom) logsEl.scrollTop = logsEl.scrollHeight;
|
| 215 |
-
const ps = await (await fetch('/plots')).json();
|
| 216 |
-
if (ps.length) {
|
| 217 |
-
document.getElementById('plots').innerHTML = ps.map(u => `<img src="${u}" />`).join('');
|
| 218 |
-
}
|
| 219 |
-
} catch (e) { /* ignore poll errors */ }
|
| 220 |
-
}
|
| 221 |
-
setInterval(poll, 3000);
|
| 222 |
-
poll();
|
| 223 |
-
</script>
|
| 224 |
-
</body>
|
| 225 |
-
</html>
|
| 226 |
-
"""
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
app = FastAPI(title="Stocker Training")
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
@app.get("/", response_class=HTMLResponse)
|
| 233 |
-
def index() -> str:
|
| 234 |
-
return HTML_PAGE
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
@app.post("/launch")
|
| 238 |
-
def launch():
|
| 239 |
-
if _state["phase"] in ("idle", "done"):
|
| 240 |
-
LOG_PATH.write_text("")
|
| 241 |
-
threading.Thread(target=_run_pipeline, daemon=True).start()
|
| 242 |
-
return {"status": "started"}
|
| 243 |
-
return {"status": "already_running", "phase": _state["phase"]}
|
| 244 |
|
| 245 |
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
phase = _state["phase"]
|
| 249 |
-
return {"phase": phase, "label": PHASE_LABELS.get(phase, phase)}
|
| 250 |
|
| 251 |
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
return
|
| 257 |
|
| 258 |
|
| 259 |
-
|
| 260 |
-
def plots():
|
| 261 |
patterns = [
|
| 262 |
"training/runs/grpo_*/*.png",
|
| 263 |
"training/runs/eval_pre/*.png",
|
|
@@ -266,15 +187,57 @@ def plots():
|
|
| 266 |
images = []
|
| 267 |
for p in patterns:
|
| 268 |
images.extend(sorted(glob.glob(str(WORKDIR / p))))
|
| 269 |
-
return
|
| 270 |
|
| 271 |
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 277 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 278 |
|
| 279 |
if __name__ == "__main__":
|
| 280 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Training Space UI β Gradio (5.x).
|
| 2 |
|
| 3 |
+
Compatible with openenv-core because gradio>=5 dropped the websockets<13
|
| 4 |
+
pin (gradio-client 1.14+ uses websockets>=13 / >=15 family).
|
| 5 |
|
| 6 |
Required Space secrets (Settings β Variables and secrets):
|
| 7 |
HF_TOKEN β write-access token for uploading results
|
| 8 |
API_BASE_URL β https://<endpoint>.endpoints.huggingface.cloud/v1
|
| 9 |
MODEL_NAME β ggml-org/gemma-4-26B-A4B-it-GGUF
|
| 10 |
+
RESULTS_REPO β Hydr473/stocker-results (defaults if unset)
|
| 11 |
+
USE_MOCK_SPECIALISTS=1 β fall back to MockLLMClient (skip endpoint)
|
| 12 |
"""
|
| 13 |
from __future__ import annotations
|
| 14 |
|
|
|
|
| 19 |
import threading
|
| 20 |
from pathlib import Path
|
| 21 |
|
| 22 |
+
import gradio as gr
|
|
|
|
|
|
|
| 23 |
from huggingface_hub import HfApi
|
| 24 |
|
| 25 |
WORKDIR = Path(__file__).resolve().parent.parent.parent
|
|
|
|
| 38 |
text=True,
|
| 39 |
cwd=str(WORKDIR),
|
| 40 |
env=env,
|
| 41 |
+
bufsize=1,
|
| 42 |
)
|
| 43 |
with open(LOG_PATH, "a") as lf:
|
| 44 |
assert proc.stdout
|
|
|
|
| 84 |
"--batch-size", "4",
|
| 85 |
"--grad-accum", "1",
|
| 86 |
"--lora-rank", "16",
|
| 87 |
+
"--lr", "5e-6",
|
| 88 |
+
"--imitation-warmstart"],
|
| 89 |
env_extra=endpoint_env,
|
| 90 |
)
|
| 91 |
|
|
|
|
| 160 |
}
|
| 161 |
|
| 162 |
|
| 163 |
+
def launch_pipeline() -> str:
|
| 164 |
+
if _state["phase"] not in ("idle", "done"):
|
| 165 |
+
return PHASE_LABELS.get(_state["phase"], _state["phase"])
|
| 166 |
+
threading.Thread(target=_run_pipeline, daemon=True).start()
|
| 167 |
+
return PHASE_LABELS["precaching"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 168 |
|
| 169 |
|
| 170 |
+
def poll_status() -> str:
|
| 171 |
+
return PHASE_LABELS.get(_state["phase"], _state["phase"])
|
|
|
|
|
|
|
| 172 |
|
| 173 |
|
| 174 |
+
def poll_logs() -> str:
|
| 175 |
+
if not LOG_PATH.exists():
|
| 176 |
+
return "No logs yet."
|
| 177 |
+
text = LOG_PATH.read_text()
|
| 178 |
+
return text[-12000:] if len(text) > 12000 else text
|
| 179 |
|
| 180 |
|
| 181 |
+
def poll_plots() -> list[str]:
|
|
|
|
| 182 |
patterns = [
|
| 183 |
"training/runs/grpo_*/*.png",
|
| 184 |
"training/runs/eval_pre/*.png",
|
|
|
|
| 187 |
images = []
|
| 188 |
for p in patterns:
|
| 189 |
images.extend(sorted(glob.glob(str(WORKDIR / p))))
|
| 190 |
+
return images
|
| 191 |
|
| 192 |
|
| 193 |
+
with gr.Blocks(title="Stocker β GRPO Training") as demo:
|
| 194 |
+
gr.Markdown(
|
| 195 |
+
"# β‘ Stocker β GRPO Training\n"
|
| 196 |
+
"L4 GPU Β· pre-cache 26B specialist votes β baseline eval β "
|
| 197 |
+
"GRPO E4B β post eval β compile β upload"
|
| 198 |
+
)
|
| 199 |
+
|
| 200 |
+
status_box = gr.Textbox(
|
| 201 |
+
label="Status",
|
| 202 |
+
value=PHASE_LABELS["idle"],
|
| 203 |
+
interactive=False,
|
| 204 |
+
lines=1,
|
| 205 |
+
)
|
| 206 |
|
| 207 |
+
with gr.Row():
|
| 208 |
+
launch_btn = gr.Button(
|
| 209 |
+
"π Launch Pipeline",
|
| 210 |
+
variant="primary",
|
| 211 |
+
scale=2,
|
| 212 |
+
)
|
| 213 |
+
|
| 214 |
+
log_box = gr.Textbox(
|
| 215 |
+
label="Live logs",
|
| 216 |
+
lines=24,
|
| 217 |
+
max_lines=40,
|
| 218 |
+
interactive=False,
|
| 219 |
+
autoscroll=True,
|
| 220 |
+
)
|
| 221 |
+
|
| 222 |
+
gallery = gr.Gallery(
|
| 223 |
+
label="Plots (training curves + eval rollouts)",
|
| 224 |
+
show_label=True,
|
| 225 |
+
columns=2,
|
| 226 |
+
height="auto",
|
| 227 |
+
object_fit="contain",
|
| 228 |
+
)
|
| 229 |
+
|
| 230 |
+
launch_btn.click(fn=launch_pipeline, outputs=status_box)
|
| 231 |
+
|
| 232 |
+
timer = gr.Timer(2.0)
|
| 233 |
+
timer.tick(fn=poll_status, outputs=status_box)
|
| 234 |
+
timer.tick(fn=poll_logs, outputs=log_box)
|
| 235 |
+
timer.tick(fn=poll_plots, outputs=gallery)
|
| 236 |
|
| 237 |
if __name__ == "__main__":
|
| 238 |
+
# theme= moved to launch() in Gradio 6+; harmless on 5 too.
|
| 239 |
+
demo.launch(
|
| 240 |
+
server_name="0.0.0.0",
|
| 241 |
+
server_port=7860,
|
| 242 |
+
show_api=False,
|
| 243 |
+
)
|
training/train_grpo.py
CHANGED
|
@@ -91,14 +91,111 @@ def build_prompt_dataset(
|
|
| 91 |
return rows
|
| 92 |
|
| 93 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
def reward_for_completion(completion_text: str, snapshot: dict) -> float:
|
| 95 |
-
"""Replay env to the
|
| 96 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
from app.council.llm import parse_json_object
|
| 98 |
from app.core.environment import StockerEnv
|
| 99 |
from app.models import EnvironmentState, TradeAction
|
| 100 |
|
| 101 |
parsed = parse_json_object(completion_text)
|
|
|
|
| 102 |
side = str(parsed.get("side", "hold")).lower()
|
| 103 |
if side not in ("buy", "sell", "hold"):
|
| 104 |
side = "hold"
|
|
@@ -111,20 +208,67 @@ def reward_for_completion(completion_text: str, snapshot: dict) -> float:
|
|
| 111 |
env.reset()
|
| 112 |
env.load_snapshot(EnvironmentState(**snapshot["env_state"]))
|
| 113 |
result = env.step(TradeAction(side=side, quantity=qty))
|
| 114 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
|
| 116 |
|
| 117 |
# ---------------------------------------------------------------------------
|
| 118 |
def run_grpo(args, run_dir: Path, dataset):
|
| 119 |
import torch
|
| 120 |
from datasets import Dataset
|
| 121 |
-
from peft import LoraConfig
|
| 122 |
-
from transformers import AutoTokenizer
|
| 123 |
from trl import GRPOConfig, GRPOTrainer
|
| 124 |
|
| 125 |
model_id = args.model
|
| 126 |
tokenizer = AutoTokenizer.from_pretrained(model_id)
|
| 127 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
# Flatten chat messages -> single prompt string the way TRL expects
|
| 129 |
def _row_to_prompt(row):
|
| 130 |
msgs = row["messages"]
|
|
@@ -133,6 +277,13 @@ def run_grpo(args, run_dir: Path, dataset):
|
|
| 133 |
train_rows = [{"prompt": _row_to_prompt(r), "snapshot": r} for r in dataset]
|
| 134 |
hf_ds = Dataset.from_list(train_rows)
|
| 135 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
def reward_fn(completions, **kwargs):
|
| 137 |
# `completions` is a list of decoded strings; `kwargs` contains the
|
| 138 |
# original row fields including "snapshot".
|
|
@@ -167,8 +318,6 @@ def run_grpo(args, run_dir: Path, dataset):
|
|
| 167 |
f"{args.batch_size * args.grad_accum}."
|
| 168 |
)
|
| 169 |
|
| 170 |
-
# max_prompt_length / max_completion_length were renamed/removed in
|
| 171 |
-
# newer TRL β let TRL pick its own defaults instead of hard-coding.
|
| 172 |
grpo_cfg = GRPOConfig(
|
| 173 |
output_dir=str(run_dir),
|
| 174 |
per_device_train_batch_size=args.batch_size,
|
|
@@ -181,11 +330,16 @@ def run_grpo(args, run_dir: Path, dataset):
|
|
| 181 |
save_strategy="epoch",
|
| 182 |
bf16=torch.cuda.is_available() and torch.cuda.is_bf16_supported(),
|
| 183 |
fp16=torch.cuda.is_available() and not torch.cuda.is_bf16_supported(),
|
|
|
|
|
|
|
|
|
|
|
|
|
| 184 |
seed=args.seed,
|
| 185 |
)
|
| 186 |
|
| 187 |
trainer = GRPOTrainer(
|
| 188 |
-
model=
|
|
|
|
| 189 |
reward_funcs=[reward_fn],
|
| 190 |
args=grpo_cfg,
|
| 191 |
train_dataset=hf_ds,
|
|
@@ -251,6 +405,10 @@ def main():
|
|
| 251 |
help="Use MockLLMClient for the 7 specialists (testing only)")
|
| 252 |
p.add_argument("--tasks", default="all",
|
| 253 |
help="Comma-separated task IDs to train on, or 'all' (default)")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 254 |
p.add_argument("--out", default=None)
|
| 255 |
args = p.parse_args()
|
| 256 |
|
|
|
|
| 91 |
return rows
|
| 92 |
|
| 93 |
|
| 94 |
+
PARSE_FAILURE_PENALTY = 0.1 # explicit penalty for malformed JSON output
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def _derive_ideal_action(snapshot: dict) -> dict:
|
| 98 |
+
"""Greedy-hindsight ideal action for a snapshot. Looks at the price
|
| 99 |
+
`lookahead_steps` ahead and returns buy when price will rise, sell
|
| 100 |
+
when price will fall, hold otherwise. Used for imitation pretrain only.
|
| 101 |
+
"""
|
| 102 |
+
from app.config import settings as global_settings
|
| 103 |
+
from app.core.tasks import get_task_definition
|
| 104 |
+
|
| 105 |
+
task = get_task_definition(snapshot["task_id"])
|
| 106 |
+
prices = task["prices"]
|
| 107 |
+
step = snapshot["step_index"]
|
| 108 |
+
K = max(1, int(getattr(global_settings, "lookahead_steps", 5)))
|
| 109 |
+
if step + K >= len(prices):
|
| 110 |
+
return {"side": "hold", "quantity": 0,
|
| 111 |
+
"rationale": "End of episode β hold."}
|
| 112 |
+
|
| 113 |
+
cur, fut = prices[step], prices[step + K]
|
| 114 |
+
pct = (fut - cur) / max(cur, 1e-9)
|
| 115 |
+
state = snapshot["env_state"]
|
| 116 |
+
cash = state.get("cash", 10000)
|
| 117 |
+
pos = state.get("position", 0)
|
| 118 |
+
max_buy = int(cash // max(cur, 1e-9))
|
| 119 |
+
|
| 120 |
+
if pct > 0.005 and max_buy > 0:
|
| 121 |
+
qty = max(1, max_buy // 2)
|
| 122 |
+
return {"side": "buy", "quantity": qty,
|
| 123 |
+
"rationale": f"+{pct:.2%} forecast over next {K} bars β buy."}
|
| 124 |
+
if pct < -0.005 and pos > 0:
|
| 125 |
+
return {"side": "sell", "quantity": pos,
|
| 126 |
+
"rationale": f"{pct:.2%} forecast over next {K} bars β exit."}
|
| 127 |
+
return {"side": "hold", "quantity": 0,
|
| 128 |
+
"rationale": "Flat forecast β hold."}
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def _imitation_pretrain(base_model, tokenizer, dataset, run_dir, args) -> None:
|
| 132 |
+
"""One-pass SFT on (prompt β ideal-action JSON) pairs. Cheap warmstart
|
| 133 |
+
so GRPO starts with at least valid JSON output and a sensible prior."""
|
| 134 |
+
import json as _json
|
| 135 |
+
from datasets import Dataset
|
| 136 |
+
from peft import LoraConfig, get_peft_model
|
| 137 |
+
from trl import SFTConfig, SFTTrainer
|
| 138 |
+
|
| 139 |
+
# IMPORTANT: must attach a LoRA adapter BEFORE SFT, or the 4-bit base
|
| 140 |
+
# weights are frozen and there's nothing to train. Same lora_cfg shape
|
| 141 |
+
# GRPO will use later; trainer.save_model preserves it.
|
| 142 |
+
sft_lora = LoraConfig(
|
| 143 |
+
r=args.lora_rank, lora_alpha=args.lora_rank * 2,
|
| 144 |
+
target_modules="all-linear", bias="none", task_type="CAUSAL_LM",
|
| 145 |
+
)
|
| 146 |
+
|
| 147 |
+
rows = []
|
| 148 |
+
for r in dataset:
|
| 149 |
+
prompt = tokenizer.apply_chat_template(
|
| 150 |
+
r["messages"], tokenize=False, add_generation_prompt=True
|
| 151 |
+
)
|
| 152 |
+
ideal = _derive_ideal_action(r)
|
| 153 |
+
completion = _json.dumps(ideal)
|
| 154 |
+
rows.append({"text": prompt + completion})
|
| 155 |
+
sft_ds = Dataset.from_list(rows)
|
| 156 |
+
print(f"[grpo] Imitation pretrain on {len(rows)} (prompt β ideal-action) pairs ...")
|
| 157 |
+
|
| 158 |
+
sft_cfg = SFTConfig(
|
| 159 |
+
output_dir=str(run_dir / "sft_warmstart"),
|
| 160 |
+
per_device_train_batch_size=args.batch_size,
|
| 161 |
+
gradient_accumulation_steps=args.grad_accum,
|
| 162 |
+
learning_rate=args.lr * 4, # warmstart can use a higher LR
|
| 163 |
+
num_train_epochs=1,
|
| 164 |
+
logging_dir=str(run_dir / "tensorboard_sft"),
|
| 165 |
+
report_to=["tensorboard"],
|
| 166 |
+
save_strategy="no",
|
| 167 |
+
bf16=base_model.dtype == __import__("torch").bfloat16,
|
| 168 |
+
gradient_checkpointing=True,
|
| 169 |
+
gradient_checkpointing_kwargs={"use_reentrant": False},
|
| 170 |
+
seed=args.seed,
|
| 171 |
+
)
|
| 172 |
+
sft_trainer = SFTTrainer(
|
| 173 |
+
model=base_model,
|
| 174 |
+
processing_class=tokenizer,
|
| 175 |
+
args=sft_cfg,
|
| 176 |
+
train_dataset=sft_ds,
|
| 177 |
+
peft_config=sft_lora,
|
| 178 |
+
)
|
| 179 |
+
sft_trainer.train()
|
| 180 |
+
print("[grpo] Imitation pretrain done. Continuing to GRPO ...")
|
| 181 |
+
|
| 182 |
+
|
| 183 |
def reward_for_completion(completion_text: str, snapshot: dict) -> float:
|
| 184 |
+
"""Replay env to the snapshot, apply the completion's action, then roll
|
| 185 |
+
forward `rollout_horizon` steps with `hold` so the consequences of the
|
| 186 |
+
action (price moves while holding the new position) accumulate into the
|
| 187 |
+
reward. Without this multi-step rollout, single-step reward is nearly
|
| 188 |
+
invariant to action and GRPO has no advantage signal.
|
| 189 |
+
|
| 190 |
+
Also applies an explicit parse-failure penalty so the model learns to
|
| 191 |
+
output valid JSON instead of getting silently defaulted to hold(0)."""
|
| 192 |
+
from app.config import settings as global_settings
|
| 193 |
from app.council.llm import parse_json_object
|
| 194 |
from app.core.environment import StockerEnv
|
| 195 |
from app.models import EnvironmentState, TradeAction
|
| 196 |
|
| 197 |
parsed = parse_json_object(completion_text)
|
| 198 |
+
parse_failed = not parsed # empty dict from parse_json_object means malformed JSON
|
| 199 |
side = str(parsed.get("side", "hold")).lower()
|
| 200 |
if side not in ("buy", "sell", "hold"):
|
| 201 |
side = "hold"
|
|
|
|
| 208 |
env.reset()
|
| 209 |
env.load_snapshot(EnvironmentState(**snapshot["env_state"]))
|
| 210 |
result = env.step(TradeAction(side=side, quantity=qty))
|
| 211 |
+
cum_reward = float(result.reward)
|
| 212 |
+
|
| 213 |
+
# Roll forward with `hold` so the position taken is actually marked-to-
|
| 214 |
+
# market over future bars. This is what gives the agent a real signal
|
| 215 |
+
# that buying at low prices / selling at high prices is good.
|
| 216 |
+
horizon = int(getattr(global_settings, "rollout_horizon", 5))
|
| 217 |
+
steps_done = 0
|
| 218 |
+
while steps_done < horizon and not result.done:
|
| 219 |
+
result = env.step(TradeAction(side="hold", quantity=0))
|
| 220 |
+
cum_reward += float(result.reward)
|
| 221 |
+
steps_done += 1
|
| 222 |
+
|
| 223 |
+
if parse_failed:
|
| 224 |
+
cum_reward -= PARSE_FAILURE_PENALTY
|
| 225 |
+
|
| 226 |
+
return cum_reward
|
| 227 |
|
| 228 |
|
| 229 |
# ---------------------------------------------------------------------------
|
| 230 |
def run_grpo(args, run_dir: Path, dataset):
|
| 231 |
import torch
|
| 232 |
from datasets import Dataset
|
| 233 |
+
from peft import LoraConfig, prepare_model_for_kbit_training
|
| 234 |
+
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
|
| 235 |
from trl import GRPOConfig, GRPOTrainer
|
| 236 |
|
| 237 |
model_id = args.model
|
| 238 |
tokenizer = AutoTokenizer.from_pretrained(model_id)
|
| 239 |
|
| 240 |
+
# GPU path: load in 4-bit so base + LoRA + reference + activations all
|
| 241 |
+
# fit in 24 GB on the L4. Without this the bf16 model alone is ~8 GB and
|
| 242 |
+
# GRPO's reference copy + KV cache + activations OOMs.
|
| 243 |
+
# CPU path: skip BnB (CUDA-only), load in fp32. Used only for end-to-end
|
| 244 |
+
# code-path tests with a tiny model β actual training needs GPU.
|
| 245 |
+
if torch.cuda.is_available():
|
| 246 |
+
bnb_compute_dtype = (
|
| 247 |
+
torch.bfloat16 if torch.cuda.is_bf16_supported() else torch.float16
|
| 248 |
+
)
|
| 249 |
+
bnb_config = BitsAndBytesConfig(
|
| 250 |
+
load_in_4bit=True,
|
| 251 |
+
bnb_4bit_compute_dtype=bnb_compute_dtype,
|
| 252 |
+
bnb_4bit_use_double_quant=True,
|
| 253 |
+
bnb_4bit_quant_type="nf4",
|
| 254 |
+
)
|
| 255 |
+
print(f"[grpo] Loading {model_id} in 4-bit (compute dtype={bnb_compute_dtype}) ...")
|
| 256 |
+
base_model = AutoModelForCausalLM.from_pretrained(
|
| 257 |
+
model_id,
|
| 258 |
+
quantization_config=bnb_config,
|
| 259 |
+
device_map={"": 0},
|
| 260 |
+
torch_dtype=bnb_compute_dtype,
|
| 261 |
+
)
|
| 262 |
+
base_model = prepare_model_for_kbit_training(
|
| 263 |
+
base_model, use_gradient_checkpointing=True
|
| 264 |
+
)
|
| 265 |
+
else:
|
| 266 |
+
print(f"[grpo] CUDA unavailable β loading {model_id} in fp32 on CPU "
|
| 267 |
+
"(end-to-end code-path test only; not a real training run).")
|
| 268 |
+
base_model = AutoModelForCausalLM.from_pretrained(
|
| 269 |
+
model_id, torch_dtype=torch.float32
|
| 270 |
+
)
|
| 271 |
+
|
| 272 |
# Flatten chat messages -> single prompt string the way TRL expects
|
| 273 |
def _row_to_prompt(row):
|
| 274 |
msgs = row["messages"]
|
|
|
|
| 277 |
train_rows = [{"prompt": _row_to_prompt(r), "snapshot": r} for r in dataset]
|
| 278 |
hf_ds = Dataset.from_list(train_rows)
|
| 279 |
|
| 280 |
+
# ββ Imitation pretrain (option C) βββββββββββββββββββββββββββββββββββββ
|
| 281 |
+
# Brief SFT pass on derived ideal actions to warmstart the LoRA before
|
| 282 |
+
# GRPO. Without it, GRPO starts from random LoRA noise and the agent
|
| 283 |
+
# may need many steps to discover even basic JSON formatting.
|
| 284 |
+
if getattr(args, "imitation_warmstart", False):
|
| 285 |
+
_imitation_pretrain(base_model, tokenizer, dataset, run_dir, args)
|
| 286 |
+
|
| 287 |
def reward_fn(completions, **kwargs):
|
| 288 |
# `completions` is a list of decoded strings; `kwargs` contains the
|
| 289 |
# original row fields including "snapshot".
|
|
|
|
| 318 |
f"{args.batch_size * args.grad_accum}."
|
| 319 |
)
|
| 320 |
|
|
|
|
|
|
|
| 321 |
grpo_cfg = GRPOConfig(
|
| 322 |
output_dir=str(run_dir),
|
| 323 |
per_device_train_batch_size=args.batch_size,
|
|
|
|
| 330 |
save_strategy="epoch",
|
| 331 |
bf16=torch.cuda.is_available() and torch.cuda.is_bf16_supported(),
|
| 332 |
fp16=torch.cuda.is_available() and not torch.cuda.is_bf16_supported(),
|
| 333 |
+
# Gradient checkpointing trades a little compute for ~30% activation
|
| 334 |
+
# memory savings. Critical on the L4 24 GB.
|
| 335 |
+
gradient_checkpointing=True,
|
| 336 |
+
gradient_checkpointing_kwargs={"use_reentrant": False},
|
| 337 |
seed=args.seed,
|
| 338 |
)
|
| 339 |
|
| 340 |
trainer = GRPOTrainer(
|
| 341 |
+
model=base_model,
|
| 342 |
+
processing_class=tokenizer,
|
| 343 |
reward_funcs=[reward_fn],
|
| 344 |
args=grpo_cfg,
|
| 345 |
train_dataset=hf_ds,
|
|
|
|
| 405 |
help="Use MockLLMClient for the 7 specialists (testing only)")
|
| 406 |
p.add_argument("--tasks", default="all",
|
| 407 |
help="Comma-separated task IDs to train on, or 'all' (default)")
|
| 408 |
+
p.add_argument("--imitation-warmstart", action="store_true",
|
| 409 |
+
help="One-pass SFT on derived ideal actions before GRPO. "
|
| 410 |
+
"Gives the model a sensible prior so GRPO doesn't "
|
| 411 |
+
"start from random LoRA noise.")
|
| 412 |
p.add_argument("--out", default=None)
|
| 413 |
args = p.parse_args()
|
| 414 |
|