Hydr473 commited on
Commit
5c054f2
·
1 Parent(s): d256f31

Add Pyright configuration, enhance dataset building, and update training notebook

Browse files

- Created a new Pyright configuration file for type checking.
- Improved error handling in `fetch_ohlcv` function to check for None values.
- Refactored `build_prices` function for better clarity and type safety.
- Updated `build_indicators` to ensure proper type handling for price data.
- Enhanced the training notebook with clearer markdown and structured code cells.
- Added new evaluation and summary files for training runs.
- Included binary files for portfolio and reward curves in evaluation runs.

app/council/llm.py CHANGED
@@ -180,17 +180,29 @@ class TransformersLLMClient:
180
  cls,
181
  model_id: str = "google/gemma-4-E4B-it",
182
  load_in_4bit: bool = True,
183
- device_map: str = "auto",
184
  ) -> "TransformersLLMClient":
 
185
  from transformers import AutoModelForCausalLM, AutoProcessor
186
 
187
- kwargs: dict = {"dtype": "auto", "device_map": device_map}
 
 
 
 
 
 
 
188
  if load_in_4bit:
189
  from transformers import BitsAndBytesConfig
190
  kwargs["quantization_config"] = BitsAndBytesConfig(
191
  load_in_4bit=True,
192
- bnb_4bit_compute_dtype="bfloat16",
193
  bnb_4bit_quant_type="nf4",
 
 
 
 
194
  )
195
  processor = AutoProcessor.from_pretrained(model_id)
196
  model = AutoModelForCausalLM.from_pretrained(model_id, **kwargs)
@@ -244,6 +256,15 @@ class TransformersLLMClient:
244
  # ----------------------------------------------------------------- helpers
245
  @staticmethod
246
  def _prep_messages(messages: list[dict]) -> list[dict]:
 
 
 
 
 
 
 
 
 
247
  import base64
248
  from io import BytesIO
249
 
@@ -252,6 +273,10 @@ class TransformersLLMClient:
252
  out = []
253
  for m in messages:
254
  content = m.get("content")
 
 
 
 
255
  if isinstance(content, list):
256
  new_parts = []
257
  for part in content:
 
180
  cls,
181
  model_id: str = "google/gemma-4-E4B-it",
182
  load_in_4bit: bool = True,
183
+ device_map: Any = "auto",
184
  ) -> "TransformersLLMClient":
185
+ import torch
186
  from transformers import AutoModelForCausalLM, AutoProcessor
187
 
188
+ # T4 (Turing) doesn't have bf16 hardware — fall back to fp16 there.
189
+ compute_dtype = (
190
+ "bfloat16"
191
+ if (torch.cuda.is_available() and torch.cuda.is_bf16_supported())
192
+ else "float16"
193
+ )
194
+
195
+ kwargs: dict = {"dtype": compute_dtype, "device_map": device_map}
196
  if load_in_4bit:
197
  from transformers import BitsAndBytesConfig
198
  kwargs["quantization_config"] = BitsAndBytesConfig(
199
  load_in_4bit=True,
200
+ bnb_4bit_compute_dtype=compute_dtype,
201
  bnb_4bit_quant_type="nf4",
202
+ bnb_4bit_use_double_quant=True,
203
+ # Allow non-quantizable modules (vision tower, embeddings) to
204
+ # land on CPU when GPU is tight — required on T4 for E4B.
205
+ llm_int8_enable_fp32_cpu_offload=True,
206
  )
207
  processor = AutoProcessor.from_pretrained(model_id)
208
  model = AutoModelForCausalLM.from_pretrained(model_id, **kwargs)
 
256
  # ----------------------------------------------------------------- helpers
257
  @staticmethod
258
  def _prep_messages(messages: list[dict]) -> list[dict]:
259
+ """Normalize OpenAI-style messages for HF AutoProcessor chat templates.
260
+
261
+ Two transforms:
262
+ 1. Plain string `content` is wrapped as `[{"type": "text", "text": ...}]`
263
+ because multimodal processors iterate `content` and break on strings.
264
+ 2. `{"type": "image_url", "image_url": {"url": "data:image/..."}}` parts
265
+ are decoded back to PIL.Image objects in `{"type": "image", "image": img}`
266
+ form expected by Gemma's processor.
267
+ """
268
  import base64
269
  from io import BytesIO
270
 
 
273
  out = []
274
  for m in messages:
275
  content = m.get("content")
276
+ if isinstance(content, str):
277
+ # Plain string -> single text part
278
+ out.append({**m, "content": [{"type": "text", "text": content}]})
279
+ continue
280
  if isinstance(content, list):
281
  new_parts = []
282
  for part in content:
app/data/indicators.py CHANGED
@@ -17,7 +17,7 @@ def rsi(close: pd.Series, length: int = 14) -> pd.Series:
17
  avg_gain = gain.ewm(alpha=1 / length, adjust=False, min_periods=length).mean()
18
  avg_loss = loss.ewm(alpha=1 / length, adjust=False, min_periods=length).mean()
19
  rs = avg_gain / avg_loss.replace(0, float("nan"))
20
- return 100 - (100 / (1 + rs))
21
 
22
 
23
  def macd(
@@ -33,7 +33,9 @@ def macd(
33
 
34
 
35
  def sma(close: pd.Series, length: int) -> pd.Series:
36
- return close.rolling(length, min_periods=length).mean()
 
 
37
 
38
 
39
  def bbands(close: pd.Series, length: int = 20, std: float = 2.0) -> pd.DataFrame:
@@ -55,4 +57,7 @@ def atr(
55
  ],
56
  axis=1,
57
  ).max(axis=1)
58
- return tr.ewm(alpha=1 / length, adjust=False, min_periods=length).mean()
 
 
 
 
17
  avg_gain = gain.ewm(alpha=1 / length, adjust=False, min_periods=length).mean()
18
  avg_loss = loss.ewm(alpha=1 / length, adjust=False, min_periods=length).mean()
19
  rs = avg_gain / avg_loss.replace(0, float("nan"))
20
+ return pd.Series(100 - (100 / (1 + rs)), index=close.index, name="rsi")
21
 
22
 
23
  def macd(
 
33
 
34
 
35
  def sma(close: pd.Series, length: int) -> pd.Series:
36
+ return pd.Series(
37
+ close.rolling(length, min_periods=length).mean(), index=close.index
38
+ )
39
 
40
 
41
  def bbands(close: pd.Series, length: int = 20, std: float = 2.0) -> pd.DataFrame:
 
57
  ],
58
  axis=1,
59
  ).max(axis=1)
