게시판바둑 on-policy 학습: 대국 로그를 KataGo 라벨로 바꾸기예제 소스

예제 소스

바둑 on-policy 학습: 대국 로그를 KataGo 라벨로 바꾸기 · 파일 169개

daemon_overnight_mix.py

#!/usr/bin/env python3
"""밤새 혼합(좌표+수) 학습 ↔ 좌표 검증 루프.

  # Cursor 터미널에서 로그 보이며 실행 (권장)
  python3 scripts/daemon_overnight_mix.py --foreground

  # OS 분리 데몬 (비권장)
  python3 scripts/daemon_overnight_mix.py

시작: vlm_models/lora_mix_qwen_r1 (없으면 lora_coord_qwen)
마감: 다음날 08:00
"""
from __future__ import annotations

import json
import os
import random
import subprocess
import sys
import time
from pathlib import Path

ROOT = Path("~/project")
PY = str(ROOT / ".venv" / "bin" / "python")
LOG = ROOT / "logs" / "vlm_eval_day" / "mix_overnight.log"
STATUS = ROOT / "logs" / "vlm_eval_day" / "MIX_STATUS.md"
COORD_JSONL = ROOT / "sft_data" / "coord_v3" / "train.jsonl"
MOVE_JSONL = ROOT / "sft_data" / "baduk_v3_aligned" / "train.jsonl"
MIX_R1 = ROOT / "vlm_models" / "lora_mix_qwen_r1"
COORD = ROOT / "vlm_models" / "lora_coord_qwen"
DEADLINE_HOUR = 8
LOCATE_GATE = 90.0
MODEL = "********"

ROUNDS = [
    (1200, 2000, 1200, "1.5e-5"),
    (1000, 2500, 1500, "1e-5"),
    (1000, 3000, 1500, "1e-5"),
    (800, 3500, 1800, "8e-6"),
    (800, 4000, 1800, "8e-6"),
    (600, 4500, 2000, "6e-6"),
]

FOREGROUND = False


def now() -> str:
    return time.strftime("%Y-%m-%d %H:%M:%S")


def past_deadline() -> bool:
    """시작일(스탬프) 다음날 DEADLINE_HOUR 이후면 True."""
    stamp = ROOT / "logs" / "vlm_eval_day" / ".overnight_start_ymd"
    t = time.localtime()
    today = time.strftime("%Y-%m-%d", t)
    if not stamp.exists():
        stamp.write_text(today, encoding="utf-8")
        start = today
    else:
        start = stamp.read_text(encoding="utf-8").strip() or today
    if today == start:
        return False
    return t.tm_hour >= DEADLINE_HOUR


def refresh_deadline_window() -> None:
    """재개 시 오늘을 시작일로 리셋 → 내일 08:00까지."""
    stamp = ROOT / "logs" / "vlm_eval_day" / ".overnight_start_ymd"
    stamp.parent.mkdir(parents=True, exist_ok=True)
    stamp.write_text(time.strftime("%Y-%m-%d"), encoding="utf-8")


def round_passed(tag: int) -> bool:
    summary = ROOT / f"logs/vlm_eval_day/coord_mix_r{tag}_summary.json"
    adapter = ROOT / f"vlm_models/lora_mix_qwen_r{tag}/adapters.safetensors"
    if not summary.exists() or not adapter.exists():
        return False
    return locate_acc(summary) >= LOCATE_GATE


def log(msg: str) -> None:
    line = f"[{now()}] {msg}"
    print(line, flush=True)
    LOG.parent.mkdir(parents=True, exist_ok=True)
    with LOG.open("a", encoding="utf-8") as f:
        f.write(line + "\n")


def write_status(md: str) -> None:
    STATUS.write_text(md, encoding="utf-8")


