DFveloper's picture
download
raw
11.6 kB
"""Korean chain-of-thought generation — the one gap mixing cannot close.
A scan of all 872,052 tool rows and every source in sft_dataset/merged.jsonl
returned `KOREAN with balanced <think>: 0`. The model's thinking mode is
therefore English-only: it would reason for English math and code and never for
Korean, on a Korean-first model. No reweighting fixes that; the data has to be
made.
The design follows rollout.py's principle rather than the earlier "ask the model
to author a record" approach: **verify against ground truth that already exists**
instead of trusting the teacher. Seeds are real Korean questions that ship with
their own answers, so a generation can be checked rather than hoped over:
kmmlu_train 14,935 multiple choice, answer ends '정답: (B)' -> exact
orca_math_korean 13,600 word problems with a numeric answer -> exact
kopen_platypus 9,552 reasoning prose, no extractable answer -> unverified
Only the first two can be graded. That is the point: a wrong Korean reasoning
trace that reaches the right letter is still useful training signal, but one that
reaches the wrong letter is actively harmful, and without seeds carrying answers
there is no way to tell them apart.
Teacher is deepseek-ai/DeepSeek-V4-Flash-0731 (local, 256 experts / 6 active).
Two non-obvious requirements, both learned the hard way in this repo:
kv_cache_dtype must be fp8 (DeepSeek-V4's MLA layout asserts otherwise), and
vLLM strips the EOS token that the official parser asserts on -- re-append it,
but only when generation stopped on EOS rather than on the length limit, or
truncated reasoning gets smuggled in as complete.
env -u PYTHONPATH /home/work/MKD-LLM/vllm_env/bin/python \
data/korean_cot/generate_ko_cot.py --model <path> --bake-off --n 100
env -u PYTHONPATH /home/work/MKD-LLM/vllm_env/bin/python \
data/korean_cot/generate_ko_cot.py --model <path> --target 25000
"""
from __future__ import annotations
import argparse
import hashlib
import json
import random
import re
import sys
import time
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent.parent
sys.path.insert(0, str(REPO / "data" / "toolcall"))
import encoding_dsv4 as ENC # noqa: E402
MERGED = REPO / "data" / "sft_dataset" / "merged.jsonl"
EOS = "<|end▁of▁sentence|>"
THINKING = "thinking"
HANGUL = re.compile("[가-힣]")
# Seeds whose answers can be graded, and the share of the plan each gets.
GRADED = {"kmmlu_train": 0.40, "orca_math_korean": 0.40}
UNGRADED = {"kopen_platypus": 0.20}
SEED_SOURCES = {**GRADED, **UNGRADED}
SYSTEM = (
"당신은 한국어로 사고하고 한국어로 답변하는 전문가입니다.\n"
"반드시 다음 규칙을 지키십시오:\n"
"1. 추론 과정 전체를 한국어로 작성하십시오. 영어나 중국어를 사용하지 마십시오.\n"
"2. 단계적으로 근거를 밝히며 생각하십시오.\n"
"3. 추론이 끝나면 최종 답변을 한국어로 간결하게 제시하십시오.\n"
"4. 객관식 문제라면 마지막 줄에 '정답: (X)' 형식으로 답을 쓰십시오.\n"
"5. 계산 문제라면 마지막 줄에 '정답: <숫자>' 형식으로 답을 쓰십시오."
)
CHOICE = re.compile(r"정답\s*[::]\s*\(?\s*([A-Ea-e])\s*\)?")
NUMBER = re.compile(r"-?\d[\d,]*\.?\d*")
def hangul_ratio(s: str) -> float:
s = re.sub(r"\s+", "", s or "")
return len(HANGUL.findall(s)) / max(len(s), 1)
def grade_key(source: str, answer_text: str):
"""The ground-truth answer a generation will be checked against, or None."""
if source == "kmmlu_train":
m = CHOICE.search(answer_text)
return ("choice", m.group(1).upper()) if m else None
if source == "orca_math_korean":
nums = NUMBER.findall(answer_text)
return ("number", nums[-1].replace(",", "")) if nums else None
return None
def check(kind_val, produced: str) -> bool:
kind, truth = kind_val
if kind == "choice":
m = CHOICE.search(produced)
return bool(m) and m.group(1).upper() == truth
nums = NUMBER.findall(produced)
if not nums:
return False
try:
return abs(float(nums[-1].replace(",", "")) - float(truth)) < 1e-6
except ValueError:
return nums[-1].replace(",", "") == truth
def load_seeds(rng: random.Random, n_wanted: int):
"""Collect Korean questions that carry their own answers."""
pools: dict[str, list] = {s: [] for s in SEED_SOURCES}
for line in open(MERGED, encoding="utf-8"):
if '"lang": "ko"' not in line and '"lang":"ko"' not in line:
continue
r = json.loads(line)
s = r.get("source")
if s not in pools:
continue
msgs = r.get("messages") or []
q = next((m.get("content") for m in msgs if m.get("role") == "user"), None)
a = next((m.get("content") for m in msgs if m.get("role") == "assistant"), "")
if not q or hangul_ratio(q) < 0.10:
continue
key = grade_key(s, a or "") if s in GRADED else None
if s in GRADED and key is None:
continue # ungradable: no answer to check against
pools[s].append((q, key, s))
for s, v in pools.items():
rng.shuffle(v)
print(f" seeds {s:22s} {len(v):>7,}", flush=True)
jobs = []
for s, share in SEED_SOURCES.items():
jobs += pools[s][: max(1, int(n_wanted * share))]
rng.shuffle(jobs)
return jobs
def parse_completion(gen):
"""Official parser, with the two adjustments vLLM forces (see module docstring)."""
text = gen.text
if getattr(gen, "finish_reason", None) == "length":
return None # truncated reasoning: reject, never repair
if text.startswith("<think>"):
text = text[len("<think>"):]
if EOS not in text:
text += EOS
try:
return ENC.parse_message_from_completion_text(text, thinking_mode=THINKING)
except Exception: # noqa: BLE001
return None
def build_record(parsed, q: str, key, source: str, idx: int):
"""Gate a generation, and shape it the way assemble.py expects."""
reasoning = (parsed.get("reasoning_content") or "").strip()
answer = (parsed.get("content") or "").strip()
if not reasoning or not answer:
return None, "empty"
# The whole purpose is Korean reasoning. A Korean answer wrapped around an
# English thought process teaches the model to think in English.
if hangul_ratio(reasoning) < 0.30:
return None, "reasoning_not_korean"
if hangul_ratio(answer) < 0.30:
return None, "answer_not_korean"
if len(reasoning) < 80:
return None, "reasoning_too_short"
if any(reasoning.count(reasoning[-n:]) >= 4 for n in (40, 80) if reasoning[-n:]):
return None, "degenerate"
if key is not None and not check(key, answer):
return None, "wrong_answer"
content = f"<think>\n{reasoning}\n</think>\n\n{answer}"
if content.count("<think>") != content.count("</think>"):
return None, "unbalanced_think"
return {
"id": f"ko_cot_{idx:07d}",
"source": "mkd_korean_cot_v1",
"lang": "ko",
"category": "korean_cot_verified" if key is not None else "korean_cot",
"license": "generated",
"share_alike": False,
"seed_source": source,
"messages": [{"role": "user", "content": q},
{"role": "assistant", "content": content}],
}, None
def dedup(recs):
seen, out = set(), []
for r in recs:
h = hashlib.blake2b(
re.sub(r"\s+", " ", r["messages"][0]["content"]).strip().lower().encode(),
digest_size=8).digest()
if h not in seen:
seen.add(h)
out.append(r)
return out
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--model", required=True)
ap.add_argument("--target", type=int, default=25000, help="verified records wanted")
ap.add_argument("--bake-off", action="store_true")
ap.add_argument("--n", type=int, default=100)
ap.add_argument("--out", default="data/korean_cot/ko_cot.jsonl")
ap.add_argument("--tp", type=int, default=4)
ap.add_argument("--max-model-len", type=int, default=8192)
ap.add_argument("--kv-cache-dtype", default="fp8")
ap.add_argument("--max-tokens", type=int, default=2048)
ap.add_argument("--reasoning-effort", default="low", choices=["low", "high", "max"])
ap.add_argument("--seed", type=int, default=1234)
args = ap.parse_args()
rng = random.Random(args.seed)
# Yield here should beat the 48.4% the tool run managed: there is no tool
# execution to corrupt, so a generation fails only on language, format or a
# wrong graded answer.
n_raw = args.n if args.bake_off else int(args.target / 0.70)
print(f"loading seeds from {MERGED.name} ...", flush=True)
jobs = load_seeds(rng, n_raw)
print(f"planned {len(jobs):,} generations "
f"(target {args.target:,} verified)\n", flush=True)
if not jobs:
raise SystemExit("no usable seeds found")
prompts = [
ENC.encode_messages(
[{"role": "system", "content": SYSTEM}, {"role": "user", "content": q}],
thinking_mode=THINKING, reasoning_effort=args.reasoning_effort)
for q, _, _ in jobs
]
from vllm import LLM, SamplingParams
llm = LLM(model=args.model, tensor_parallel_size=args.tp, trust_remote_code=True,
max_model_len=args.max_model_len, gpu_memory_utilization=0.90,
kv_cache_dtype=args.kv_cache_dtype)
sp = SamplingParams(temperature=0.7, top_p=0.95,
max_tokens=args.max_tokens, seed=args.seed)
t0 = time.time()
outs = llm.generate(prompts, sp)
dt = time.time() - t0
n_tok = sum(len(o.outputs[0].token_ids) for o in outs)
print(f"\ngenerated {len(outs):,} in {dt/60:.1f} min | {n_tok:,} tokens | "
f"{n_tok/max(dt,1):,.0f} tok/s", flush=True)
kept, why = [], {}
for i, (o, (q, key, src)) in enumerate(zip(outs, jobs)):
parsed = parse_completion(o.outputs[0])
if parsed is None:
why["unparseable_or_truncated"] = why.get("unparseable_or_truncated", 0) + 1
continue
rec, reason = build_record(parsed, q, key, src, i)
if rec:
kept.append(rec)
else:
why[reason] = why.get(reason, 0) + 1
before = len(kept)
kept = dedup(kept)
print(f"\ngates passed {before:,}/{len(outs):,} ({before/len(outs)*100:.1f}%)")
print(f"after dedup {len(kept):,} (removed {before-len(kept):,})")
print("rejected:")
for k, v in sorted(why.items(), key=lambda x: -x[1]):
print(f" {k:28s} {v:>7,}")
verified = sum(1 for r in kept if r["category"] == "korean_cot_verified")
print(f"\nanswer-verified {verified:,} / {len(kept):,}")
out = REPO / args.out
out.parent.mkdir(parents=True, exist_ok=True)
if args.bake_off:
out = out.with_suffix(".bakeoff.jsonl")
with open(out, "w", encoding="utf-8") as f:
for r in kept:
f.write(json.dumps(r, ensure_ascii=False) + "\n")
print(f"-> {out}")
if args.bake_off and kept:
print("\n===== SAMPLE (review before committing GPU hours) =====")
for r in kept[:2]:
print(f"\n--- seed={r['seed_source']} category={r['category']}")
print(f"Q: {r['messages'][0]['content'][:220]}")
print(f"A: {r['messages'][1]['content'][:700]}")
if __name__ == "__main__":
main()

Xet Storage Details

Size:
11.6 kB
·
Xet hash:
335aa93ccd0be4d9ca787e9b4d20260596a2f18e733ae10b2d0fb870337d6184

Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.