60
+ return pd.Series(
61
+ tr.ewm(alpha=1 / length, adjust=False, min_periods=length).mean(),
62
+ index=close.index,
63
+ )
app/data/loader.py CHANGED
@@ -57,7 +57,9 @@ def macro() -> pd.DataFrame:
57
  def episode_rows(task_id: str) -> pd.DataFrame:
58
  """Episode prices for a task, sorted by date."""
59
  df = prices()
60
- return df[(df["task_id"] == task_id) & df["in_episode"]].sort_values("date").reset_index(drop=True)
 
 
61
 
62
 
63
  def lookup_indicators(ticker: str, date: str) -> dict:
 
57
  def episode_rows(task_id: str) -> pd.DataFrame:
58
  """Episode prices for a task, sorted by date."""
59
  df = prices()
60
+ mask = (df["task_id"] == task_id) & df["in_episode"]
61
+ sub = df.loc[mask]
62
+ return sub.sort_values(by="date").reset_index(drop=True)
63
 
64
 
65
  def lookup_indicators(ticker: str, date: str) -> dict:
pyrightconfig.json ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "include": ["app", "scripts", "server", "tests", "training", "inference.py", "client.py"],
3
+ "exclude": ["**/__pycache__", "**/.venv", "**/.cache", "training/runs"],
4
+ "extraPaths": ["."],
5
+ "typeCheckingMode": "basic",
6
+ "reportMissingImports": "warning",
7
+ "reportMissingTypeStubs": "none",
8
+ "pythonVersion": "3.12"
9
+ }
scripts/build_dataset.py CHANGED
@@ -82,12 +82,14 @@ def fetch_ohlcv(ticker: str, start: str, end: str) -> pd.DataFrame:
82
  df = yf.download(
83
  ticker, start=start, end=end, auto_adjust=False, progress=False
84
  )
85
- if df.empty:
86
  raise RuntimeError(f"yfinance returned empty for {ticker} {start}..{end}")
87
  if isinstance(df.columns, pd.MultiIndex):
88
  df.columns = df.columns.get_level_values(0)
89
- df.index = pd.to_datetime(df.index).tz_localize(None).normalize()
90
- df = df[["Open", "High", "Low", "Close", "Volume"]]
 
 
91
  df.columns = ["open", "high", "low", "close", "volume"]
92
  return df
93
 
@@ -97,38 +99,39 @@ def lookback_start(episode_start: str, days: int) -> str:
97
 
98
 
99
  def build_prices() -> pd.DataFrame:
100
- rows = []
101
  for task in TASKS:
102
  start = lookback_start(task["episode_start"], LOOKBACK_DAYS)
103
  end = (pd.to_datetime(task["episode_end"]) + pd.Timedelta(days=1)).strftime("%Y-%m-%d")
104
  df = fetch_ohlcv(task["ticker"], start, end)
 
 
105
  for date, row in df.iterrows():
 
106
  rows.append({
107
  "task_id": task["task_id"],
108
  "ticker": task["ticker"],
109
- "date": date.strftime("%Y-%m-%d"),
110
- "open": float(row["open"]),
111
- "high": float(row["high"]),
112
- "low": float(row["low"]),
113
- "close": float(row["close"]),
114
- "volume": float(row["volume"]),
115
- "in_episode": (
116
- pd.to_datetime(task["episode_start"])
117
- <= date
118
- <= pd.to_datetime(task["episode_end"])
119
- ),
120
  })
121
  return pd.DataFrame(rows)
122
 
123
 
124
  def build_indicators(prices: pd.DataFrame) -> pd.DataFrame:
125
  """Compute indicators per ticker on the full lookback+episode window."""
126
- out_rows = []
127
  for ticker, group in prices.groupby("ticker"):
128
- df = group.sort_values("date").set_index(pd.to_datetime(group["date"]))
129
- close = df["close"]
130
- high = df["high"]
131
- low = df["low"]
 
 
132
 
133
  ind = pd.DataFrame(index=df.index)
134
  ind["rsi14"] = ta.rsi(close, length=14)
@@ -147,10 +150,10 @@ def build_indicators(prices: pd.DataFrame) -> pd.DataFrame:
147
  ind["ticker"] = ticker
148
  ind["date"] = ind["date"].dt.strftime("%Y-%m-%d")
149
  out_rows.append(ind)
150
- out = pd.concat(out_rows, ignore_index=True)
151
  cols = ["ticker", "date", "rsi14", "macd", "macd_signal",
152
  "sma20", "sma50", "sma200", "bb_lower", "bb_upper", "atr14"]
153
- return out[cols]
154
 
155
 
156
  def build_peers() -> pd.DataFrame:
@@ -232,8 +235,9 @@ def render_charts(prices: pd.DataFrame) -> int:
232
  for ticker, group in prices.groupby("ticker"):
233
  group = group.sort_values("date").reset_index(drop=True)
234
  group["date_dt"] = pd.to_datetime(group["date"])
235
- for i, row in group.iterrows():
236
- if not row["in_episode"]:
 
237
  continue
238
  window_start_idx = max(0, i - 60)
239
  window = group.iloc[window_start_idx : i + 1].copy()
 
82
  df = yf.download(
83
  ticker, start=start, end=end, auto_adjust=False, progress=False
84
  )
85
+ if df is None or df.empty:
86
  raise RuntimeError(f"yfinance returned empty for {ticker} {start}..{end}")
87
  if isinstance(df.columns, pd.MultiIndex):
88
  df.columns = df.columns.get_level_values(0)
