Часть 5. Базовая линия: с чего стартуем
Важное предупреждение:
Весь код по моей методологии написан нейросетью DeepSeek, сам бы не осилил (я не программист).
За мной лишь отладка, исправление неточностей и ошибок.
5.1. Что и зачем
Прежде чем что-то обучать, нужен "снимок до". Иначе после Phase A мы не поймём: cos 0.92 к учителю — это хорошо или "а должно было быть 0.98"? retrieval 0.98 — это выросло относительно старта или упало?
Поэтому в этом разделе — два коротких замера, до всякого обучения:
Retrieval на
hnp_val(590 троек query/pos/neg) — прогоняем три модели: учителя DeepPavlov, turbo (база нашего студента) и самого студента со случайной головой. Смотрим, кто как различает hard negatives.Cosine к учителю на
val.txt(4967 текстов) — прогоняем студента в 768-dim режиме (projected=True) и учителя, считаем cos по каждому тексту. Это наша отправная точка для Phase A.
Учителя здесь прогоняем на лету — teacher_val.npy появится только в разделе про кэширование (часть 6). 4967 текстов на CPU — это пара минут, не критично.
Студент в этом разделе — baseline student: тот же student_init.pt, который мы собрали в 4.1. Энкодер в нём — реальный turbo, а вот attention pooling и проекция — случайные, только что инициализированные. Так и надо: это нижняя граница, которую потом будем улучшать.
5.2. Тест 1: retrieval на hnp_val
Скрипт: 5.1_eval_models_hnp.py
"""Базовая линия на hnp_val: retrieval_acc для учителя, turbo и студента. Один пайп: для каждой модели — forward на 590 записей hnp_val, retrieval_acc = доля запросов, где max(sim(q,pos)) > max(sim(q,neg)). Плюс средние cos на pos и neg (для отладки). Учитель и turbo прогоняются "на лету" — teacher_val.npy ещё не создан. Сохраняет meta/5.1_eval_models_hnp_meta.json. """ import importlib.util import json import time from pathlib import Path import numpy as np import torch from sentence_transformers import SentenceTransformer from transformers import AutoModel, AutoTokenizer ROOT = Path(__file__).resolve().parent.parent DATA = ROOT / "data" META_DIR = DATA / "meta" META_DIR.mkdir(parents=True, exist_ok=True) META = META_DIR / "5.1_eval_models_hnp_meta.json" HNP_VAL = DATA / "hnp_val.jsonl" SCRIPT_41 = Path(__file__).parent / "4.1_build_student.py" OUT_MODEL = DATA / "student_init.pt" OUT_TOKENIZER = DATA / "student_tokenizer" TEACHER = "DeepPavlov/rubert-base-cased-sentence" TURBO = "sergeyzh/rubert-tiny-turbo" MAX_LEN = 128 BATCH = 64 DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu") # --- Динамическая загрузка StudentTiny из 4.1 --- _spec = importlib.util.spec_from_file_location("build_student", SCRIPT_41) build_student = importlib.util.module_from_spec(_spec) _spec.loader.exec_module(build_student) StudentTiny = build_student.StudentTiny ENCODER = build_student.ENCODER HIDDEN = build_student.HIDDEN TEACHER_DIM = build_student.TEACHER_DIM def read_jsonl(p): with Path(p).open(encoding="utf-8") as f: return [json.loads(l) for l in f if l.strip()] def eval_st_model(model: SentenceTransformer, records, name: str): """Retrieval_acc для sentence-transformers модели.""" t0 = time.time() queries = [r["query"] for r in records] q_emb = model.encode(queries, batch_size=BATCH, convert_to_numpy=True, normalize_embeddings=True, show_progress_bar=False) n = len(records) pos_sims, neg_sims = [], [] for i, r in enumerate(records): p = model.encode(r["pos"], batch_size=BATCH, convert_to_numpy=True, normalize_embeddings=True, show_progress_bar=False) ng = model.encode(r["neg"], batch_size=BATCH, convert_to_numpy=True, normalize_embeddings=True, show_progress_bar=False) pos_sims.append(float((p @ q_emb[i]).max())) neg_sims.append(float((ng @ q_emb[i]).max())) pos_sims = np.array(pos_sims) neg_sims = np.array(neg_sims) correct = int((pos_sims > neg_sims).sum()) return { "model": name, "dim": int(q_emb.shape[1]), "n": n, "retrieval_acc": round(correct / n, 4), "cos_pos_mean": round(float(pos_sims.mean()), 4), "cos_neg_mean": round(float(neg_sims.mean()), 4), "cos_gap": round(float(pos_sims.mean() - neg_sims.mean()), 4), "elapsed_sec": round(time.time() - t0, 1), } @torch.no_grad() def eval_student(student, tokenizer, records, name="student"): t0 = time.time() n = len(records) q_texts = [r["query"] for r in records] q_enc = tokenizer(q_texts, padding="max_length", truncation=True, max_length=MAX_LEN, return_tensors="pt") q_emb = student(q_enc["input_ids"].to(DEVICE), q_enc["attention_mask"].to(DEVICE), projected=False).float().cpu().numpy() pos_sims, neg_sims = [], [] for i, r in enumerate(records): p_enc = tokenizer(r["pos"], padding="max_length", truncation=True, max_length=MAX_LEN, return_tensors="pt") n_enc = tokenizer(r["neg"], padding="max_length", truncation=True, max_length=MAX_LEN, return_tensors="pt") p_emb = student(p_enc["input_ids"].to(DEVICE), p_enc["attention_mask"].to(DEVICE), projected=False).float().cpu().numpy() n_emb = student(n_enc["input_ids"].to(DEVICE), n_enc["attention_mask"].to(DEVICE), projected=False).float().cpu().numpy() pos_sims.append(float((p_emb @ q_emb[i]).max())) neg_sims.append(float((n_emb @ q_emb[i]).max())) pos_sims = np.array(pos_sims) neg_sims = np.array(neg_sims) correct = int((pos_sims > neg_sims).sum()) return { "model": name, "dim": int(q_emb.shape[1]), "n": n, "retrieval_acc": round(correct / n, 4), "cos_pos_mean": round(float(pos_sims.mean()), 4), "cos_neg_mean": round(float(neg_sims.mean()), 4), "cos_gap": round(float(pos_sims.mean() - neg_sims.mean()), 4), "elapsed_sec": round(time.time() - t0, 1), } def main(): t0 = time.time() torch.manual_seed(42) print(f"Device: {DEVICE}", flush=True) records = read_jsonl(HNP_VAL) print(f"hnp_val: {len(records):,} записей\n", flush=True) results = [] # 1. Учитель print(f"Loading teacher: {TEACHER}", flush=True) teacher_st = SentenceTransformer(TEACHER, device=str(DEVICE)) print("→ teacher", flush=True) r = eval_st_model(teacher_st, records, "teacher (DeepPavlov)") print(f" {r}", flush=True) results.append(r) del teacher_st torch.cuda.empty_cache() if DEVICE.type == "cuda" else None # 2. Turbo print(f"\nLoading turbo: {TURBO}", flush=True) turbo_st = SentenceTransformer(TURBO, device=str(DEVICE)) print("→ turbo", flush=True) r = eval_st_model(turbo_st, records, "turbo") print(f" {r}", flush=True) results.append(r) del turbo_st torch.cuda.empty_cache() if DEVICE.type == "cuda" else None # 3. Студент (случайный, до обучения) print("\nLoading student (random init)...", flush=True) encoder = AutoModel.from_pretrained(ENCODER).to(DEVICE) student = StudentTiny(encoder).to(DEVICE) sd = torch.load(OUT_MODEL, map_location=DEVICE) if isinstance(sd, dict) and "state_dict" in sd: sd = sd["state_dict"] student.load_state_dict(sd) student.eval() tokenizer = AutoTokenizer.from_pretrained(OUT_TOKENIZER) print("→ student", flush=True) r = eval_student(student, tokenizer, records, "student (random)") print(f" {r}", flush=True) results.append(r) # Итоговая таблица print("\n" + "=" * 70) print(f"{'model':<28} {'dim':>4} {'acc':>7} {'cos_pos':>8} {'cos_neg':>8} {'gap':>7}") print("-" * 70) for r in results: print(f"{r['model']:<28} {r['dim']:>4} {r['retrieval_acc']:>7.4f} " f"{r['cos_pos_mean']:>8.4f} {r['cos_neg_mean']:>8.4f} {r['cos_gap']:>7.4f}") print("=" * 70) meta = { "script": "5.1_eval_models_hnp", "timestamp": time.strftime("%Y-%m-%dT%H:%M:%S"), "hnp_val": str(HNP_VAL), "n_records": len(records), "max_len": MAX_LEN, "seed": 42, "device": str(DEVICE), "results": results, "elapsed_sec": round(time.time() - t0, 1), } META.write_text(json.dumps(meta, indent=2, ensure_ascii=False), encoding="utf-8") print(f"\nMeta: {META}") if __name__ == "__main__": main()
Вывод скрипта:
====================================================================== model dim acc cos_pos cos_neg gap ---------------------------------------------------------------------- teacher (DeepPavlov) 768 0.9763 0.9792 0.8168 0.1624 turbo 312 0.9678 0.9851 0.9409 0.0442 student (random) 312 0.9610 0.9467 0.8039 0.1428 ======================================================================
Замечание про скорость: turbo работает через SentenceTransformer.encode() (батчи, оптимизированный пайплайн) и прогоняется за 7.6 сек. Студент — наш собственный forward с padding="max_length" до 256 токенов на каждый текст, поэтому 16 сек. Учитель на CPU — 103 сек (12 слоёв, 768-dim). Всё это до обучения, оптимизировать нечего.
5.3. Тест 2: cos к учителю на val.txt
Скрипт: 5.2_eval_cos_to_teacher.py
"""Cos между случайным студентом и учителем на val.txt — отправная точка. Прогоняет учителя (SentenceTransformer) и студента (projected=True) на val.txt, считает cos по каждому тексту. Сохраняет meta/5.2_eval_cos_to_teacher_meta.json. """ import importlib.util import json import time from pathlib import Path import numpy as np import torch from sentence_transformers import SentenceTransformer from transformers import AutoModel, AutoTokenizer ROOT = Path(__file__).resolve().parent.parent DATA = ROOT / "data" META_DIR = DATA / "meta" META_DIR.mkdir(parents=True, exist_ok=True) META = META_DIR / "5.2_eval_cos_to_teacher_meta.json" VAL_TXT = DATA / "val.txt" SCRIPT_41 = Path(__file__).parent / "4.1_build_student.py" OUT_MODEL = DATA / "student_init.pt" OUT_TOKENIZER = DATA / "student_tokenizer" TEACHER = "DeepPavlov/rubert-base-cased-sentence" MAX_LEN = 256 BATCH = 64 DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu") _spec = importlib.util.spec_from_file_location("build_student", SCRIPT_41) build_student = importlib.util.module_from_spec(_spec) _spec.loader.exec_module(build_student) StudentTiny = build_student.StudentTiny ENCODER = build_student.ENCODER def read_lines(p): return [l.strip() for l in Path(p).read_text(encoding="utf-8").split("\n") if l.strip()] def main(): t0 = time.time() torch.manual_seed(42) print(f"Device: {DEVICE}", flush=True) texts = read_lines(VAL_TXT) print(f"val.txt: {len(texts):,} текстов\n", flush=True) # 1. Учитель print(f"Loading teacher: {TEACHER}", flush=True) teacher_st = SentenceTransformer(TEACHER, device=str(DEVICE)) print("→ teacher encode", flush=True) teacher_emb = teacher_st.encode( texts, batch_size=BATCH, convert_to_numpy=True, normalize_embeddings=True, show_progress_bar=True, ).astype(np.float32) del teacher_st torch.cuda.empty_cache() if DEVICE.type == "cuda" else None print(f" teacher dim: {teacher_emb.shape[1]}\n", flush=True) # 2. Студент print("Loading student (random init)...", flush=True) encoder = AutoModel.from_pretrained(ENCODER).to(DEVICE) student = StudentTiny(encoder).to(DEVICE) sd = torch.load(OUT_MODEL, map_location=DEVICE) if isinstance(sd, dict) and "state_dict" in sd: sd = sd["state_dict"] student.load_state_dict(sd) student.eval() tokenizer = AutoTokenizer.from_pretrained(OUT_TOKENIZER) print("→ student encode", flush=True) n = len(texts) student_emb = np.zeros((n, TEACHER_DIM), dtype=np.float32) if False else np.zeros((n, 768), dtype=np.float32) with torch.no_grad(): for i in range(0, n, BATCH): batch = texts[i:i+BATCH] enc = tokenizer(batch, padding="max_length", truncation=True, max_length=MAX_LEN, return_tensors="pt") e = student(enc["input_ids"].to(DEVICE), enc["attention_mask"].to(DEVICE), projected=True).float().cpu().numpy() student_emb[i:i+len(e)] = e if (i // BATCH) % 20 == 0: print(f" {min(i+BATCH, n):,}/{n:,}", flush=True) # 3. Cos cos = (student_emb * teacher_emb).sum(axis=-1) # уже L2-норм. stats = { "n": n, "mean": round(float(cos.mean()), 4), "std": round(float(cos.std()), 4), "min": round(float(cos.min()), 4), "max": round(float(cos.max()), 4), "p10": round(float(np.percentile(cos, 10)), 4), "p50": round(float(np.percentile(cos, 50)), 4), "p90": round(float(np.percentile(cos, 90)), 4), } print("\nCos(student_proj, teacher) на val.txt:") for k, v in stats.items(): print(f" {k}: {v}") meta = { "script": "5.2_eval_cos_to_teacher", "timestamp": time.strftime("%Y-%m-%dT%H:%M:%S"), "val_txt": str(VAL_TXT), "teacher": TEACHER, "student_checkpoint": str(OUT_MODEL), "max_len": MAX_LEN, "batch": BATCH, "device": str(DEVICE), "cos_stats": stats, "elapsed_sec": round(time.time() - t0, 1), } META.write_text(json.dumps(meta, indent=2, ensure_ascii=False), encoding="utf-8") print(f"\nMeta: {META}") if __name__ == "__main__": main()
Вывод скрипта:
Cos(student_proj, teacher) на val.txt: n: 4967 mean: -0.0164 std: 0.0344 min: -0.1223 max: 0.1013 p10: -0.0607 p50: -0.0165 p90: 0.0282
Тут важное: считаем именно projected=True (768-dim), иначе cos со студентом 312-dim и учителем 768-dim просто не имеет смысла.
5.4. Результаты
Сводим всё в одну таблицу:
Модель |
dim |
retrieval_acc |
cos_pos |
cos_neg |
gap |
|---|---|---|---|---|---|
Учитель DeepPavlov |
768 |
0.9763 |
0.9792 |
0.8168 |
0.1624 |
Turbo |
312 |
0.9678 |
0.9851 |
0.9409 |
0.0442 |
Student (baseline) |
312 |
0.9610 |
0.9467 |
0.8039 |
0.1428 |
А cos студента к учителю на val.txt:
Метрика |
Значение |
|---|---|
mean |
−0.0164 |
std |
0.0344 |
p10 / p50 / p90 |
−0.0607 / −0.0165 / 0.0282 |
И вот что из этого следует.
Первое: baseline student даёт 0.961 retrieval — это не заслуга головы. Attention pooling и проекция у него случайные. Всё, что он умеет, унаследовано от энкодера turbo: его contrastive-pretrained hidden states уже несут семантику, а pooling со случайными весами query её не разрушает. Это значит, что изначальная планка ≥0,85 при постановке задачи была слишком консервативной: она достигнута ещё до обучения. Реальная планка — превзойти turbo (0.968) и подобраться к учителю (0.976). Именно так цель в части 0.1 и поправлена — на ≥0.97 с оговоркой, что turbo со старта 0.968.
Второе: negation blindness подтверждён на реальных данных, а не на трёх парах. В разделе 1.5 мы смотрели на три примера "устроился / не устроился" и предполагали, что это системная проблема. Теперь видно на 590 тройках: у turbo cos_neg = 0.9409 — практически как cos_pos = 0.9851. Gap всего 4.4%. Mean pooling сглаживает все токены, и сигнал "не" / "до" / "отменил" в нём тонет. Учитель лучше (gap 16.2%), но и он не идеален — а мы помним, что его mean pooling — та же история, просто модель больше.
Третье: случайный attention pooling уже разделяет лучше, чем mean. У baseline student gap 14.3% — почти как у учителя, хотя pooling случайный. Причина не в семантике (её там ещё нет), а в том, что attention pooling выбирает разные подмножества токенов разными головами, а не усредняет всё подряд. Это случайно "разбрасывает" эмбеддинги — но зато не сглаживает. Хороший знак: когда pooling обучится, он будет разбрасывать осмысленно. Именно на это и рассчитана Phase B.
Четвёртое: cos к учителю ≈ 0 — и это нормальная отправная точка. Mean = −0.016, разброс ±0.03. Случайная проекция 312→768 просто "разбрасывает" векторы в 768-мерном пространстве, ничего общего с пространством учителя у неё нет. Важно понимать: retrieval и cos к учителю — независимые оси. Студент уже умеет разделять пары в своём пространстве (0.961), но это пространство не имеет ничего общего с учительским (cos ≈ 0).
Итого имеем:
Планка retrieval — ≥0.97 (turbo со старта 0.968).
Cos к учителю — стартуем с ≈ 0, цель Phase A — ≥0.90.
Обе оси независимы, нужны обе фазы.
С этими числами и идём в кэширование (часть 6): считаем эмбеддинги учителя один раз и токенизируем всё, что понадобится Phase A и Phase B.