diff --git a/docs/FINETUNING.md b/docs/FINETUNING.md index 27b7219..79162ba 100644 --- a/docs/FINETUNING.md +++ b/docs/FINETUNING.md @@ -18,12 +18,17 @@ `용어집` 화면에서 "원문 → 각 언어 역어"를 등록하면 끝입니다. 학습 없이 바로 적용됩니다. -동작 방식은 번역 백엔드에 따라 다릅니다. +동작 방식은 번역 모델에 따라 다릅니다. -- **NLLB 계열(1~3티어)** — 등록 단어를 `⟦0⟧` 같은 토큰으로 바꿔치기해 모델이 아예 - 건드리지 못하게 한 뒤, 번역이 끝나면 지정한 역어로 되돌립니다. 100% 보장됩니다. -- **LLM 계열(4~5티어)** — 그 문장에 실제로 나온 용어만 골라 프롬프트에 - "이 용어는 이렇게 옮겨라"로 넣어줍니다. 조사·어미까지 문맥에 맞게 붙습니다. +- **1~4티어 (NLLB, Seed-X)** — 등록 단어를 `⟦0⟧` 같은 토큰으로 바꿔치기해 모델이 아예 + 건드리지 못하게 한 뒤, 번역이 끝나면 지정한 역어로 되돌립니다. +- **5티어 (Qwen3)** — 그 문장에 실제로 나온 용어만 골라 프롬프트에 + "이 용어는 이렇게 옮겨라"로 넣어줍니다. 조사·어미까지 문맥에 맞게 붙어 더 자연스럽습니다. + +4티어 Seed-X가 LLM인데도 프롬프트 방식을 못 쓰는 이유는, 이 모델이 chat template 없는 +**번역 전용 completion 모델**이기 때문입니다. 지시문을 이해하지 못할뿐더러 모델 카드가 +정한 고정 프롬프트 형식(끝의 `` 같은 언어 태그 포함)을 벗어나면 품질이 무너집니다. +용어집을 문맥까지 자연스럽게 반영하고 싶다면 5티어를 쓰세요. CSV로 한 번에 가져올 수 있습니다. diff --git a/docs/MODELS.md b/docs/MODELS.md index 91967c6..9697be4 100644 --- a/docs/MODELS.md +++ b/docs/MODELS.md @@ -64,6 +64,30 @@ Seed-X는 번역만 하도록 만들어진 모델이라 7B치고 품질이 비 **VRAM이 8GB 이하라면 → 3티어 "균형".** 그리고 이 경우의 추가학습은 NLLB-1.3B를 **풀 파인튜닝**하는 쪽이 오히려 유리하다 (docs/FINETUNING.md 참고). +## Seed-X를 쓸 때 반드시 지켜야 하는 것 + +Seed-X는 일반 챗 모델이 아니라 **번역만 하도록 학습된 completion 모델**입니다. +모델 카드가 명시하는 제약이 세 가지 있고, 구현에 그대로 반영했습니다. + +1. **프롬프트 끝의 언어 태그는 필수입니다.** PPO 학습에 쓰인 신호라 빠지면 품질이 + 크게 흔들립니다. 형식은 정확히 이렇습니다. + ``` + Translate the following English sentence into Korean: + May the force be with you + ``` +2. **chat template이 없습니다.** `apply_chat_template`을 쓰거나 멀티턴 대화 형식으로 + 넣으면 안 됩니다. +3. **지시문을 끼워 넣으면 안 됩니다.** "구어체로 자연스럽게 옮겨라" 같은 문장을 붙이면 + 학습 분포를 벗어납니다. 그래서 4티어는 용어집도 프롬프트가 아니라 + 플레이스홀더 치환으로 넣습니다. + +이 제약 때문에 프롬프트 빌더를 `build_seedx_prompt()`로 분리하고, 형식이 바뀌면 +바로 깨지도록 `tests/test_prompts.py`에 회귀 테스트를 걸어놨습니다. + +참고로 `AWQ-Int4`/`GPTQ-Int8`은 ByteDance가 직접 배포하는 **공식** 양자화본입니다 +(모델 카드가 경고하는 것은 제3자 양자화본입니다). 다만 AWQ 가중치를 읽으려면 +`pip install autoawq`가 필요합니다. + ## 주의 — 벤치마크를 곧이곧대로 믿지 말 것 위 비교는 공개 벤치마크와 모델 카드에 근거한 것이고, **실제 게임/방송 음성에서의 diff --git a/pyproject.toml b/pyproject.toml index 02e64c6..98e59fc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,8 @@ gpu = [ "huggingface-hub>=0.24", "sentencepiece>=0.2", ] +# 4티어(Seed-X AWQ Int4) 전용 커널. 다른 티어에는 필요 없다. +awq = ["autoawq>=0.2.6"] # Windows 오디오 캡처 windows = [ "PyAudioWPatch>=0.2.12.7", diff --git a/requirements.txt b/requirements.txt index 775af4c..26407cb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -20,3 +20,6 @@ psutil>=5.9; sys_platform == "win32" # --- 선택 ------------------------------------------------------------ webrtcvad-wheels>=2.0.14 + +# 4티어(Seed-X AWQ Int4)를 쓸 때만 필요합니다: +# pip install autoawq diff --git a/scripts/finetune_mt.py b/scripts/finetune_mt.py index 7120798..1c32eaa 100644 --- a/scripts/finetune_mt.py +++ b/scripts/finetune_mt.py @@ -21,7 +21,13 @@ from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) from hearo.constants import LANGUAGES, models_dir # noqa: E402 -from hearo.models.tiers import MTBackend, get_tier, ordered_tiers # noqa: E402 +from hearo.models.tiers import ( # noqa: E402 + MTBackend, + PromptStyle, + get_tier, + ordered_tiers, +) +from hearo.models.translator import build_seedx_prompt # noqa: E402 #: NLLB(seq2seq)와 Qwen(causal LM)에서 공통으로 존재하는 attention 투영 이름 LORA_TARGETS = ["q_proj", "v_proj"] @@ -52,8 +58,19 @@ def load_pairs(path: Path) -> list[dict]: return pairs -def build_prompt(item: dict) -> tuple[str, str]: - """LLM 학습용 (프롬프트, 정답) 쌍. translator.py 의 추론 프롬프트와 맞춰야 한다.""" +def build_prompt(item: dict, style: PromptStyle = PromptStyle.INSTRUCT) -> tuple[str, str]: + """LLM 학습용 (프롬프트, 정답) 쌍. + + **추론 때 쓰는 프롬프트와 반드시 같아야 한다.** 형식이 어긋나면 학습은 + 정상적으로 끝나지만 실제 번역에서는 효과가 거의 나오지 않는다. 그래서 + Seed-X 형식은 translator.py 의 함수를 그대로 재사용한다. + """ + if style is PromptStyle.SEEDX: + prompt = build_seedx_prompt( + item["source"], item["source_lang"], item["target_lang"] + ) + return prompt, "\n" + item["target"] + src = LANGUAGES[item["source_lang"]]["english"] tgt = LANGUAGES[item["target_lang"]]["english"] prompt = ( @@ -65,7 +82,8 @@ def build_prompt(item: dict) -> tuple[str, str]: return prompt, " " + item["target"] -def train_llm(pairs, repo, output: Path, epochs: int, lr: float, rank: int, batch: int): +def train_llm(pairs, repo, output: Path, epochs: int, lr: float, rank: int, batch: int, + style: PromptStyle = PromptStyle.INSTRUCT): import torch from datasets import Dataset from peft import LoraConfig, get_peft_model @@ -82,7 +100,7 @@ def train_llm(pairs, repo, output: Path, epochs: int, lr: float, rank: int, batc tokenizer.pad_token = tokenizer.eos_token def encode(item): - prompt, answer = build_prompt(item) + prompt, answer = build_prompt(item, style) prompt_ids = tokenizer(prompt, add_special_tokens=False)["input_ids"] answer_ids = tokenizer(answer, add_special_tokens=False)["input_ids"] answer_ids.append(tokenizer.eos_token_id) @@ -227,7 +245,10 @@ def main() -> int: if tier.mt.backend is MTBackend.TRANSFORMERS: repo = args.base_model or tier.mt.repo print(f"LLM LoRA 학습: {repo} · {len(pairs)}쌍 · {args.epochs} epoch") - train_llm(pairs, repo, args.output, args.epochs, args.lr, args.rank, args.batch) + train_llm( + pairs, repo, args.output, args.epochs, args.lr, args.rank, args.batch, + tier.mt.prompt_style, + ) else: # CT2 변환본에는 학습에 필요한 가중치가 없으므로 원본 리포로 바꾼다. repo = args.base_model or _hf_original(tier.mt.repo) diff --git a/src/hearo/constants.py b/src/hearo/constants.py index 90b0fa3..9ee7b02 100644 --- a/src/hearo/constants.py +++ b/src/hearo/constants.py @@ -11,11 +11,19 @@ APP_VERSION = "0.1.0" ORG_NAME = "tkrmagid" # 지원 언어 (1차: 4개) +# +# 각 키의 의미: +# label — UI 표시 이름 +# english — 프롬프트에 쓰는 영어 언어명 +# nllb — NLLB-200 언어 코드 (FLORES-200 형식) +# seedx — Seed-X 프롬프트 끝에 붙이는 태그. 모델 카드가 "언어 태그는 필수"라고 +# 명시하며, 빠지면 번역 품질이 크게 흔들린다. 값이 우리 언어코드와 +# 같아 보여도 우연이 아니라 Seed-X 표에 맞춘 것이므로 따로 적어둔다. LANGUAGES: dict[str, dict[str, str]] = { - "ko": {"label": "한국어", "english": "Korean", "nllb": "kor_Hang", "flag": "KO"}, - "en": {"label": "English", "english": "English", "nllb": "eng_Latn", "flag": "EN"}, - "ja": {"label": "日本語", "english": "Japanese", "nllb": "jpn_Jpan", "flag": "JA"}, - "zh": {"label": "中文", "english": "Chinese", "nllb": "zho_Hans", "flag": "ZH"}, + "ko": {"label": "한국어", "english": "Korean", "nllb": "kor_Hang", "seedx": "ko"}, + "en": {"label": "English", "english": "English", "nllb": "eng_Latn", "seedx": "en"}, + "ja": {"label": "日本語", "english": "Japanese", "nllb": "jpn_Jpan", "seedx": "ja"}, + "zh": {"label": "中文", "english": "Chinese", "nllb": "zho_Hans", "seedx": "zh"}, } LANGUAGE_CODES = tuple(LANGUAGES.keys()) diff --git a/tests/test_prompts.py b/tests/test_prompts.py new file mode 100644 index 0000000..478d56c --- /dev/null +++ b/tests/test_prompts.py @@ -0,0 +1,82 @@ +"""번역 프롬프트 형식 회귀 테스트. + +Seed-X 는 chat template 없는 번역 전용 completion 모델이라 모델 카드가 정한 +형식을 한 글자도 벗어나면 안 된다. 특히 끝의 `<언어코드>` 태그는 PPO 학습에 +쓰인 것이라 빠지면 품질이 무너진다. +""" + +from __future__ import annotations + +import pytest + +from hearo.constants import LANGUAGE_CODES, LANGUAGES +from hearo.models.glossary import Glossary, GlossaryEntry +from hearo.models.tiers import TIERS, MTBackend, PromptStyle, get_tier +from hearo.models.translator import build_seedx_prompt + + +def test_seedx_prompt_matches_model_card_exactly(): + """모델 카드 예시: "Translate the following English sentence into Chinese:\\nMay the force be with you " """ + assert build_seedx_prompt("May the force be with you", "en", "zh") == ( + "Translate the following English sentence into Chinese:\n" + "May the force be with you " + ) + + +def test_seedx_prompt_always_ends_with_target_language_tag(): + for target in LANGUAGE_CODES: + prompt = build_seedx_prompt("hello", "en", target) + assert prompt.endswith(f" <{target}>"), f"{target} 태그 누락" + + +def test_seedx_prompt_has_no_extra_instructions(): + """지시문을 끼워 넣으면 Seed-X 의 학습 분포를 벗어난다.""" + prompt = build_seedx_prompt("fall back now", "en", "ko") + lowered = prompt.lower() + for forbidden in ("casual", "output only", "glossary", "game or broadcast", "용어"): + assert forbidden not in lowered + assert prompt.count("\n") == 1 # 지시문 한 줄 + 본문 한 줄 + + +@pytest.mark.parametrize("code", LANGUAGE_CODES) +def test_every_language_has_a_seedx_tag(code): + assert LANGUAGES[code]["seedx"], f"{code} 에 seedx 태그가 없습니다" + + +def test_seedx_tier_does_not_use_prompt_glossary(): + """Seed-X 는 지시문을 못 알아들으므로 프롬프트 용어집을 쓰면 안 된다.""" + precision = get_tier("precision") + assert precision.mt.prompt_style is PromptStyle.SEEDX + assert precision.mt.supports_prompt_glossary is False + + +def test_instruct_tier_uses_prompt_glossary(): + ultimate = get_tier("ultimate") + assert ultimate.mt.prompt_style is PromptStyle.INSTRUCT + assert ultimate.mt.supports_prompt_glossary is True + + +def test_seq2seq_tiers_never_use_prompt_glossary(): + for tier in TIERS.values(): + if tier.mt.backend is MTBackend.CTRANSLATE2: + assert tier.mt.prompt_style is PromptStyle.NONE + assert tier.mt.supports_prompt_glossary is False + + +def test_every_llm_tier_declares_a_prompt_style(): + for tier in TIERS.values(): + if tier.mt.backend is MTBackend.TRANSFORMERS: + assert tier.mt.prompt_style is not PromptStyle.NONE, tier.key + + +def test_glossary_survives_seedx_placeholder_path(): + """Seed-X 경로에서 용어집이 프롬프트가 아니라 치환으로 동작하는지.""" + g = Glossary([GlossaryEntry("Nexus", {"ko": "넥서스"})]) + protected, repl = g.protect("push to the Nexus", "ko") + + prompt = build_seedx_prompt(protected, "en", "ko") + assert "Nexus" not in prompt # 모델이 건드릴 수 없게 가려짐 + assert prompt.endswith(" ") + + # 모델이 플레이스홀더를 그대로 통과시켰다고 가정 + assert Glossary.restore("⟦0⟧로 밀어", repl) == "넥서스로 밀어" diff --git a/tests/test_tiers.py b/tests/test_tiers.py index beeb4d5..4139b29 100644 --- a/tests/test_tiers.py +++ b/tests/test_tiers.py @@ -1,4 +1,11 @@ -from hearo.models.tiers import DEFAULT_TIER, TIERS, MTBackend, get_tier, ordered_tiers +from hearo.models.tiers import ( + DEFAULT_TIER, + TIERS, + MTBackend, + PromptStyle, + get_tier, + ordered_tiers, +) def test_exactly_five_tiers_ordered_by_quality(): @@ -20,12 +27,20 @@ def test_exactly_one_recommended_and_one_finetune_pick(): assert sum(t.best_after_finetune for t in TIERS.values()) == 1 -def test_llm_backends_support_prompt_glossary(): +def test_only_instruct_models_support_prompt_glossary(): + """LLM이라고 다 되는 게 아니다 — 지시문을 이해하는 instruct 모델만 가능하다. + + Seed-X 는 번역 전용 completion 모델이라 지시문을 넣으면 오히려 망가진다. + """ for tier in TIERS.values(): - if tier.mt.backend is MTBackend.TRANSFORMERS: - assert tier.mt.supports_prompt_glossary - else: - assert not tier.mt.supports_prompt_glossary + expected = tier.mt.prompt_style is PromptStyle.INSTRUCT + assert tier.mt.supports_prompt_glossary is expected, tier.key + + +def test_seq2seq_backends_have_no_prompt_style(): + for tier in TIERS.values(): + if tier.mt.backend is MTBackend.CTRANSLATE2: + assert tier.mt.prompt_style is PromptStyle.NONE, tier.key def test_unknown_key_falls_back_to_default():