File size: 30,240 Bytes
c1ca515
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
# Как обучен адаптер Qwen3-0.6B → Qwen3-4B: подробный разбор

Это технический разбор адаптера [`adapter_v14_bal.safetensors`](adapter_v14_bal.safetensors) —
220M-маппинга из пространства текстового энкодера Qwen3-0.6B в пространство текстового
энкодера FLUX.2-klein-4B (Qwen3-4B). Английская карточка модели — в [README.md](README.md),
здесь то же самое, но с цифрами замеров, историей решений и разбором того, что не сработало.

---

## 1. Зачем вообще адаптер

Учитель в нашей дистилляции — FLUX.2-klein-4B. Его DiT обучен на текстовом условии, которое
считает его собственный энкодер: Qwen3-4B, 36 слоёв, hidden 2560, склейка слоёв **9, 18, 27** —
это `joint_attention_dim = 7680`, и ровно эти три слоя берёт сам пайплайн diffusers.

Наш micro-студент имеет дешёвый энкодер — **Qwen3-0.6B** (28 слоёв, hidden 1024), потому что
в micro-модели весь бюджет уходит в DiT, а не в текст.

Возникает развилка при дистилляции:

* кормить учителя **его родным** условием (нужен Qwen3-4B: +7.5 ГБ VRAM и ~0.3 с на шаг);
* либо кормить учителя **нашим** условием, но переведённым в его пространство.

Второй путь и есть адаптер: `y = A(наш_ТЕ(x))`, где `A` учится по тексту, без картинок,
без VAE и без диффузии. Цель — не «похоже на подсказку», а **в точности то, что дал бы родной
энкодер учителя на том же тексте**. Тогда учитель ведёт студента в том режиме, в каком он
работал всегда, а студент дистиллируется от условия, которое сам и умеет считать.

Ключевое упрощение: задача — чистая регрессия «эмбеддинги в эмбеддинги». Токенизаторы
у Qwen3-0.6B и Qwen3-4B **идентичны** (vocab 151669, тот же `chat_template`, тот же
`padding_side=right`), поэтому позиции ученика и учителя совпадают ровно, без выравнивания.
У референсной работы (`recoilme/sdxs`, `qwen_adapter_2b_4b`) токенизаторы разные, и там
позиции частично разъезжаются — у нас этой проблемы нет.

---

## 2. Как он устроен

```
текст → Qwen3-0.6B (заморожен) → hidden states слоёв 2,9,14,18,23,27 → склейка (B, 256, 6144)
      → адаптер → (B, 256, 7680) → DiT klein

y_i = MLP(x_i) + Attention(x)_i
      │           │
      │           └─ residual-ветка: 2 блока self-attention, d=1024, 8 голов, 39.6M
      └─ поточечная MLP: 6144 → 8192 → 8192 → 7680, GELU(tanh), 180.4M
```

Итого 220.0M параметров. Порядок склейки — послойно-майорный, `[слой2 | слой9 | … | слой27]`,
ровно так же, как klein собирает свои три слоя (`stack(dim=1)` → `permute`).

Применяется адаптер **только к условию учителя**. Наш DiT по-прежнему получает свой 4D-контекст
`(B, L, 6, 1024)` и обрабатывает его сам (`TextFusion` + `txtmlp`) — адаптер существует для
взаимодействия с учителем, а не для студента.

### Почему шесть слоёв, а не три

Соблазн взять симметрию: klein использует три слоя из 36 (25%, 50%, 75%) — значит, и нам взять
три. Мы так и начали, и это оказалось хуже по всем замерам:

| вход адаптера | параметров | отн. ошибка |
|---|---|---|
| 3 слоя → 3072, MLP `3072→4096→7680`, полный промпт | 44M | 36.03% |
| то же, но `--drop-first 5` и 5 датасетов × 3000 | 44M | 32.04% |
| 3 слоя → 3072, MLP `3072→6144→6144→7680` | 104M | 29.92% |
| **6 слоёв → 6144, MLP `6144→8192→8192→7680` + `ln-per-layer`** | **180M** | **25.43%** |

