От учителя к студенту: путь к изотропным эмбеддингам в 29M модели. Часть 1-10. анизотропия.. анизотропия. дистилляция.. анизотропия. дистилляция. искусственный интеллект.. анизотропия. дистилляция. искусственный интеллект. эмбеддинги.

Часть 1. Окружение, разведка моделей

Важное предупреждение:
Весь код по моей методологии написан нейросетью DeepSeek, сам бы не осилил (я не программист).
За мной лишь отладка, исправление неточностей и ошибок.

1.1. Создание окружения

# путь условный, замените на свой
mkdir -p ~/work/..../rubert-tiny2-distill
cd ~/work/..../rubert-tiny2-distill
python3 -m venv .venv
source .venv/bin/activate
pip install --upgrade pip

# PyTorch CPU
pip install torch --index-url https://download.pytorch.org/whl/cpu

# Основные библиотеки
pip install 
    transformers 
    sentence-transformers 
    datasets 
    numpy 
    scikit-learn 
    tqdm 
    huggingface_hub

Проверка:

python -c "import torch, transformers, sentence_transformers; print('OK')"

Ожидаемый вывод: OK

1.2. Зачем просматривать модели перед обучением

Прежде чем строить студента, нужно ответить на два вопроса:

  1. Какую модель взять за базу? cointegrated/rubert-tiny2 и sergeyzh/rubert-tiny-turbo — обе 3 слоя, 312 dim, 29.4M параметров (turbo — это tiny2, дообученная на contrastive; архитектура та же).

  2. Как агрегировать токены в одно предложение? Учитель использует mean pooling (из карточки модели), но у нас своя голова внимания. Надо понять, какой слой даёт наиболее “семантичный” сигнал.

Метрика для сравнения — gap. Определяется как:

gap = mean(cos на похожих парах) - mean(cos на непохожих)

В “непохожих” в формуле смешаны hard и easy negatives, ниже они показаны отдельно, чтобы видеть вклад каждого типа.

Чем больше gap, тем лучше модель различает семантику и тем “чище” её пространство. У идеальной модели gap был бы большим (похожие — близко, разные — далеко).

Тестовый набор — 10 пар, подобранных специально:

  • 4 позитивные (парафразы): “кошка на стуле” – “кот уселся”, “умер от инфаркта” – “жизнь оборвал сердечный приступ”.

  • 3 hard negatives (отрицания): “он устроился” – “он НЕ устроился”, “я люблю кошек” – “я НЕ люблю”, “назначил” – “отменил”.

  • 3 обычных негатива (разные темы): “кошка” – “самолёт”, “борщ” – “Пифагор”.

Перебираем комбинации:

  • Слои: embeddings, L0, L1, L2 (все выходы модели).

  • Pooling-стратегии: mean, cls, max, last-token.

Это разведочный набор, а не оценка качества. Эти 10 пар нужны, чтобы выбрать слой и pooling-стратегию. Выводы о качестве делаются отдельно — на hnp_val (590 пар) и внешнем наборе (5000 пар) в следующих разделах.

Цель: найти комбинацию с максимальным gap. Также важно посмотреть на cos hard negatives.

1.3. Скрипт разведки слоёв

Первый запуск inspect_layers.py скачает ~940 MB (DeepPavlov ~700 MB + turbo ~120 MB + tiny2 ~120 MB).

Файл: scripts/1.1_inspect_layers.py

"""Разведка слоёв и pooling-стратегий.

Сравниваем:
- cointegrated/rubert-tiny2 (базовая LM, 3 слоя, 312 dim)
- sergeyzh/rubert-tiny-turbo (sentence encoder, 312 dim)
- DeepPavlov/rubert-base-cased-sentence (эталон, 12 слоёв, 768 dim)

Pooling: mean / CLS / max / last-token
"""
import sys
from pathlib import Path

import numpy as np
import torch
import torch.nn.functional as F
from transformers import AutoModel, AutoTokenizer

DEVICE = torch.device("cpu")

torch.manual_seed(42); np.random.seed(42)

MODELS = [
    ("cointegrated/rubert-tiny2", "base LM"),
    ("sergeyzh/rubert-tiny-turbo", "sentence enc"),
    ("DeepPavlov/rubert-base-cased-sentence", "teacher"),
]