89
+ idx = pd.DatetimeIndex(pd.to_datetime(df.index)).tz_localize(None)
90
+ idx = idx.normalize() # type: ignore[attr-defined]
91
+ df.index = idx
92
+ df = df.loc[:, ["Open", "High", "Low", "Close", "Volume"]]
93
  df.columns = ["open", "high", "low", "close", "volume"]
94
  return df
95
 
 
99
 
100
 
101
  def build_prices() -> pd.DataFrame:
102
+ rows: list[dict] = []
103
  for task in TASKS:
104
  start = lookback_start(task["episode_start"], LOOKBACK_DAYS)
105
  end = (pd.to_datetime(task["episode_end"]) + pd.Timedelta(days=1)).strftime("%Y-%m-%d")
106
  df = fetch_ohlcv(task["ticker"], start, end)
107
+ episode_start = pd.to_datetime(task["episode_start"])
108
+ episode_end = pd.to_datetime(task["episode_end"])
109
  for date, row in df.iterrows():
110
+ ts = pd.Timestamp(date) # type: ignore[arg-type]
111
  rows.append({
112
  "task_id": task["task_id"],
113
  "ticker": task["ticker"],
114
+ "date": ts.strftime("%Y-%m-%d"),
115
+ "open": float(row["open"]), # type: ignore[arg-type]
116
+ "high": float(row["high"]), # type: ignore[arg-type]
117
+ "low": float(row["low"]), # type: ignore[arg-type]
118
+ "close": float(row["close"]), # type: ignore[arg-type]
119
+ "volume": float(row["volume"]), # type: ignore[arg-type]
120
+ "in_episode": episode_start <= ts <= episode_end,
 
 
 
 
121
  })
122
  return pd.DataFrame(rows)
123
 
124
 
125
  def build_indicators(prices: pd.DataFrame) -> pd.DataFrame:
126
  """Compute indicators per ticker on the full lookback+episode window."""
127
+ out_rows: list[pd.DataFrame] = []
128
  for ticker, group in prices.groupby("ticker"):
129
+ df = group.sort_values("date").set_index(
130
+ pd.to_datetime(group["date"])
131
+ )
132
+ close = pd.Series(df["close"].astype(float))
133
+ high = pd.Series(df["high"].astype(float))
134
+ low = pd.Series(df["low"].astype(float))
135
 
136
  ind = pd.DataFrame(index=df.index)
137
  ind["rsi14"] = ta.rsi(close, length=14)
 
150
  ind["ticker"] = ticker
151
  ind["date"] = ind["date"].dt.strftime("%Y-%m-%d")
152
  out_rows.append(ind)
153
+ out: pd.DataFrame = pd.concat(out_rows, ignore_index=True)
154
  cols = ["ticker", "date", "rsi14", "macd", "macd_signal",
155
  "sma20", "sma50", "sma200", "bb_lower", "bb_upper", "atr14"]
156
+ return out.loc[:, cols]
157
 
158
 
159
  def build_peers() -> pd.DataFrame:
 
235
  for ticker, group in prices.groupby("ticker"):
236
  group = group.sort_values("date").reset_index(drop=True)
237
  group["date_dt"] = pd.to_datetime(group["date"])
238
+ for raw_i, row in group.iterrows():
239
+ i = int(raw_i) # type: ignore[arg-type]
240
+ if not bool(row["in_episode"]):
241
  continue
242
  window_start_idx = max(0, i - 60)
243
  window = group.iloc[window_start_idx : i + 1].copy()
training/runs/eval_smoke/portfolio_curve.png ADDED
training/runs/eval_smoke/reward_curve.png ADDED
training/runs/eval_smoke/summary.csv ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ task_id,total_reward,final_portfolio,buy_and_hold,alpha_pct
2
+ task_easy,0.1,10000.0,8755.6,14.21
3
+ task_medium,0.1,10000.0,9007.25,11.02
4
+ task_hard,0.1,10000.0,5668.0,76.43
training/train_grpo.ipynb CHANGED
@@ -4,23 +4,82 @@
4
  "cell_type": "markdown",
5
  "id": "intro",
6
  "metadata": {},
7
- "source": "# Stocker — End-to-end Colab workflow\n\nSelf-contained: clones the repo, installs deps, builds the dataset, loads\n**`google/gemma-4-E4B-it`** in-process via `transformers` (4-bit BnB),\npre-caches all 7 specialist votes, runs **GRPO** on the moderator LoRA via\nTRL, and saves loss / reward plots + the trained adapter.\n\nDesigned for a free **Colab T4** (16 GB VRAM) — no separate vLLM server\nneeded. If you have an L4/A100, drop `load_in_4bit=False` for full bf16.\n\nOutputs live under `training/runs/<timestamp>/`:\n- `moderator-lora/` — trained PEFT adapter\n- `loss.png`, `reward.png` — training curves\n- `eval_pre.json`, `eval_post.json` — pre/post backtest reports\n\n> Tip: `Runtime → Change runtime type → T4 GPU` before running."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  },
9
  {
10
  "cell_type": "code",
11
- "execution_count": null,
12
  "id": "install",
13
  "metadata": {},
14
  "outputs": [],
15
- "source": "# Install — keep Colab's pre-baked pandas/numpy (TF + cudf depend on them);\n# only upgrade what we strictly need.\n#\n# Big upgrades (transformers/trl/peft) are intentional — Colab ships old.\n# Everything else uses --upgrade-strategy only-if-needed so we don't\n# break google-colab/tensorflow/gradio/etc.\n!pip install -q -U 'transformers>=4.55' 'trl>=0.11' 'peft>=0.13' 'accelerate>=1.0' 'bitsandbytes>=0.43' 'datasets>=3.0'\n!pip install -q --upgrade-strategy=only-if-needed yfinance mplfinance pyarrow 'pydantic>=2' pydantic-settings 'openai>=1' tensorboard 'huggingface_hub>=1.10'\n# Indicators are computed in-repo (app/data/indicators.py) — no pandas-ta."
 
 
 
 
 
 
 
 
 
 
16
  },