Причин у выигрыша шесть-слоёв две, и обе измеримые.

**Первая — нормы слоёв растут как 13 → 557.** Норма выхода слоя Qwen3-0.6B по глубине
отличается в десятки раз. Если склеить слои как есть, вход адаптера почти целиком состоит из
глубоких слоёв, а ранний слой даёт **0.05%** — то есть его как будто нет. Именно поэтому
шесть слоёв идут вместе с `--norm ln-per-layer` (у каждого среза свои статистики, без
обучаемых параметров): после неё все шесть слоёв сопоставимы по масштабу и каждый начинает
работать. Отдельного замера «шесть слоёв без нормировки» у нас нет — но при таком разбросе
норм он выродился бы в «три глубоких слоя», то есть в 104M-строку таблицы выше.

**Вторая — глубина чтения.** Выход слоя 9 у 36-слойной модели — это абстракция, снятая после
девяти блоков. У 28-слойной модели такой же по смыслу абстракции соответствует не слой 7
(та же доля глубины), а другой слой — и «правильная» глубина чтения зависит от токена: для
поверхностных вещей (регистр, отдельный тег, эмодзи) информативнее ранние слои, для смысла —
поздние. Отдав адаптеру **несколько** глубин, мы позволяем ему выбирать чтение самому, вместо
того чтобы угадывать единственный «правильный» слой. Эмпирика это подтверждает: набор
`2,9,14,18,23,27` — не равномерная сетка (9 и 14 стоят ближе друг к другу), он выбирался
по замерам, а не по формуле.

**Дополнительный аргумент:** ровно этот набор — то, что ест наш micro-DiT
(`TextFusion` → `txtmlp`). Условие адаптера и условие студента — это одни и те же фичи,
разница только в цели (пространство 4B против своего). Для дистилляции это удобно: студент
учится на том же представлении текста, что и потом использует сам.

Почему не взять все 28 слоёв: во-первых, вход первого линейного слоя вырос бы до 28 672
(первый слой стал бы 235M параметров сам по себе), во-вторых, соседние слои сильно
коррелированы, в-третьих, студенту нужны именно эти шесть. Это выбор, а не измеренный
оптимум — сравнения «6 против 12 против 28» у нас нет.

### Почему MLP, а не что-то другое

Рассматривались четыре варианта:

1. **Дообучить сам Qwen3-0.6B**, чтобы он имитировал выход 4B. Дороже (трогаем 596M),
   ломает модель для любых других применений и требует учителя «в цикле» при каждом
   использовании — а нам нужен маленький замороженный артефакт.
2. **Линейная проекция** `6144 → 7680` (47M). Не проверяли отдельно, но вся лестница
   ёмкости показывает, что одной матрицы тут мало: области значений у энкодеров разной
   глубины и ширины, соответствие нелинейное.
3. **MLP** — выбрали его. Дешевле всех в обучении, учится по тексту без картинок, работает
   побатчно и параллельно по токенам.
4. **Transformer/Perceiver** — оставили как ступень развития (см. п. 4).

Форма MLP — `6144 → 8192 → 8192 → 7680`, три линейных слоя с двумя GELU(tanh):
лестница ёмкости 44M → 104M → 180M даёт монотонный выигрыш (32.0% → 29.9% → 25.4%).
В этой лестнице менялись две вещи сразу — ширина и число входных слоёв (3072 против 6144),
поэтому вклад именно ширины мы не выделяли: выигрыш дают и ёмкость, и более широкий вход.
Ясно только, что дальше 180M упирается уже не в параметры, а в данные.

### Почему одной поточечной MLP мало

Поточечная MLP обрабатывает каждый токен **независимо** — контекста соседей у неё нет.
А смысл в тексте распределён по токенам: `a bulldog` токенизатор режет как
`['a', 'Ġbulld', 'og']`, `a rottweiler` — как `['ro', 'tt', 'we', 'iler']`,
`striped bowtie` — как `['strip', 'ed', 'Ġbow', 'tie']`. Собрать «бульдога» из `Ġbulld`
и `og` поточечное отображение не может: у него нет способа посмотреть на соседа.