def run(cmd: list[str], log_path: Path) -> int:
    log("RUN " + " ".join(cmd))
    env = os.environ.copy()
    env["HF_HOME"] = str(ROOT / "vlm_models")
    env["HF_HUB_CACHE"] = str(ROOT / "vlm_models")
    env["TOKENIZERS_PARALLELISM"] = "********"
    env["PYTHONUNBUFFERED"] = "1"
    log_path.parent.mkdir(parents=True, exist_ok=True)
    with log_path.open("a", encoding="utf-8") as f:
        f.write(f"\n===== {now()} =====\n")
        f.flush()
        if FOREGROUND:
            p = subprocess.Popen(
                cmd,
                cwd=str(ROOT),
                env=env,
                stdout=subprocess.PIPE,
                stderr=subprocess.STDOUT,
                text=True,
                bufsize=1,
            )
            assert p.stdout is not None
            for line in p.stdout:
                sys.stdout.write(line)
                sys.stdout.flush()
                f.write(line)
                f.flush()
            rc = int(p.wait())
        else:
            p = subprocess.run(cmd, cwd=str(ROOT), env=env, stdout=f, stderr=subprocess.STDOUT)
            rc = int(p.returncode)
    log(f"EXIT {rc}")
    return rc


def locate_acc(summary: Path) -> float:
    if not summary.exists():
        return -1.0
    d = json.loads(summary.read_text(encoding="utf-8"))
    return float(d.get("by_kind", {}).get("locate", {}).get("acc", d.get("accuracy", -1)))


def build_mix(coord_n: int, move_n: int, out_path: Path, seed: int) -> int:
    rng = random.Random(seed)
    with COORD_JSONL.open(encoding="utf-8") as f:
        cl = [x for x in f if x.strip()]
    with MOVE_JSONL.open(encoding="utf-8") as f:
        ml = [x for x in f if x.strip()]
    rng.shuffle(cl)
    rng.shuffle(ml)
    lines = cl[: min(coord_n, len(cl))] + ml[: min(move_n, len(ml))]
    rng.shuffle(lines)
    out_path.parent.mkdir(parents=True, exist_ok=True)
    with out_path.open("w", encoding="utf-8") as f:
        f.writelines(lines)
    return len(lines)