17
  {
18
  "cell_type": "code",
19
- "execution_count": null,
20
  "id": "clone",
21
  "metadata": {},
22
- "outputs": [],
23
- "source": "import os, sys, pathlib\n\n# 1) Already in a stocker repo? (running from VSCode on a local clone, or\n# the repo was manually uploaded to Colab). Detect and skip the clone.\ndef _is_stocker_root(p: str) -> bool:\n return os.path.isfile(os.path.join(p, \"app\", \"council\", \"specialists.py\"))\n\nCANDIDATES = [os.getcwd(), \"/content/stocker\", \"/workspace/stocker\"]\nWORKDIR = next((c for c in CANDIDATES if _is_stocker_root(c)), None)\n\nif WORKDIR is None:\n # 2) Not present — clone. EDIT THIS URL before running on a fresh Colab.\n REPO_URL = \"https://github.com/<your-username>/stocker.git\"\n WORKDIR = \"/content/stocker\"\n assert \"<your-username>\" not in REPO_URL, (\n \"Edit REPO_URL in this cell to your fork before running on a fresh runtime.\"\n )\n !git clone {REPO_URL} {WORKDIR}\n assert _is_stocker_root(WORKDIR), f\"Clone failed — {WORKDIR}/app/council/ missing.\"\n\nos.chdir(WORKDIR)\nsys.path.insert(0, WORKDIR)\nprint(\"Working dir:\", WORKDIR)"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  },
25
  {
26
  "cell_type": "code",
@@ -28,39 +87,253 @@
28
  "id": "auth",
29
  "metadata": {},
30
  "outputs": [],
31
- "source": "# Gemma 4 is Apache-2.0 (not gated) — token just lifts download rate limits.\nfrom huggingface_hub import login\nimport getpass\nlogin(token=getpass.getpass(\"HF token (read scope is enough): \"))"
 
 
 
 
 
32
  },
33
  {
34
  "cell_type": "code",
35
  "execution_count": null,
36
  "id": "build-data",
37
  "metadata": {},
38
- "outputs": [],
39
- "source": "# Build the bundled dataset (yfinance + indicators + chart PNGs).\n# Idempotent — skip if already done.\n!python scripts/build_dataset.py\n!python scripts/validate_tasks.py"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
  },
41
  {
42
  "cell_type": "code",
 
43
  "id": "f55a1108",
44
- "source": "from app.council.llm import TransformersLLMClient\n\n# 4-bit BnB by default; drop load_in_4bit=False on L4/A100.\nclient = TransformersLLMClient.from_pretrained(\n \"google/gemma-4-E4B-it\",\n load_in_4bit=True,\n)\nprint(\"Model loaded on:\", client.model.device)",
45
  "metadata": {},
46
- "execution_count": null,
47
- "outputs": []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
  },
49
  {
50
  "cell_type": "code",
 
51
  "id": "a3a9bd4c",
52
- "source": "# Pre-cache the 7 specialists' votes for every (task, step). Specialists\n# are FROZEN, so this runs once and is reused across every GRPO step.\nfrom app.council.runner import Council\nfrom app.core.environment import StockerEnv\nfrom app.core.tasks import list_task_ids\n\ncouncil = Council(client=client, use_cache=True)\ntotal = 0\nfor task_id in list_task_ids():\n env = StockerEnv(task_id=task_id)\n obs = env.reset().observation\n while True:\n for sp in council.specialists:\n council._cached_vote(sp, obs) # writes .cache/council/<role>/...\n total += 1\n result = env.step({\"side\": \"hold\", \"quantity\": 0})\n if result.done:\n break\n obs = result.observation\nprint(f\"cached {total} specialist votes across {len(list_task_ids())} tasks\")",
53
  "metadata": {},
54
- "execution_count": null,
55
- "outputs": []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  },
57
  {
58
  "cell_type": "code",
 
59
  "id": "6af95f46",
60
- "source": "# Pre-training baseline rollout (specialists from cache, moderator = base Gemma)\n!python -m training.eval_rollout --out training/runs/eval_pre\n!cat training/runs/eval_pre/summary.csv",
61
  "metadata": {},
62
- "execution_count": null,
63
- "outputs": []
 
 
 
 
64
  },
65
  {
66
  "cell_type": "code",
@@ -68,7 +341,20 @@
68
  "id": "train",
69
  "metadata": {},
70
  "outputs": [],
71
- "source": "# GRPO on the moderator LoRA. Specialist votes are read from cache —\n# only the moderator is re-rolled per training step, so this is fast.\n#\n# Knobs: --num-generations is K candidates per prompt (GRPO group size).\n# On a free T4 keep batch_size=1, grad_accum=8.\n!python -m training.train_grpo \\\n --epochs 2 \\\n --num-generations 8 \\\n --batch-size 1 \\\n --grad-accum 8 \\\n --lora-rank 16 \\\n --lr 5e-6"
 
 
 
 
 
 
 
 
 
 
 
 
 
72
  },
73
  {
74
  "cell_type": "code",
@@ -76,27 +362,63 @@
76
  "id": "eval",
77
  "metadata": {},
78
  "outputs": [],
79
- "source": "# Post-training eval — load the trained adapter into the same client.\nimport glob, os\nRUN_DIR = sorted(glob.glob(\"training/runs/grpo_*\"))[-1]\nLORA_DIR = os.path.join(RUN_DIR, \"moderator-lora\")\nprint(\"Using LoRA:\", LORA_DIR)\n\n# Attach adapter and re-run eval. The adapter name \"moderator\" matches what\n# Moderator.decide() requests via extra_body={'lora_request': {'name': ...}}\nfrom peft import PeftModel\nclient.model = PeftModel.from_pretrained(client.model, LORA_DIR, adapter_name=\"moderator\")\nclient.moderator_lora = \"moderator\"\n\n!python -m training.eval_rollout --moderator-lora moderator --out training/runs/eval_post\n!cat training/runs/eval_post/summary.csv"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80
  },