Это видно и в цифрах: если токен совпадает с целым словом, перенос почти идеальный
(`a red fox` — 23.8%, `an orange cat` — 24.0%), а на обрубках ошибка резко выше
(`a bulldog` — 26.6%, `a rottweiler` — 33.1%).

Было две попытки добавить контекст.

**Попытка 1 — окно соседей (v10).** Первый слой получает не один токен, а срез
`[x_{i-1}, x_i, x_{i+1}]` (281.1M параметров). Соседи действительно обучились:
нормы блоков `‖W_лев‖ = 15.9`, `‖W_прав‖ = 15.0` против `‖W_центр‖ = 124.0` — то есть модель
честно использует контекст. Но выигрыш ровный ~1 п.п. **на всех** промптах, включая цельные
слова, а худший обрубок `og` не сдвинулся вообще (46.1% и до, и после). Вывод: локальное окно
не решает задачу — «бульдог» собирается не из соседних токенов, а требует внимания
ко всему предложению.

**Попытка 2 — residual-ветка внимания (v11, в v14 она же).** Поверх MLP добавлен
самостоятельный модуль: `Attention(x) = out(блоки(norm(x)))`, где `out` инициализирован
**нулями**. Это принципиально: на старте обучения адаптер **побитово равен** старой поточечной
MLP, то есть новая архитектура не может испортить уже выученное, а обучение начинается
как точное дообучение. Дальше ветка сама набирает вес.

Параметры выбраны скромно: 2 блока, `d=1024`, 8 голов, 39.6M — всего 18% от общего размера.
Одного блока, скорее всего, хватило бы, но два дали лучший результат из измеренного, а цена
в 40M при бюджете 220M незначительна. Аттеншн без причинности: текст не автогенеративный,
ограничивать токен предыдущими незачем.

### Лосс и метрика

**Лосс:** MSE по токенам с пер-токенным весом

```
вес_i = 1 / max(‖y_i‖², 300²),   паддинг: 0.1
```

Без весов лосс на 97% состоит из ошибки **пяти служебных токенов шаблона** (attention-sink):
их норма ~6000 против ~180 у смысловых. Модель честно оптимизировала бы эти пять токенов,
а промпт не учила бы вовсе. Поэтому:

* `--norm-floor 300` — порог, выше которого токен считается «крупным» и его вклад
  нормируется по норме;
* `--drop-first 5` — первые пять токенов шаблона не идут ни в адаптер, ни в цель (срезка
  делается **после** адаптера, иначе позиционные ветки видели бы 251 токен на обучении
  и 256 на инференсе);
* `--pad-weight 0.1` — паддинг-позиции участвуют, но с малым весом.

**Метрика:** относительная ошибка по токену `‖pred − y‖ / ‖y‖`, отдельно по трём группам:

| группа | что это | токенов | средняя ‖y‖ | отн. ошибка |
|---|---|---|---|---|
| обычные | смысловые токены промпта (‖y‖ < 300) | 1499 (16 капшенов) | 180.7 | **21.3%** |
| крупные | attention-sink (‖y‖ ≥ 300), после `drop-first` их почти нет | 0–1 | ~6000 | — |
| паддинг | позиции после конца промпта — в последовательности их ~63% | 2517 | 143.5 | **60.0%** |

Паддинг не падает и не должен: содержания промпта в нём нет, вес в лоссе 0.1, а цель там —
обобщённый «режим ассистента» учителя с почти постоянной нормой (разброс по позициям ±18 при
средней 143.5). Смотреть надо только на «обычные»; ноль на паддинге означал бы зубрёжку
константы.

Отдельно про грабли: **element-wise MSE и R² здесь нельзя использовать**. В референсной
работе приведён `R² = 0.9995` — но он измерен без маски, и эти 0.9995 на 99.6% состоят из
подгонки тех самых пяти синк-токенов. Мы через это прошли и получили ровно такую же
«отличную» цифру при реальной относительной ошибке 36–38%.

