kgrabko commited on
Commit
2c5f7b4
·
verified ·
1 Parent(s): 640884a

Create mmlu_eval_sft.py

Browse files
Files changed (1) hide show
  1. mmlu_eval_sft.py +311 -0
mmlu_eval_sft.py ADDED
@@ -0,0 +1,311 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # mmlu_eval_sft.py — eval loop for JiRack models + automatic SFT set from the failures.
3
+ #
4
+ # 1. cais/mmlu ("all", split "test": 14042 questions, 57 subjects)
5
+ # 2. your model behind an OpenAI-compatible endpoint (Ollama: http://localhost:11434/v1)
6
+ # 3. the answer letter is checked against the MMLU key HERE (no judge)
7
+ # 4. accuracy overall and per subject -> summary.json
8
+ # 5. only for wrong / unparsed answers: DeepSeek writes the step-by-step reasoning
9
+ # for the KNOWN correct answer -> sft.jsonl ("question -> reasoning -> answer")
10
+ #
11
+ # Both endpoints use the same openai SDK client; only base_url / api_key / model differ.
12
+ # Keys come from the environment only:
13
+ # MODEL_API_KEY (Ollama ignores it; default "ollama")
14
+ # DEEPSEEK_API_KEY (required for the sft stage)
15
+ #
16
+ # Install: pip install openai datasets
17
+ # Examples:
18
+ # # quick smoke test, 50 questions, eval only
19
+ # python3 mmlu_eval_sft.py --model jirack-llama2b --limit 50 --stage eval
20
+ # # full run: eval, then SFT from the failures
21
+ # export DEEPSEEK_API_KEY=... # in the terminal, never in chat
22
+ # python3 mmlu_eval_sft.py --model jirack-llama2b --stage all --workers 4
23
+ # # later the ternary 16B: only the config changes
24
+ # python3 mmlu_eval_sft.py --model jirack-16b-ternary --out runs/16b
25
+ #
26
+ # Restart-safe: results.jsonl and sft.jsonl are appended per question; a rerun with the
27
+ # same --out skips everything already done.
28
+ #
29
+ # License note: sft.jsonl rows carry "generator" = the DeepSeek model. Output of the
30
+ # DeepSeek API falls under DeepSeek's terms (distillation allowed, AI-generated content
31
+ # must be disclosed) — keep that field when the data goes into training.
32
+
33
+ import argparse
34
+ import json
35
+ import os
36
+ import random
37
+ import re
38
+ import sys
39
+ import threading
40
+ import time
41
+ from collections import defaultdict
42
+ from concurrent.futures import ThreadPoolExecutor, as_completed
43
+
44
+ LETTERS = "ABCD"
45
+
46
+ ANSWER_SYSTEM = ("You are taking a multiple-choice test. Reply with the letter of the "
47
+ "correct option (A, B, C or D) and nothing else.")
48
+
49
+ REASON_SYSTEM = ("You are an expert tutor. You are given a multiple-choice question and its "
50
+ "correct answer. Explain step by step why that answer is correct and, briefly, "
51
+ "why the other options are wrong. Use facts, not guesses. Be concise "
52
+ "(at most about 200 words). The last line must be exactly: Answer: <letter>")
53
+
54
+
55
+ # ------------------------------------------------------------------ helpers
56
+ def format_question(q, choices):
57
+ lines = [q.strip(), ""]
58
+ for letter, text in zip(LETTERS, choices):
59
+ lines.append(f"{letter}. {str(text).strip()}")
60
+ return "\n".join(lines)
61
+
62
+
63
+ def parse_letter(text, choices):
64
+ """Letter A-D from a model reply; None if it cannot be read unambiguously."""
65
+ if not text:
66
+ return None
67
+ t = text.strip()
68
+ # reasoning models: drop a <think>...</think> block
69
+ t = re.sub(r"<think>.*?</think>", "", t, flags=re.S).strip()
70
+ m = re.search(r"(?:answer|ответ)\s*(?:is|:)?\s*\(?([ABCD])\b", t, flags=re.I)
71
+ if m:
72
+ return m.group(1).upper()
73
+ m = re.match(r"^\(?([ABCD])(?:[\).:,\s]|$)", t)
74
+ if m:
75
+ return m.group(1).upper()
76
+ found = set(re.findall(r"\b([ABCD])\b", t))
77
+ if len(found) == 1:
78
+ return found.pop()
79
+ # model replied with the option text instead of the letter
80
+ low = t.lower().rstrip(".")
81
+ hits = [LETTERS[i] for i, c in enumerate(choices) if str(c).strip().lower() == low]
82
+ return hits[0] if len(hits) == 1 else None
83
+
84
+
85
+ def final_letter(text):
86
+ m = re.findall(r"Answer:\s*\(?([ABCD])\b", text or "", flags=re.I)
87
+ return m[-1].upper() if m else None
88
+
89
+
90
+ def load_done(path, key="idx"):
91
+ done = {}
92
+ if os.path.exists(path):
93
+ with open(path, encoding="utf-8") as f:
94
+ for line in f:
95
+ line = line.strip()
96
+ if line:
97
+ row = json.loads(line)
98
+ done[row[key]] = row
99
+ return done
100
+
101
+
102
+ class JsonlWriter:
103
+ def __init__(self, path):
104
+ self.f = open(path, "a", encoding="utf-8")
105
+ self.lock = threading.Lock()
106
+
107
+ def write(self, row):
108
+ with self.lock:
109
+ self.f.write(json.dumps(row, ensure_ascii=False) + "\n")
110
+ self.f.flush()
111
+
112
+ def close(self):
113
+ self.f.close()
114
+
115
+
116
+ def chat(client, model, messages, max_tokens, temperature, retries, timeout):
117
+ """chat.completions with retry on rate limits, timeouts, connection and 5xx errors."""
118
+ import openai
119
+ delay = 2.0
120
+ for attempt in range(1, retries + 1):
121
+ try:
122
+ r = client.chat.completions.create(model=model, messages=messages,
123
+ max_tokens=max_tokens, temperature=temperature,
124
+ timeout=timeout)
125
+ return r.choices[0].message.content or ""
126
+ except (openai.RateLimitError, openai.APITimeoutError, openai.APIConnectionError,
127
+ openai.InternalServerError) as e:
128
+ err = e
129
+ except openai.APIStatusError as e:
130
+ if e.status_code < 500 and e.status_code != 429:
131
+ raise # 400/401/404: retrying will not help
132
+ err = e
133
+ if attempt == retries:
134
+ raise err
135
+ time.sleep(delay + random.random())
136
+ delay = min(delay * 2, 60)
137
+
138
+
139
+ # ------------------------------------------------------------------ data
140
+ def load_mmlu(args):
141
+ from datasets import load_dataset
142
+ ds = load_dataset("cais/mmlu", args.config, split=args.split)
143
+ rows = [{"idx": i, "subject": r["subject"], "question": r["question"],
144
+ "choices": list(r["choices"]), "answer": LETTERS[int(r["answer"])]}
145
+ for i, r in enumerate(ds)]
146
+ if args.subjects:
147
+ keep = set(args.subjects.split(","))
148
+ rows = [r for r in rows if r["subject"] in keep]
149
+ if args.shuffle:
150
+ random.Random(args.seed).shuffle(rows)
151
+ if args.limit:
152
+ rows = rows[:args.limit]
153
+ return rows
154
+
155
+
156
+ # ------------------------------------------------------------------ stage 1: eval
157
+ def run_eval(args, rows):
158
+ from openai import OpenAI
159
+ client = OpenAI(base_url=args.model_base_url,
160
+ api_key=os.environ.get("MODEL_API_KEY", "ollama"), max_retries=0)
161
+ path = os.path.join(args.out, "results.jsonl")
162
+ done = load_done(path)
163
+ todo = [r for r in rows if r["idx"] not in done]
164
+ print(f"[eval] {args.model} @ {args.model_base_url}: {len(rows)} questions, "
165
+ f"{len(rows) - len(todo)} already done, {len(todo)} to run")
166
+ w = JsonlWriter(path)
167
+
168
+ def one(r):
169
+ prompt = format_question(r["question"], r["choices"])
170
+ try:
171
+ reply = chat(client, args.model,
172
+ [{"role": "system", "content": ANSWER_SYSTEM},
173
+ {"role": "user", "content": prompt}],
174
+ args.answer_max_tokens, args.temperature, args.retries, args.timeout)
175
+ error = None
176
+ except Exception as e: # keep going; the row is marked
177
+ reply, error = "", f"{type(e).__name__}: {e}"
178
+ pred = parse_letter(reply, r["choices"])
179
+ row = {**r, "model": args.model, "reply": reply, "pred": pred,
180
+ "correct": pred == r["answer"], "error": error}
181
+ w.write(row)
182
+ return row
183
+
184
+ t0, n = time.time(), 0
185
+ with ThreadPoolExecutor(max_workers=args.workers) as ex:
186
+ for fut in as_completed([ex.submit(one, r) for r in todo]):
187
+ fut.result()
188
+ n += 1
189
+ if n % 50 == 0 or n == len(todo):
190
+ print(f"[eval] {n}/{len(todo)} | {(time.time() - t0) / n:.2f} s/question")
191
+ w.close()
192
+
193
+
194
+ def summarize(args, rows):
195
+ wanted = {r["idx"] for r in rows}
196
+ res = [r for i, r in load_done(os.path.join(args.out, "results.jsonl")).items() if i in wanted]
197
+ by = defaultdict(lambda: [0, 0])
198
+ for r in res:
199
+ by[r["subject"]][0] += r["correct"]
200
+ by[r["subject"]][1] += 1
201
+ n = len(res)
202
+ correct = sum(r["correct"] for r in res)
203
+ summary = {
204
+ "model": args.model, "dataset": f"cais/mmlu/{args.config}/{args.split}",
205
+ "questions": n, "correct": correct,
206
+ "accuracy": round(correct / n, 4) if n else None,
207
+ "unparsed": sum(r["pred"] is None and not r["error"] for r in res),
208
+ "api_errors": sum(bool(r["error"]) for r in res),
209
+ "per_subject": {s: {"accuracy": round(c / t, 4), "n": t}
210
+ for s, (c, t) in sorted(by.items(), key=lambda kv: kv[1][0] / kv[1][1])},
211
+ }
212
+ with open(os.path.join(args.out, "summary.json"), "w", encoding="utf-8") as f:
213
+ json.dump(summary, f, ensure_ascii=False, indent=2)
214
+ print(f"[summary] accuracy {summary['accuracy']} ({correct}/{n}) | unparsed "
215
+ f"{summary['unparsed']} | api errors {summary['api_errors']} (25% = random guessing)")
216
+ for s, v in list(summary["per_subject"].items())[:5]:
217
+ print(f" weakest: {s:40s} {v['accuracy']:.3f} (n={v['n']})")
218
+ return res
219
+
220
+
221
+ # ------------------------------------------------------------------ stage 2: SFT from failures
222
+ def run_sft(args, res):
223
+ key = os.environ.get("DEEPSEEK_API_KEY")
224
+ if not key:
225
+ sys.exit("DEEPSEEK_API_KEY is not set (export it in the terminal)")
226
+ from openai import OpenAI
227
+ client = OpenAI(base_url=args.deepseek_base_url, api_key=key, max_retries=0)
228
+ # API errors are excluded: the model never answered, that is not a knowledge failure
229
+ fails = [r for r in res if not r["correct"] and not r["error"]]
230
+ path = os.path.join(args.out, "sft.jsonl")
231
+ done = load_done(path, key="idx")
232
+ rej_path = os.path.join(args.out, "sft_rejected.jsonl")
233
+ rejected = load_done(rej_path, key="idx")
234
+ todo = [r for r in fails if r["idx"] not in done and r["idx"] not in rejected]
235
+ print(f"[sft] {len(fails)} failures, {len(fails) - len(todo)} already handled, "
236
+ f"{len(todo)} to send to {args.deepseek_model}")
237
+ w, wr = JsonlWriter(path), JsonlWriter(rej_path)
238
+
239
+ def one(r):
240
+ prompt = format_question(r["question"], r["choices"])
241
+ ans = r["answer"]
242
+ ask = (f"{prompt}\n\nCorrect answer: {ans}. "
243
+ f"{r['choices'][LETTERS.index(ans)]}\n\nExplain why.")
244
+ try:
245
+ text = chat(client, args.deepseek_model,
246
+ [{"role": "system", "content": REASON_SYSTEM},
247
+ {"role": "user", "content": ask}],
248
+ args.reason_max_tokens, 0.3, args.retries, args.timeout).strip()
249
+ except Exception as e:
250
+ wr.write({"idx": r["idx"], "reason": f"api: {type(e).__name__}: {e}"})
251
+ return False
252
+ if final_letter(text) != ans: # explanation argues for another option -> drop it
253
+ wr.write({"idx": r["idx"], "reason": "final letter mismatch", "text": text})
254
+ return False
255
+ body = re.sub(r"\n*\s*Answer:\s*\(?[ABCD]\)?\.?\s*$", "", text, flags=re.I).strip()
256
+ assistant = f"{body}\n\nAnswer: {ans}. {r['choices'][LETTERS.index(ans)]}"
257
+ w.write({
258
+ "idx": r["idx"], "subject": r["subject"],
259
+ "messages": [{"role": "user", "content": prompt},
260
+ {"role": "assistant", "content": assistant}],
261
+ "model_pred": r["pred"], "answer": ans,
262
+ "source": f"cais/mmlu/{args.config}/{args.split}",
263
+ "generator": f"deepseek:{args.deepseek_model}",
264
+ })
265
+ return True
266
+
267
+ ok = 0
268
+ with ThreadPoolExecutor(max_workers=args.workers) as ex:
269
+ for i, fut in enumerate(as_completed([ex.submit(one, r) for r in todo]), 1):
270
+ ok += fut.result()
271
+ if i % 25 == 0 or i == len(todo):
272
+ print(f"[sft] {i}/{len(todo)} | written {ok}")
273
+ w.close()
274
+ wr.close()
275
+ print(f"[sft] {path}: {len(load_done(path))} rows | rejected: {rej_path}")
276
+
277
+
278
+ # ------------------------------------------------------------------ main
279
+ def main():
280
+ ap = argparse.ArgumentParser(description="MMLU eval + SFT-from-failures via OpenAI-compatible APIs")
281
+ ap.add_argument("--stage", choices=["eval", "sft", "all"], default="all")
282
+ ap.add_argument("--out", default="runs/llama2b", help="output folder")
283
+ ap.add_argument("--model", required=True, help="model name on your server (ollama list)")
284
+ ap.add_argument("--model-base-url", default=os.environ.get("MODEL_BASE_URL", "http://localhost:11434/v1"))
285
+ ap.add_argument("--deepseek-base-url", default=os.environ.get("DEEPSEEK_BASE_URL", "https://api.deepseek.com"))
286
+ ap.add_argument("--deepseek-model", default=os.environ.get("DEEPSEEK_MODEL", "deepseek-chat"))
287
+ ap.add_argument("--config", default="all")
288
+ ap.add_argument("--split", default="test")
289
+ ap.add_argument("--subjects", default="", help="comma list, e.g. high_school_geography,world_religions")
290
+ ap.add_argument("--limit", type=int, default=0, help="0 = all questions")
291
+ ap.add_argument("--shuffle", action="store_true", help="random subset with --limit")
292
+ ap.add_argument("--seed", type=int, default=42)
293
+ ap.add_argument("--workers", type=int, default=1, help="parallel requests")
294
+ ap.add_argument("--temperature", type=float, default=0.0)
295
+ ap.add_argument("--answer-max-tokens", type=int, default=16)
296
+ ap.add_argument("--reason-max-tokens", type=int, default=600)
297
+ ap.add_argument("--retries", type=int, default=5)
298
+ ap.add_argument("--timeout", type=float, default=120.0)
299
+ args = ap.parse_args()
300
+
301
+ os.makedirs(args.out, exist_ok=True)
302
+ rows = load_mmlu(args)
303
+ if args.stage in ("eval", "all"):
304
+ run_eval(args, rows)
305
+ res = summarize(args, rows)
306
+ if args.stage in ("sft", "all"):
307
+ run_sft(args, res)
308
+
309
+
310
+ if __name__ == "__main__":
311
+ main()