def pipeline() -> int:
    os.chdir(ROOT)
    subprocess.run(["pkill", "-f", "vlm_server serve"], capture_output=True)

    base = MIX_R1 if (MIX_R1 / "adapters.safetensors").exists() else COORD
    if not (base / "adapters.safetensors").exists():
        log("FATAL: no base adapter")
        return 2

    # 재개: 마감 창을 오늘→내일 08:00 으로 연장
    refresh_deadline_window()

    mode = "foreground/Cursor" if FOREGROUND else "daemon"
    write_status(
        f"# Overnight mix\n\n- resumed: {now()}\n- mode: **{mode}**\n"
        f"- until: tomorrow {DEADLINE_HOUR}:00\n"
        f"- base: `{base.name}`\n"
        f"- focus: skip PASS rounds, continue incomplete\n"
    )
    log(f"=== OVERNIGHT MIX RESUME mode={mode} base={base} until tomorrow {DEADLINE_HOUR}:00 ===")

    if not COORD_JSONL.exists() or not MOVE_JSONL.exists():
        log("FATAL: datasets missing")
        return 2

    good_adapter = str(base)
    history: list[str] = []
    fail_streak = 0
    out_start = 2 if base == MIX_R1 else 1

    # 이미 PASS 한 라운드 반영
    for tip in range(out_start, out_start + len(ROUNDS) + 2):
        if round_passed(tip):
            good_adapter = str(ROOT / f"vlm_models/lora_mix_qwen_r{tip}")
            history.append(f"- R{tip} already PASS locate (skip) → `lora_mix_qwen_r{tip}`")
            log(f"skip R{tip} (already PASS)")

    for round_i, (coord_n, move_n, max_samples, lr) in enumerate(ROUNDS):
        if past_deadline():
            break
        tag = out_start + round_i
        if round_passed(tag):
            continue

        if fail_streak > 0:
            coord_n = int(coord_n * 1.4)
            move_n = max(500, int(move_n * 0.55))
            lr = "1e-5"

        out_dir = ROOT / f"vlm_models/lora_mix_qwen_r{tag}"
        mix_path = ROOT / f"sft_data/mix_overnight_r{tag}/train.jsonl"
        n = build_mix(coord_n, move_n, mix_path, seed=300 + tag)

        # 중단된 라운드: 체크포인트가 있으면 그 위에서 남은 iters만
        resume_from = good_adapter
        train_samples = max_samples
        if (out_dir / "adapters.safetensors").exists() and not round_passed(tag):
            # r3 @1000/1500 등 → 남은 분량만 추가 학습
            remain = max(300, max_samples // 3)
            resume_from = str(out_dir)
            train_samples = remain
            log(f"R{tag}: continue from partial checkpoint +{train_samples} iters")

        log(
            f"R{tag}: mix={n} coord~{coord_n} move~{move_n} samples={train_samples} "
            f"lr={lr} resume={resume_from}"
        )
        write_status(
            "\n".join(
                [
                    "# Overnight mix",
                    f"- updated: {now()}",
                    f"- mode: **{mode}**",
                    f"- round: **r{tag}** / train",
                    f"- mix: coord~{coord_n} + move~{move_n}",
                    f"- resume: `{resume_from}`",
                    f"- lr: {lr}, max_samples: {train_samples}",
                    "",
                    *history[-12:],
                    "",
                ]
            )
        )
        if past_deadline():
            break

        rc = run(
            [
                PY,
                "-m",
                "vlm_server.train_lora",
                "--dataset-jsonl",
                str(mix_path),
                "--model",
                MODEL,
                "--output-dir",
                str(out_dir),
                "--resume-from",
                resume_from,
                "--epochs",
                "1",
                "--batch-size",
                "1",
                "--grad-accum",
                "8",
                "--lr",
                lr,
                "--lora-rank",
                "16",
                "--lora-alpha",
                "32",
                "--max-seq-length",
                "4096",
                "--max-samples",
                str(train_samples),
                "--grad-checkpoint",
            ],
            ROOT / f"logs/vlm_eval_day/mix_train_r{tag}.log",
        )
        if rc != 0:
            history.append(f"- R{tag} train FAILED rc={rc}")
            fail_streak += 1
            continue

        sum_path = ROOT / f"logs/vlm_eval_day/coord_mix_r{tag}_summary.json"
        run(
            [
                PY,
                "-m",
                "vlm_server.verify_coords",
                "--model",
                MODEL,
                "--lora-dir",
                str(out_dir),
                "--board-format",
                "aligned",
                "--trials",
                "48",
                "--out-jsonl",
                str(ROOT / f"logs/vlm_eval_day/coord_mix_r{tag}.jsonl"),
                "--out-summary",
                str(sum_path),
            ],
            ROOT / "logs/vlm_eval_day/coord_verify_mix.log",
        )
        acc = locate_acc(sum_path)
        log(f"R{tag} locate={acc}% gate={LOCATE_GATE}")
        if acc >= LOCATE_GATE:
            good_adapter = str(out_dir)
            fail_streak = 0
            history.append(f"- R{tag} PASS locate **{acc}%** → `{out_dir.name}`")
        else:
            fail_streak += 1
            history.append(
                f"- R{tag} FAIL locate **{acc}%** → rollback `{Path(good_adapter).name}`"
            )
            if fail_streak >= 3:
                history.append("- fail_streak>=3 → stop")
                break
        write_status(
            "\n".join(
                [
                    "# Overnight mix",
                    f"- updated: {now()}",
                    f"- mode: **{mode}**",
                    f"- last: r{tag} locate **{acc}%**",
                    f"- best: `{good_adapter}`",
                    f"- fail_streak: {fail_streak}",
                    "",
                    "## history",
                    *history,
                    "",
                ]
            )
        )

    write_status(
        (STATUS.read_text(encoding="utf-8") if STATUS.exists() else "")
        + f"\n\n## finished\n- {now()}\n- best: `{good_adapter}`\n"
    )
    log(f"=== OVERNIGHT MIX END best={good_adapter} ===")
    return 0


def main() -> int:
    global FOREGROUND
    FOREGROUND = "--foreground" in sys.argv

    if FOREGROUND:
        return pipeline()

    # OS double-fork daemon
    if os.fork() > 0:
        return 0
    os.setsid()
    if os.fork() > 0:
        os._exit(0)
    os.chdir(ROOT)
    os.umask(0)
    with LOG.open("a", encoding="utf-8") as f:
        os.dup2(f.fileno(), 1)
        os.dup2(f.fileno(), 2)
    dn = os.open(os.devnull, os.O_RDONLY)
    os.dup2(dn, 0)
    os.close(dn)
    os._exit(pipeline())


if __name__ == "__main__":
    raise SystemExit(main())