---

## 3. Как обучали

Команда финального прогона:

```bash
python3 train_adapter.py --ds-path datasets \
    --max-per-group 3000 --ds-skip testg_320_640_temp --seed 42 \
    --drop-first 5 --attention 2 --attn-dim 1024 --attn-heads 8 \
    --empty-ratio 0.05 --epochs 8 --batch-size 32 --log-every 100 --eval-every 1 \
    --student-layers 2,9,14,18,23,27 --hidden 8192 --proj-layers 3 --norm ln-per-layer \
    --lr 1e-4 --attn-lr 2e-4 --lr-schedule cos --epoch-checkpoints \
    --resume adapter_v12_full.pt --adapter-out adapter_v14_bal.pt
```

**Без кэша энкодеров.** Оба энкодера живут онлайн в VRAM (учитель 7.5 ГБ в bf16), кэша
эмбеддингов нет. Это дороже по памяти (пик 18.5 ГиБ), зато любой датасет и любой текст
тренируются сразу, а не требуют предварительного прогона. Один шаг — 0.68 с (batch 32,
длина 256), эпоха — 568 шагов, ~7 минут.

**Данные и их состав.** Первый прогон был на 13 163 капшенах, потом 64 450, 95 992, 16 157 —
и каждый раз цифра улучшалась на десятые доли. Главное открытие оказалось не в объёме, а
в **составе**:

* обучение на полном пуле (323 173 капшена, 86% которого — один прозаический датасет)
  даёт модель, которая **уезжает в прозу**: короткие промпты ухудшаются на 2–3 п.п.,
  и на картинках это видно (бульдог становится всё менее похож);
* поэтому v14 учили на **сбалансированном пуле**: по 3000 капшенов на группу источников
  (`--max-per-group` группирует по верхней папке датасета), включая danbooru-теги
  с вычищенной служебной разметкой `*tags*`;
* v14 (19 157 капшенов) обошёл v12 (323 173) **по всем** колонкам. Состав важнее размера.

**Пустые промпты обязательны.** `--empty-ratio 0.05` — 5% обучающих примеров с пустым
промптом. Это нужно не для качества условия, а для CFG: у klein-base ветка uncond считается
по пустому промпту через тот же адаптер, и если её не учить, при `guidance_scale=4.0`
картинка портится. Наглядно: v11 (без пустых) — ошибка uncond **20.2%**, v12/v14 (с пустыми) —
**3.0% / 2.3%**. Разница в 8 раз — и именно она решает, будет ли CFG чистым.

**Расписание.** Обучение с нуля — 3e-4 с косинусом, дообучение — 1e-4 (MLP) и 2e-4 (ветка
внимания) с косинусом до нуля. Ветка внимания берёт свой LR: у неё нулевая инициализация,
и ей нужен чуть больший шаг, чтобы набрать вес за разумное время.

**Резюм и падения.** Прогон v14 один раз умер посреди эпохи (оборванный процесс, без
трейсбека). Чекпоинт `.last.pt` пишется каждую эпоху и содержит веса **и** оптимизатор **и**
номер эпохи, поэтому продолжение — это одна строка `--resume ...last.pt` при `--epochs`
больше пройденного. Срезы по эпохам (`--epoch-checkpoints`) пишутся отдельно, чтобы выбирать
не «последнюю», а лучшую эпоху.

---

## 4. Что получилось

### Версии (одни и те же данные: val 627 капшенов + шесть коротких промптов)

| чекпоинт | bulldog | rottweiler | striped | red fox | orange cat | теги | пустой | негатив | val 627 |
|---|---|---|---|---|---|---|---|---|---|
| v8 — поточечная 180M | 29.18 | 35.80 | 29.37 | 26.92 | 26.69 | 23.09 | 4.64 | 37.40 | 24.36 |
| v11 — +внимание, без пустых | 27.17 | 34.18 | 27.06 | 24.60 | 24.11 | 22.40 | 20.23 | 36.85 | 23.73 |
| v12 — 2 эпохи, полный пул 323k | 29.52 | 35.82 | 30.02 | 26.99 | 27.29 | 23.70 | 2.99 | 38.58 | 23.83 |
| **v14 — 8 эпох, баланс + теги** | **26.56** | **33.12** | **25.17** | **23.77** | **23.95** | **20.19** | **2.27** | **35.59** | **21.66** |