81
  {
82
  "cell_type": "code",
 
83
  "id": "b1d888ed",
84
- "source": "# Show training + eval curves inline\nfrom IPython.display import Image, display\nimport os\nfor p in [f\"{RUN_DIR}/loss.png\", f\"{RUN_DIR}/reward.png\",\n \"training/runs/eval_pre/reward_curve.png\",\n \"training/runs/eval_post/reward_curve.png\",\n \"training/runs/eval_pre/portfolio_curve.png\",\n \"training/runs/eval_post/portfolio_curve.png\"]:\n if os.path.exists(p):\n print(p)\n display(Image(p))",
85
  "metadata": {},
86
- "execution_count": null,
87
- "outputs": []
 
 
 
 
 
 
 
 
 
 
 
 
88
  }
89
  ],
90
  "metadata": {
91
  "kernelspec": {
92
- "display_name": "Python 3",
93
  "language": "python",
94
  "name": "python3"
95
  },
96
  "language_info": {
97
- "name": "python"
 
 
 
 
 
 
 
 
 
98
  }
99
  },
100
  "nbformat": 4,
101
  "nbformat_minor": 5
102
- }
 
4
  "cell_type": "markdown",
5
  "id": "intro",
6
  "metadata": {},
7
+ "source": [
8
+ "# Stocker — End-to-end Colab workflow\n",
9
+ "\n",
10
+ "Self-contained: clones the repo, installs deps, builds the dataset, loads\n",
11
+ "**`google/gemma-4-E4B-it`** in-process via `transformers` (4-bit BnB),\n",
12
+ "pre-caches all 7 specialist votes, runs **GRPO** on the moderator LoRA via\n",
13
+ "TRL, and saves loss / reward plots + the trained adapter.\n",
14
+ "\n",
15
+ "Designed for a free **Colab T4** (16 GB VRAM) — no separate vLLM server\n",
16
+ "needed. If you have an L4/A100, drop `load_in_4bit=False` for full bf16.\n",
17
+ "\n",
18
+ "Outputs live under `training/runs/<timestamp>/`:\n",
19
+ "- `moderator-lora/` — trained PEFT adapter\n",
20
+ "- `loss.png`, `reward.png` — training curves\n",
21
+ "- `eval_pre.json`, `eval_post.json` — pre/post backtest reports\n",
22
+ "\n",
23
+ "> Tip: `Runtime → Change runtime type → T4 GPU` before running."
24
+ ]
25
  },
26
  {
27
  "cell_type": "code",
28
+ "execution_count": 7,
29
  "id": "install",
30
  "metadata": {},
31
  "outputs": [],
32
+ "source": [
33
+ "# Install — keep Colab's pre-baked pandas/numpy (TF + cudf depend on them);\n",
34
+ "# only upgrade what we strictly need.\n",
35
+ "#\n",
36
+ "# Big upgrades (transformers/trl/peft) are intentional — Colab ships old.\n",
37
+ "# Everything else uses --upgrade-strategy only-if-needed so we don't\n",
38
+ "# break google-colab/tensorflow/gradio/etc.\n",
39
+ "!pip install -q -U 'transformers>=4.55' 'trl>=0.11' 'peft>=0.13' 'accelerate>=1.0' 'bitsandbytes>=0.43' 'datasets>=3.0'\n",
40
+ "!pip install -q --upgrade-strategy=only-if-needed yfinance mplfinance pyarrow 'pydantic>=2' pydantic-settings 'openai>=1' tensorboard 'huggingface_hub>=1.10'\n",
41
+ "# Indicators are computed in-repo (app/data/indicators.py) — no pandas-ta."
42
+ ]
43
  },
44
  {
45
  "cell_type": "code",
46
+ "execution_count": 8,
47
  "id": "clone",
48
  "metadata": {},
49
+ "outputs": [
50
+ {
51
+ "name": "stdout",
52
+ "output_type": "stream",
53
+ "text": [
54
+ "Working dir: /content/stocker\n"
55
+ ]
56
+ }
57
+ ],
58
+ "source": [
59
+ "import os, sys, pathlib\n",
60
+ "\n",
61
+ "# 1) Already in a stocker repo? (running from VSCode on a local clone, or\n",
62
+ "# the repo was manually uploaded to Colab). Detect and skip the clone.\n",
63
+ "def _is_stocker_root(p: str) -> bool:\n",
64
+ " return os.path.isfile(os.path.join(p, \"app\", \"council\", \"specialists.py\"))\n",
65
+ "\n",
66
+ "CANDIDATES = [os.getcwd(), \"/content/stocker\", \"/workspace/stocker\"]\n",
67
+ "WORKDIR = next((c for c in CANDIDATES if _is_stocker_root(c)), None)\n",
68
+ "\n",
69
+ "if WORKDIR is None:\n",
70
+ " # 2) Not present — clone. EDIT THIS URL before running on a fresh Colab.\n",
71
+ " REPO_URL = \"https://github.com/<your-username>/stocker.git\"\n",
72
+ " WORKDIR = \"/content/stocker\"\n",
73
+ " assert \"<your-username>\" not in REPO_URL, (\n",
74
+ " \"Edit REPO_URL in this cell to your fork before running on a fresh runtime.\"\n",
75
+ " )\n",
76
+ " !git clone {REPO_URL} {WORKDIR}\n",
77
+ " assert _is_stocker_root(WORKDIR), f\"Clone failed — {WORKDIR}/app/council/ missing.\"\n",
78
+ "\n",
79
+ "os.chdir(WORKDIR)\n",
80
+ "sys.path.insert(0, WORKDIR)\n",
81
+ "print(\"Working dir:\", WORKDIR)"
82
+ ]
83
  },
84
  {
85
  "cell_type": "code",
 
87
  "id": "auth",
88
  "metadata": {},
89
  "outputs": [],
90
+ "source": [
91
+ "# Gemma 4 is Apache-2.0 (not gated) — token just lifts download rate limits.\n",
92
+ "from huggingface_hub import login\n",
93
+ "import getpass\n",
94
+ "login(token=getpass.getpass(\"HF token (read scope is enough): \"))"
95
+ ]
96
  },