PAIRS = [
    ("кошка сидит на стуле", "кот уселся на стуле", 1),
    ("он умер от инфаркта", "его жизнь оборвал сердечный приступ", 1),
    ("психология травмы и стадии горя", "этапы переживания утраты", 1),
    ("студент сдал экзамен на отлично", "учащийся успешно прошёл испытание", 1),
    ("он устроился на работу", "он не устроился на работу", 0),   # hard
    ("я люблю кошек", "я не люблю кошек", 0),                      # hard
    ("врач назначил лечение", "врач отменил лечение", 0),          # hard
    ("кошка сидит на стуле", "самолёт летит в небе", 0), # тот же anchor, теперь с негативом
    ("повар готовит борщ", "теорема Пифагора", 0),
    ("история Древнего Рима", "строение ДНК", 0),
]

HARD_NEG_INDICES = [4, 5, 6]  # индексы hard negatives в PAIRS (0-based)


def mean_pool(h, m):
    mm = m.unsqueeze(-1).float()
    return (h * mm).sum(1) / mm.sum(1).clamp(min=1e-9)

def cls_pool(h, m):
    return h[:, 0]

def max_pool(h, m):
    mm = m.unsqueeze(-1).float()
    h = h.masked_fill(mm == 0, float("-inf"))
    return h.max(1).values

def last_pool(h, m):
    # индекс последнего реального токена
    lengths = (m.sum(1) - 1).clamp(min=0)  # [B]
    return h[torch.arange(h.size(0)), lengths]

POOLERS = {
    "mean": mean_pool,
    "cls": cls_pool,
    "max": max_pool,
    "last": last_pool,
}


def l2(x):
    return F.normalize(x, dim=-1)


def evaluate_model(model_id, tag):
    print(f"n{'='*80}")
    print(f"Model: {model_id}  ({tag})")
    print(f"{'='*80}", flush=True)

    model = AutoModel.from_pretrained(model_id).to(DEVICE).eval()
    tokenizer = AutoTokenizer.from_pretrained(model_id)

    cfg = model.config
    print(f"  layers={cfg.num_hidden_layers}  hidden={cfg.hidden_size}  "
          f"heads={cfg.num_attention_heads}  vocab={cfg.vocab_size}  "
          f"max_pos={cfg.max_position_embeddings}")
    n_params = sum(p.numel() for p in model.parameters())
    print(f"  params={n_params:,}  ({n_params*4/1e6:.1f} MB fp32)")

    texts = [a for a, _, _ in PAIRS] + [b for _, b, _ in PAIRS]
    labels = [lbl for _, _, lbl in PAIRS]
    n = len(PAIRS)

    enc = tokenizer(texts, padding=True, truncation=True, max_length=256,
                    return_tensors="pt")

    with torch.no_grad():
        out = model(**enc, output_hidden_states=True)

    hidden_states = out.hidden_states  # tuple (n_layers+1)

    print(f"n  {'layer':<12} {'pool':<6} {'sim+':>7} {'sim-':>7} {'gap':>7} {'hard-':>7}")
    print(f"  {'-'*50}")

    best = (None, -999)
    for layer_idx, hidden in enumerate(hidden_states):
        for pool_name, pool_fn in POOLERS.items():
            emb = l2(pool_fn(hidden, enc["attention_mask"]))
            pos_s, neg_s, hard_s = [], [], []
            for i, (a, b, lbl) in enumerate(PAIRS):
                s = float((emb[i] * emb[n + i]).sum())
                if i in HARD_NEG_INDICES:
                    hard_s.append(s)
                if lbl == 1:
                    pos_s.append(s)
                else:
                    neg_s.append(s)
            p, nn, hh = np.mean(pos_s), np.mean(neg_s), np.mean(hard_s)
            gap = p - nn
            layer_name = f"emb" if layer_idx == 0 else f"L{layer_idx-1}"
            print(f"  {layer_name:<12} {pool_name:<6} {p:>7.3f} {nn:>7.3f} "
                  f"{gap:>7.3f} {hh:>7.3f}")
            if gap > best[1]:
                best = ((layer_name, pool_name, emb), gap)

    # Разбор лучшего
    (layer_name, pool_name, emb), gap = best
    print(f"n  Лучшая комбинация: layer={layer_name}  pool={pool_name}  gap={gap:.3f}")
    print(f"  Разбор пар:")
    for i, (a, b, lbl) in enumerate(PAIRS):
        s = float((emb[i] * emb[n + i]).sum())
        tag = "POS" if lbl == 1 else ("HRD" if i in HARD_NEG_INDICES else "NEG")
        print(f"    [{tag}] {s:+.3f}  {a[:40]:<42} <-> {b[:40]}")

    return best