Исторические прогоны (v5–v7) учились на других val-наборах и в эту таблицу не включены:
их цифры 25.43 / 24.96 / 24.69% несопоставимы с «627» строками.

### Лестница эпох v14 (тот же прогон, те же данные)

| эпоха | bulldog | striped | red fox | orange | теги | пустой | val 627 |
|---|---|---|---|---|---|---|---|
| ep0 | 27.79 | 26.92 | 25.12 | 25.34 | 21.92 | 3.50 | 22.87 |
| ep1 | 27.20 | 26.17 | 24.53 | 24.70 | 20.94 | 3.23 | 22.55 |
| ep2 | 27.04 | 25.81 | 24.32 | 24.62 | 21.02 | 2.93 | 22.21 |
| ep3 | 26.77 | 25.41 | 24.11 | 24.25 | 20.46 | 2.70 | 22.01 |
| ep4 | 26.58 | 25.25 | 23.80 | 24.03 | 20.42 | 2.57 | 21.85 |
| ep5 | 26.61 | 25.30 | 23.87 | 24.12 | 20.32 | 2.40 | 21.73 |
| ep6 | 26.56 | 25.19 | 23.81 | 24.00 | 20.21 | 2.29 | 21.67 |
| **ep7** | 26.56 | **25.17** | **23.77** | **23.95** | **20.19** | **2.27** | **21.66** |

Кривая монотонна, но к концу насыщается: ep5 → ep7 добавляют по 0.03–0.06 п.п. Восемь эпох
на сбалансированном пуле — примерно там, где дальнейшее обучение перестаёт платить за себя.
Побочный приятный эффект: uncond падает с 3.50% до 2.27% — CFG становится чище от эпохи
к эпохе, ничего специально для этого делать не нужно.

### Что переносится, а что нет (по картинкам)

Панели «родной Qwen3-4B | Qwen3-0.6B + адаптер», дистиллированная klein, 4 шага, один сид
(`media/` в репозитории):

* **переносится**: сцена, свет, композиция, поза, одежда, стиль, рендер текста — надпись
  «Qwen3-0.6b» на табличке верна; аниме и danbooru-теги (кошачьи уши, бант, хвост, клыки,
  поза) работают; прозаический портрет женщины совпадает с родным почти неотличимо;
* **не переносится**: **идентичность объекта**. «Бульдог» выходит терьером, «ротвейлер» —
  тоже не ротвейлером. Это лучшая из наших цифр по бульдогу (26.56 против 29.18 у поточечной),
  и всё равно порода не та.

И отдельная методическая вещь, которую стоит держать в голове: **цифра не предсказывает
картинку**. v12 при лучшем val, чем v11, был хуже по коротким промптам и заметно хуже уводил
модель в прозу. Поэтому решение о публикации принималось по семплам, а замеры использовались
как навигация, а не как критерий приёмки.

---

## 5. Что не сработало (и почему это полезно знать)

| попытка | результат |
|---|---|
| окно соседей `k=1` вместо внимания (v10) | соседи обучились (‖W‖ 15.9/15.0 против 124.0 у центра), выигрыш ровный ~1 п.п., худший обрубок не сдвинулся |
| негативные промпты в обучении (v9, `--neg-ratio`) | ошибка негативов упала 37.4% → 15.7%, **но** появились синева и пятна на картинках при одном и том же сиде; откатили |
| обучение на полном пуле 323k (v12) | val улучшился, короткие промпты ухудшились — модель уехала в прозу |
| валидация по прозаическому val | скрыла провал на коротких промптах (проза доминировала в цифре) |
| element-wise MSE / R² как метрика | измеряет пять синк-токенов: «R² 0.9995» при реальной ошибке 36–38% |
| первые версии без `--drop-first` | 97% лосса — служебные токены шаблона |
| шесть слоёв **без** `ln-per-layer` | ранний слой даёт 0.05% входа, т.е. шести слоёв как будто нет |