97
  {
98
  "cell_type": "code",
99
  "execution_count": null,
100
  "id": "build-data",
101
  "metadata": {},
102
+ "outputs": [
103
+ {
104
+ "name": "stdout",
105
+ "output_type": "stream",
106
+ "text": [
107
+ "[1/6] Fetching OHLCV ...\n",
108
+ " prices: 536 rows\n",
109
+ "[2/6] Computing indicators ...\n",
110
+ " indicators: 536 rows\n",
111
+ "[3/6] Fetching peers + commodity ...\n",
112
+ " peers: 374 rows\n",
113
+ "[4/6] Loading curated news/forums/macro ...\n",
114
+ " news: 25 headlines, forums: 17 posts, macro: 12 events\n",
115
+ "[5/6] Rendering candlestick charts ...\n",
116
+ " charts: 126 PNGs in data/charts\n",
117
+ "[6/6] Done.\n",
118
+ "task_easy ticker=AAPL steps= 43 chart_ok=yes\n",
119
+ "task_medium ticker=INTC steps= 41 chart_ok=yes\n",
120
+ "task_hard ticker=META steps= 42 chart_ok=yes\n",
121
+ "\n",
122
+ "All 3 tasks OK.\n"
123
+ ]
124
+ }
125
+ ],
126
+ "source": [
127
+ "# Build the bundled dataset (yfinance + indicators + chart PNGs).\n",
128
+ "# Idempotent — skip if already done.\n",
129
+ "!python scripts/build_dataset.py\n",
130
+ "!python scripts/validate_tasks.py"
131
+ ]
132
  },
133
  {
134
  "cell_type": "code",
135
+ "execution_count": null,
136
  "id": "f55a1108",
 
137
  "metadata": {},
138
+ "outputs": [
139
+ {
140
+ "name": "stderr",
141
+ "output_type": "stream",
142
+ "text": [
143
+ "/usr/local/lib/python3.12/dist-packages/huggingface_hub/utils/_auth.py:103: UserWarning: \n",
144
+ "Error while fetching `HF_TOKEN` secret value from your vault: 'Requesting secret HF_TOKEN timed out. Secrets can only be fetched when running from the Colab UI.'.\n",
145
+ "You are not authenticated with the Hugging Face Hub in this notebook.\n",
146
+ "If the error persists, please let us know by opening an issue on GitHub (https://github.com/huggingface/huggingface_hub/issues/new).\n",
147
+ " warnings.warn(\n"
148
+ ]
149
+ },
150
+ {
151
+ "data": {
152
+ "application/vnd.jupyter.widget-view+json": {
153
+ "model_id": "09427ffe6e7b4be094cef43d13c33fe6",
154
+ "version_major": 2,
155
+ "version_minor": 0
156
+ },
157
+ "text/plain": [
158
+ "processor_config.json: 0.00B [00:00, ?B/s]"
159
+ ]
160
+ },
161
+ "metadata": {},
162
+ "output_type": "display_data"
163
+ },
164
+ {
165
+ "data": {
166
+ "application/vnd.jupyter.widget-view+json": {
167
+ "model_id": "8874cb7e313545a2b79bad9317858169",
168
+ "version_major": 2,
169
+ "version_minor": 0
170
+ },
171
+ "text/plain": [
172
+ "chat_template.jinja: 0.00B [00:00, ?B/s]"
173
+ ]
174
+ },
175
+ "metadata": {},
176
+ "output_type": "display_data"
177
+ },
178
+ {
179
+ "data": {
180
+ "application/vnd.jupyter.widget-view+json": {
181
+ "model_id": "37bb28cb29c64f97b2699f780174d9ee",
182
+ "version_major": 2,
183
+ "version_minor": 0
184
+ },
185
+ "text/plain": [
186
+ "config.json: 0.00B [00:00, ?B/s]"
187
+ ]
188
+ },
189
+ "metadata": {},
190
+ "output_type": "display_data"
191
+ },
192
+ {
193
+ "data": {
194
+ "application/vnd.jupyter.widget-view+json": {
195
+ "model_id": "459c2365fa5e4cf1ab6897f7a5e9816f",
196
+ "version_major": 2,
197
+ "version_minor": 0
198
+ },
199
+ "text/plain": [
200
+ "tokenizer_config.json: 0.00B [00:00, ?B/s]"
201
+ ]
202
+ },
203
+ "metadata": {},
204
+ "output_type": "display_data"
205
+ },
206
+ {
207
+ "data": {
208
+ "application/vnd.jupyter.widget-view+json": {
209
+ "model_id": "0f6eab584b334df89d7aa973094c3e58",
210
+ "version_major": 2,
211
+ "version_minor": 0
212
+ },
213
+ "text/plain": [
214
+ "tokenizer.json: 0%| | 0.00/32.2M [00:00<?, ?B/s]"
215
+ ]
216
+ },
217
+ "metadata": {},
218
+ "output_type": "display_data"
219
+ },
220
+ {
221
+ "data": {
222
+ "application/vnd.jupyter.widget-view+json": {
223
+ "model_id": "7c1bbf3af57b4782b0ea600e652b680a",
224
+ "version_major": 2,
225
+ "version_minor": 0
226
+ },
227
+ "text/plain": [
228
+ "model.safetensors: 0%| | 0.00/16.0G [00:00<?, ?B/s]"
229
+ ]
230
+ },
231
+ "metadata": {},
232
+ "output_type": "display_data"
233
+ },
234
+ {
235
+ "data": {
236
+ "application/vnd.jupyter.widget-view+json": {
237
+ "model_id": "f5cfa2f28b1844cd9808cf55cfdfc9db",
238
+ "version_major": 2,
239
+ "version_minor": 0
240
+ },
241
+ "text/plain": [
242
+ "Loading weights: 0%| | 0/2076 [00:00<?, ?it/s]"
243
+ ]
244
+ },
245
+ "metadata": {},
246
+ "output_type": "display_data"
247
+ },
248
+ {
249
+ "data": {
250
+ "application/vnd.jupyter.widget-view+json": {
251
+ "model_id": "925f4bb541bf49a5b42067ba6e4c10a9",
252
+ "version_major": 2,
253
+ "version_minor": 0
254
+ },
255
+ "text/plain": [
256
+ "generation_config.json: 0%| | 0.00/208 [00:00<?, ?B/s]"
257
+ ]
258
+ },
259
+ "metadata": {},
260
+ "output_type": "display_data"
261
+ },
262
+ {
263
+ "name": "stdout",
264
+ "output_type": "stream",
265
+ "text": [
266
+ "Model loaded on: cuda:0\n"
267
+ ]
268
+ }
269
+ ],
270
+ "source": [
271
+ "from app.council.llm import TransformersLLMClient\n",
272
+ "\n",
273
+ "# 4-bit BnB by default; drop load_in_4bit=False on L4/A100.\n",
274
+ "client = TransformersLLMClient.from_pretrained(\n",
275
+ " \"google/gemma-4-E4B-it\",\n",
276
+ " load_in_4bit=True,\n",
277
+ ")\n",
278
+ "print(\"Model loaded on:\", client.model.device)"
279
+ ]
280
  },
