От учителя к студенту: путь к изотропным эмбеддингам в 29M модели. Часть 5/10
Важное предупреждение:
Весь код по моей методологии написан нейросетью 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.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.