---

## 6. Инженерные грабли

* **fp8 для энкодера учителя.** Официальный fp8-файл klein diffusers не читает
  (`chunk expects at least a 1-dimensional tensor` на scale-тензорах). Работает
  `QuantoConfig(weights="float8")` через optimum-quanto (устаревший, но живой);
  torchao-вариант с текущими torch молча не срабатывает.
* **`is_distilled` в klein.** Дистиллированная klein молча отключает CFG
  (`do_classifier_free_guidance = guidance_scale > 1 and not is_distilled`), а в пайплайне
  нет `negative_prompt` — только `negative_prompt_embeds`. Для экспериментов с CFG нужен
  неизбелённый близнец `FLUX.2-klein-base-4B`; при загрузке из локальной папки
  `model_index.json` надо править на `is_distilled: false`, иначе CFG отключится незаметно.
* **Два флага `--len`.** Длина условия 256, но первые пять токенов шаблона — синк;
  срезка обязана быть в одном и том же месте на обучении и инференсе, иначе позиционные
  ветки видят разное число токенов.
* **Скорость.** Дистиллированная klein fp8, 4 шага — 1–2 с на картинку 768×1280;
  base fp8, 50 шагов с CFG 4 — 18–30 с; форвард энкодера учителя — ~0.3 с.

---

## 7. Открытые вопросы

1. **Идентичность объекта** — главный провал. Данные и эпохи его не лечат (v6: +51k капшенов
   и +1 эпоха дали ту же цифру и ту же картинку). Следующие нерадикальные ступени:
   дилатация окна соседей, второй уровень смешивания, token-mixing MLP (ранг 64, +0.2M).
2. **Нет критерия приёмки по существу.** Правильный тест — сравнить поле скоростей klein-DiT
   с родным условием и через адаптер (одни латенты, один t) и решать по этой цифре,
   а не по проценту ошибки условия.
3. **Нужен ли адаптер вообще.** Если в конкретном пайплайне можно позволить себе Qwen3-4B
   (+7.5 ГБ VRAM, ~0.3 с/шаг), адаптер не нужен: кормите учителя родным энкодером,
   а наш 0.6B оставьте студенту. Адаптер — это размен «качество против VRAM и скорости».
4. **Сравнение 6 слоёв против 12 и 28** — не измерено, мы остановились на рабочем наборе.

---

## 8. Как повторить

```bash
# 1. Обучение с нуля (сбалансированный пул + теги, ветка внимания с нуля)
python3 src/train_adapter.py --ds-path datasets --max-per-group 3000 --seed 42 \
    --drop-first 5 --attention 2 --attn-dim 1024 --attn-heads 8 --empty-ratio 0.05 \
    --epochs 8 --batch-size 32 --student-layers 2,9,14,18,23,27 \
    --hidden 8192 --proj-layers 3 --norm ln-per-layer --lr 3e-4 --attn-lr 2e-4 \
    --lr-schedule cos --epoch-checkpoints --adapter-out adapter_v14_bal.pt

# 2. Экспорт в safetensors (схема пишется в метаданные файла, self-check внутри)
python3 src/export_safetensors.py --in adapter_v14_bal.pt --attn-heads 8 \
    --student-layers 2,9,14,18,23,27 --drop-first 5 --len 256

# 3. Использование: подменяем у пайплайна только расчёт условия
python3 example.py "A red fox walks through a snowy forest at dusk." fox.png
```

Требования: GPU с ~24 ГБ (оба энкодера онлайн + адаптер + активации, пик обучения 18.5 ГиБ),
diffusers с поддержкой `Flux2KleinPipeline`, transformers с Qwen3, и для fp8-учителя —
optimum-quanto.