281
  {
282
  "cell_type": "code",
283
+ "execution_count": null,
284
  "id": "a3a9bd4c",
 
285
  "metadata": {},
286
+ "outputs": [
287
+ {
288
+ "ename": "TypeError",
289
+ "evalue": "string indices must be integers, not 'str'",
290
+ "output_type": "error",
291
+ "traceback": [
292
+ "\u001b[0;31m---------------------------------------------------------------------------\u001b[0m",
293
+ "\u001b[0;31mTypeError\u001b[0m Traceback (most recent call last)",
294
+ "\u001b[0;32m/tmp/ipykernel_1481/2753751009.py\u001b[0m in \u001b[0;36m<cell line: 0>\u001b[0;34m()\u001b[0m\n\u001b[1;32m 12\u001b[0m \u001b[0;32mwhile\u001b[0m \u001b[0;32mTrue\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 13\u001b[0m \u001b[0;32mfor\u001b[0m \u001b[0msp\u001b[0m \u001b[0;32min\u001b[0m \u001b[0mcouncil\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mspecialists\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m---> 14\u001b[0;31m \u001b[0mcouncil\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0m_cached_vote\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0msp\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mobs\u001b[0m\u001b[0;34m)\u001b[0m \u001b[0;31m# writes .cache/council/<role>/...\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 15\u001b[0m \u001b[0mtotal\u001b[0m \u001b[0;34m+=\u001b[0m \u001b[0;36m1\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 16\u001b[0m \u001b[0mresult\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0menv\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mstep\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m{\u001b[0m\u001b[0;34m\"side\"\u001b[0m\u001b[0;34m:\u001b[0m \u001b[0;34m\"hold\"\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0;34m\"quantity\"\u001b[0m\u001b[0;34m:\u001b[0m \u001b[0;36m0\u001b[0m\u001b[0;34m}\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n",
295
+ "\u001b[0;32m/content/stocker/app/council/runner.py\u001b[0m in \u001b[0;36m_cached_vote\u001b[0;34m(self, sp, obs)\u001b[0m\n\u001b[1;32m 73\u001b[0m \u001b[0;32mpass\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 74\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m---> 75\u001b[0;31m \u001b[0mvote\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0msp\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mvote\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mobs\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 76\u001b[0m \u001b[0mkey\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mparent\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mmkdir\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mparents\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0;32mTrue\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mexist_ok\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0;32mTrue\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 77\u001b[0m \u001b[0mkey\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mwrite_text\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mvote\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mmodel_dump_json\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n",
296
+ "\u001b[0;32m/content/stocker/app/council/specialists.py\u001b[0m in \u001b[0;36mvote\u001b[0;34m(self, obs)\u001b[0m\n\u001b[1;32m 30\u001b[0m \u001b[0;32mdef\u001b[0m \u001b[0mvote\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mself\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mobs\u001b[0m\u001b[0;34m:\u001b[0m \u001b[0mMarketObservation\u001b[0m\u001b[0;34m)\u001b[0m \u001b[0;34m->\u001b[0m \u001b[0mSpecialistVote\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 31\u001b[0m \u001b[0mmessages\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mprepare_messages\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mobs\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m---> 32\u001b[0;31m \u001b[0mtext\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mclient\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mcomplete\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mmessages\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mmax_tokens\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0;36m256\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mtemperature\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0;36m0.2\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 33\u001b[0m \u001b[0mdata\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mparse_json_object\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mtext\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 34\u001b[0m return SpecialistVote(\n",
297
+ "\u001b[0;32m/content/stocker/app/council/llm.py\u001b[0m in \u001b[0;36mcomplete\u001b[0;34m(self, messages, max_tokens, temperature, extra_body)\u001b[0m\n\u001b[1;32m 209\u001b[0m \u001b[0;31m# the processor expects.\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 210\u001b[0m \u001b[0mprepped\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0m_prep_messages\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mmessages\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m--> 211\u001b[0;31m inputs = self.processor.apply_chat_template(\n\u001b[0m\u001b[1;32m 212\u001b[0m \u001b[0mprepped\u001b[0m\u001b[0;34m,\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 213\u001b[0m \u001b[0madd_generation_prompt\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0;32mTrue\u001b[0m\u001b[0;34m,\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n",
298
+ "\u001b[0;32m/usr/local/lib/python3.12/dist-packages/transformers/processing_utils.py\u001b[0m in \u001b[0;36mapply_chat_template\u001b[0;34m(self, conversation, chat_template, tools, documents, add_generation_prompt, continue_final_message, return_assistant_tokens_mask, tokenize, return_tensors, return_dict, load_audio_from_video, processor_kwargs, **kwargs)\u001b[0m\n\u001b[1;32m 1815\u001b[0m \u001b[0mcontent\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mmessage\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mget\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m\"content\"\u001b[0m\u001b[0;34m)\u001b[0m \u001b[0;32mor\u001b[0m \u001b[0;34m[\u001b[0m\u001b[0;34m]\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 1816\u001b[0m visuals = [\n\u001b[0;32m-> 1817\u001b[0;31m \u001b[0mcontent_block\u001b[0m \u001b[0;32mfor\u001b[0m \u001b[0mcontent_block\u001b[0m \u001b[0;32min\u001b[0m \u001b[0mcontent\u001b[0m \u001b[0;32mif\u001b[0m \u001b[0mcontent_block\u001b[0m\u001b[0;34m[\u001b[0m\u001b[0;34m\"type\"\u001b[0m\u001b[0;34m]\u001b[0m \u001b[0;32min\u001b[0m \u001b[0;34m[\u001b[0m\u001b[0;34m\"image\"\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0;34m\"video\"\u001b[0m\u001b[0;34m]\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 1818\u001b[0m ]\n\u001b[1;32m 1819\u001b[0m audio_fnames = [\n",
299
+ "\u001b[0;31mTypeError\u001b[0m: string indices must be integers, not 'str'"
300
+ ]
301
+ }
302
+ ],
303
+ "source": [
304
+ "# Pre-cache the 7 specialists' votes for every (task, step). Specialists\n",
305
+ "# are FROZEN, so this runs once and is reused across every GRPO step.\n",
306
+ "from app.council.runner import Council\n",
307
+ "from app.core.environment import StockerEnv\n",
308
+ "from app.core.tasks import list_task_ids\n",
309
+ "\n",
310
+ "council = Council(client=client, use_cache=True)\n",
311
+ "total = 0\n",
312
+ "for task_id in list_task_ids():\n",
313
+ " env = StockerEnv(task_id=task_id)\n",
314
+ " obs = env.reset().observation\n",
315
+ " while True:\n",
316
+ " for sp in council.specialists:\n",
317
+ " council._cached_vote(sp, obs) # writes .cache/council/<role>/...\n",
318
+ " total += 1\n",
319
+ " result = env.step({\"side\": \"hold\", \"quantity\": 0})\n",
320
+ " if result.done:\n",
321
+ " break\n",
322
+ " obs = result.observation\n",
323
+ "print(f\"cached {total} specialist votes across {len(list_task_ids())} tasks\")"
324
+ ]
325
  },