def main():
    print(f"Device: {DEVICE}")
    results = {}
    for model_id, tag in MODELS:
        try:
            best, gap = evaluate_model(model_id, tag)
            results[model_id] = (best[0], best[1], gap)
        except Exception as e:
            print(f"n[!] {model_id} failed: {e}")
            results[model_id] = None

    print(f"n{'='*80}")
    print("ИТОГ")
    print(f"{'='*80}")
    for model_id, r in results.items():
        if r is None:
            print(f"  {model_id:<50} FAILED")
        else:
            layer, pool, gap = r
            print(f"  {model_id:<50} best={layer}/{pool}  gap={gap:.3f}")


if __name__ == "__main__":
    main()

Запуск:

cd ~/work/..../rubert-tiny2-distill
source .venv/bin/activate

python scripts/inspect_layers.py

Время: ~2 минуты (три модели, минимальный тест).

1.4. Результаты инспекции

Модель

Лучшая комбинация

Gap

cointegrated/rubert-tiny2

L2 / cls

0.064

sergeyzh/rubert-tiny-turbo

L2 / mean

0.125

DeepPavlov/rubert-base-cased-sentence

L11 / mean

0.329

Три вывода:

  1. Turbo почти в 2 раза сильнее tiny2. Обе модели имеют одинаковую архитектуру (3 слоя, 312 dim), но turbo дообучен contrastive на русских данных (см. карточку модели). Это дало ему готовое понимание семантики, тогда как tiny2 остаётся базовой MLM-моделью, её представления не оптимизированы под сравнение текстов.

  2. Учитель — в 2.5x сильнее turbo. DeepPavlov с 12 слоями и 768 dim имеет гораздо более богатое представление. Наша задача — не догнать его по абсолютной семантике, а приблизиться настолько, чтобы retrieval и дедубликация работали.

  3. Учитель страдает от negation blindness (см. 1.5).

1.5. Проблема negation blindness

Это важное открытие инспекции. Смотрим, что даёт DeepPavlov на hard negatives (лучшая комбинация — L11/mean):

Пара

cos

“он устроился на работу” – “он не устроился на работу”

0.578

“я люблю кошек” – “я не люблю кошек”

0.702

“врач назначил лечение” – “врач отменил лечение”

0.655

Модель даёт высокое сходство (0.58–0.70) на парах, где семантика противоположна.

Причина — в архитектуре и pooling. Базовая rubert-base-cased — MLM-модель: она учится восстанавливать токены по контексту, и вклад одного токена (“не”) размывается. Sentence-версия дообучена на парах, но использует mean pooling, который усредняет все токены и ещё сильнее ослабляет сигнал отрицания.

Практическое следствие:

  • Порог дедубликации на таких парах срабатывает ложно.

  • Семантический поиск ставит “противоположное” на высокие позиции.

  • Для задач, где отрицания важны (психотерапия, юриспруденция, медицина), учитель работает плохо.

Через contrastive на ru-HNP мы попробуем сбивать cos на hard negatives у ученика, чтобы он не перенес на себя “болезнь” учителя.

1.6. Выбор базовой модели

Берём sergeyzh/rubert-tiny-turbo. Причины:

  1. Стартовый gap почти в 2 раза выше — семантику не надо учить с нуля — только адаптировать.

  2. Contrastive-предобучение — должен сойтись быстрее в Phase B.

Что забираем у turbo:

  • Только энкодер (3 слоя, 312 dim).

  • Его родной pooling (mean) игнорируем — заменим своим Attention Pooling.

  • Hidden states сохраняют contrastive-обученные представления, это ключевое.

Автор: AntonovPsy

Источник