"""Reproducible raw-vs-CPT evaluation for the Qwen3 resident base. Measures token-weighted RU/EN perplexity on pinned human-written UD test sets, plus deterministic RU/EN generation probes. Writes machine-readable JSON for decision_gate.py. It does not mutate a model or dataset. """ from __future__ import annotations import argparse import json import math import os from pathlib import Path os.environ.setdefault("HF_HOME", "/mnt/D/.cache/huggingface") os.environ.setdefault("HF_DATASETS_CACHE", "/mnt/D/.cache/huggingface/datasets") import torch from transformers import AutoModelForCausalLM, AutoTokenizer from corpus_common import has_mixed_script, cyrillic_ratio RU_PROMPTS = [ "Объясни простыми словами, почему зимой дни короче.", "Опиши, как спокойно подготовиться к сложному рабочему дню.", "Напиши короткое напоминание купить продукты после работы.", "Расскажи, чем резервная копия отличается от синхронизации.", "Продолжи естественно: Когда я вернулась домой, оказалось, что", "Сформулируй вежливый отказ от встречи без лишних подробностей.", "Объясни разницу между привычкой и разовым действием.", "Дай три коротких совета человеку, который плохо выспался.", ] HERE = Path(__file__).resolve().parent EN_PROMPTS = [ "Explain in one sentence why winter days are shorter.", "Write a polite one-sentence reminder to buy groceries after work.", "Explain the difference between a backup and synchronization.", "Continue naturally: When I returned home, I discovered that", ] def load_lines(path: Path, limit: int) -> list[str]: lines = [line.strip() for line in path.read_text(encoding="utf-8").splitlines() if line.strip()] return lines[:limit] if limit else lines def load_model(model_id: str, device: str): tok = AutoTokenizer.from_pretrained(model_id) dtype = torch.bfloat16 if device == "cuda" else torch.float32 model = AutoModelForCausalLM.from_pretrained( model_id, dtype=dtype, attn_implementation="eager", local_files_only=False, ).eval().to(device) return tok, model @torch.inference_mode() def generate(tok, model, prompt: str, max_new_tokens: int) -> str: encoded = tok(prompt, return_tensors="pt").to(model.device) output = model.generate( **encoded, max_new_tokens=max_new_tokens, do_sample=False, pad_token_id=tok.eos_token_id, ) continuation = output[0][encoded.input_ids.shape[1]:] return tok.decode(continuation, skip_special_tokens=True).strip() @torch.inference_mode() def perplexity(tok, model, texts: list[str], max_length: int) -> tuple[float, int]: nll = 0.0 predicted = 0 for text in texts: encoded = tok(text, return_tensors="pt", truncation=True, max_length=max_length).to(model.device) tokens = int(encoded.attention_mask.sum()) - 1 if tokens <= 0: continue loss = model(**encoded, labels=encoded.input_ids).loss.float().item() nll += loss * tokens predicted += tokens if not predicted: raise ValueError("evaluation set contains no predictable tokens") return math.exp(nll / predicted), predicted def main() -> None: ap = argparse.ArgumentParser() ap.add_argument("--model", required=True) ap.add_argument("--ru-text", default=str(HERE / "data/eval/ru_ud_test.txt")) ap.add_argument("--en-text", default=str(HERE / "data/eval/en_ud_test.txt")) ap.add_argument("--limit", type=int, default=512, help="sentences per PPL language; 0 uses the complete files") ap.add_argument("--max-length", type=int, default=512) ap.add_argument("--max-new-tokens", type=int, default=96) ap.add_argument("--device", choices=("auto", "cpu", "cuda"), default="auto") ap.add_argument("--output") args = ap.parse_args() device = ("cuda" if torch.cuda.is_available() else "cpu") if args.device == "auto" else args.device tok, model = load_model(args.model, device) ru_texts = load_lines(Path(args.ru_text), args.limit) en_texts = load_lines(Path(args.en_text), args.limit) ru_ppl, ru_tokens = perplexity(tok, model, ru_texts, args.max_length) en_ppl, en_tokens = perplexity(tok, model, en_texts, args.max_length) ru_gens = [generate(tok, model, p, args.max_new_tokens) for p in RU_PROMPTS] en_gens = [generate(tok, model, p, args.max_new_tokens) for p in EN_PROMPTS] ru_valid = [not has_mixed_script(g) and cyrillic_ratio(g) >= 0.80 for g in ru_gens] en_valid = [len(g) >= 10 and cyrillic_ratio(g) < 0.20 for g in en_gens] report = { "schema_version": 1, "model": args.model, "device": device, "deterministic": True, "perplexity": { "ru": ru_ppl, "ru_predicted_tokens": ru_tokens, "en": en_ppl, "en_predicted_tokens": en_tokens, "sentence_limit": args.limit, }, "generation": { "ru_valid_pct": 100 * sum(ru_valid) / len(ru_valid), "ru_mixed_script_count": sum(has_mixed_script(g) for g in ru_gens), "en_retained": all(en_valid), "ru": [{"prompt": p, "output": g, "valid": ok} for p, g, ok in zip(RU_PROMPTS, ru_gens, ru_valid)], "en": [{"prompt": p, "output": g, "valid": ok} for p, g, ok in zip(EN_PROMPTS, en_gens, en_valid)], }, } rendered = json.dumps(report, ensure_ascii=False, indent=2) + "\n" if args.output: Path(args.output).write_text(rendered, encoding="utf-8") print(rendered, end="") if __name__ == "__main__": main()