326
  {
327
  "cell_type": "code",
328
+ "execution_count": null,
329
  "id": "6af95f46",
 
330
  "metadata": {},
331
+ "outputs": [],
332
+ "source": [
333
+ "# Pre-training baseline rollout (specialists from cache, moderator = base Gemma)\n",
334
+ "!python -m training.eval_rollout --out training/runs/eval_pre\n",
335
+ "!cat training/runs/eval_pre/summary.csv"
336
+ ]
337
  },
338
  {
339
  "cell_type": "code",
 
341
  "id": "train",
342
  "metadata": {},
343
  "outputs": [],
344
+ "source": [
345
+ "# GRPO on the moderator LoRA. Specialist votes are read from cache —\n",
346
+ "# only the moderator is re-rolled per training step, so this is fast.\n",
347
+ "#\n",
348
+ "# Knobs: --num-generations is K candidates per prompt (GRPO group size).\n",
349
+ "# On a free T4 keep batch_size=1, grad_accum=8.\n",
350
+ "!python -m training.train_grpo \\\n",
351
+ " --epochs 2 \\\n",
352
+ " --num-generations 8 \\\n",
353
+ " --batch-size 1 \\\n",
354
+ " --grad-accum 8 \\\n",
355
+ " --lora-rank 16 \\\n",
356
+ " --lr 5e-6"
357
+ ]
358
  },
359
  {
360
  "cell_type": "code",
 
362
  "id": "eval",
363
  "metadata": {},
364
  "outputs": [],
365
+ "source": [
366
+ "# Post-training eval — load the trained adapter into the same client.\n",
367
+ "import glob, os\n",
368
+ "RUN_DIR = sorted(glob.glob(\"training/runs/grpo_*\"))[-1]\n",
369
+ "LORA_DIR = os.path.join(RUN_DIR, \"moderator-lora\")\n",
370
+ "print(\"Using LoRA:\", LORA_DIR)\n",
371
+ "\n",
372
+ "# Attach adapter and re-run eval. The adapter name \"moderator\" matches what\n",
373
+ "# Moderator.decide() requests via extra_body={'lora_request': {'name': ...}}\n",
374
+ "from peft import PeftModel\n",
375
+ "client.model = PeftModel.from_pretrained(client.model, LORA_DIR, adapter_name=\"moderator\")\n",
376
+ "client.moderator_lora = \"moderator\"\n",
377
+ "\n",
378
+ "!python -m training.eval_rollout --moderator-lora moderator --out training/runs/eval_post\n",
379
+ "!cat training/runs/eval_post/summary.csv"
380
+ ]
381
  },
382
  {
383
  "cell_type": "code",
384
+ "execution_count": null,
385
  "id": "b1d888ed",
 
386
  "metadata": {},
387
+ "outputs": [],
388
+ "source": [
389
+ "# Show training + eval curves inline\n",
390
+ "from IPython.display import Image, display\n",
391
+ "import os\n",
392
+ "for p in [f\"{RUN_DIR}/loss.png\", f\"{RUN_DIR}/reward.png\",\n",
393
+ " \"training/runs/eval_pre/reward_curve.png\",\n",
394
+ " \"training/runs/eval_post/reward_curve.png\",\n",
395
+ " \"training/runs/eval_pre/portfolio_curve.png\",\n",
396
+ " \"training/runs/eval_post/portfolio_curve.png\"]:\n",
397
+ " if os.path.exists(p):\n",
398
+ " print(p)\n",
399
+ " display(Image(p))"
400
+ ]
401
  }
402
  ],
403
  "metadata": {
404
  "kernelspec": {
405
+ "display_name": "Python 3 (ipykernel)",
406
  "language": "python",
407
  "name": "python3"
408
  },
409
  "language_info": {
410
+ "codemirror_mode": {
411
+ "name": "ipython",
412
+ "version": 3
413
+ },
414
+ "file_extension": ".py",
415
+ "mimetype": "text/x-python",
416
+ "name": "python",
417
+ "nbconvert_exporter": "python",
418
+ "pygments_lexer": "ipython3",
419
+ "version": "3.12.13"
420
  }
421
  },
422
  "nbformat": 4,
423
  "nbformat_minor": 5
424
+ }