recoilme commited on
Commit
c1ca515
·
1 Parent(s): 9932fa0

TRAINING.ru.md: подробный русский разбор обучения адаптера

Browse files

- почему 6 слоёв ТЕ, а не 3 (нормы 13 -> 557, глубина чтения, совпадение с набором студента)
- почему MLP такой формы, почему поточечности мало и что дала residual-ветка внимания
- лосс, метрика и три группы токенов; почему R² здесь врёт
- данные: баланс по группам + danbooru-теги, пустые промпты, отказ от негативов
- таблицы: версии, лестница 8 эпох, что не сработало, инженерные грабли
- src/export_safetensors.py добавлен, чтобы команды из статьи работали

Files changed (3) hide show
  1. README.md +4 -0
  2. TRAINING.ru.md +357 -0
  3. src/export_safetensors.py +60 -0
README.md CHANGED
@@ -18,6 +18,8 @@ _Replace klein's 4B text encoder with a 0.6B one — 7.5 GB VRAM and ~0.3 s/step
18
 
19
  Train status: trained on one Vast.ai GPU (RTX 5090, 32 GB), text only — no images, no VAE, no diffusion.
20
 
 
 
21
  [support train please..](#donations)
22
 
23
  * Adapter: point-wise MLP `6144 -> 8192 -> 8192 -> 7680` (180.4M) **plus a 2-block residual attention branch** (39.6M) — `adapter_v14_bal.safetensors`
@@ -93,6 +95,8 @@ example.py diffusers usage example
93
  src/adapter_lib.py adapter schema + loading
94
  src/klein_condition.py encode_prompt replacement for the klein pipeline
95
  src/train_adapter.py training (online encoders, no cached embeddings)
 
 
96
  media/before_after.png main illustration: native Qwen3-4B vs Qwen3-0.6B + adapter
97
  media/tags_before_after.png danbooru tags and anime-style prompts
98
  media/limitations.png known issue: object identity (bulldog)
 
18
 
19
  Train status: trained on one Vast.ai GPU (RTX 5090, 32 GB), text only — no images, no VAE, no diffusion.
20
 
21
+ Разбор обучения по-русски, с цифрами и историей решений: [TRAINING.ru.md](TRAINING.ru.md)
22
+
23
  [support train please..](#donations)
24
 
25
  * Adapter: point-wise MLP `6144 -> 8192 -> 8192 -> 7680` (180.4M) **plus a 2-block residual attention branch** (39.6M) — `adapter_v14_bal.safetensors`
 
95
  src/adapter_lib.py adapter schema + loading
96
  src/klein_condition.py encode_prompt replacement for the klein pipeline
97
  src/train_adapter.py training (online encoders, no cached embeddings)
98
+ src/export_safetensors.py .pt -> .safetensors with the schema metadata inside
99
+ TRAINING.ru.md detailed Russian write-up: how it was trained and why
100
  media/before_after.png main illustration: native Qwen3-4B vs Qwen3-0.6B + adapter
101
  media/tags_before_after.png danbooru tags and anime-style prompts
102
  media/limitations.png known issue: object identity (bulldog)
TRAINING.ru.md ADDED
@@ -0,0 +1,357 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Как обучен адаптер Qwen3-0.6B → Qwen3-4B: подробный разбор
2
+
3
+ Это технический разбор адаптера [`adapter_v14_bal.safetensors`](adapter_v14_bal.safetensors) —
4
+ 220M-маппинга из пространства текстового энкодера Qwen3-0.6B в пространство текстового
5
+ энкодера FLUX.2-klein-4B (Qwen3-4B). Английская карточка модели — в [README.md](README.md),
6
+ здесь то же самое, но с цифрами замеров, историей решений и разбором того, что не сработало.
7
+
8
+ ---
9
+
10
+ ## 1. Зачем вообще адаптер
11
+
12
+ Учитель в нашей дистилляции — FLUX.2-klein-4B. Его DiT обучен на текстовом условии, которое
13
+ считает его собственный энкодер: Qwen3-4B, 36 слоёв, hidden 2560, склейка слоёв **9, 18, 27** —
14
+ это `joint_attention_dim = 7680`, и ровно эти три слоя берёт сам пайплайн diffusers.
15
+
16
+ Наш micro-студент имеет дешёвый энкодер — **Qwen3-0.6B** (28 слоёв, hidden 1024), потому что
17
+ в micro-модели весь бюджет уходит в DiT, а не в текст.
18
+
19
+ Возникает развилка при дистилляции:
20
+
21
+ * кормить учителя **его родным** условием (нужен Qwen3-4B: +7.5 ГБ VRAM и ~0.3 с на шаг);
22
+ * либо кормить учителя **нашим** условием, но переведённым в его пространство.
23
+
24
+ Второй путь и есть адаптер: `y = A(наш_ТЕ(x))`, где `A` учится по тексту, без картинок,
25
+ без VAE и без диффузии. Цель — не «похоже на подсказку», а **в точности то, что дал бы родной
26
+ энкодер учителя на том же тексте**. Тогда учитель ведёт студента в том режиме, в каком он
27
+ работал всегда, а студент дистиллируется от условия, которое сам и умеет считать.
28
+
29
+ Ключевое упрощение: задача — чистая регрессия «эмбеддинги в эмбеддинги». Токенизаторы
30
+ у Qwen3-0.6B и Qwen3-4B **идентичны** (vocab 151669, тот же `chat_template`, тот же
31
+ `padding_side=right`), поэтому позиции ученика и учителя совпадают ровно, без выравнивания.
32
+ У референсной работы (`recoilme/sdxs`, `qwen_adapter_2b_4b`) токенизаторы разные, и там
33
+ позиции частично разъезжаются — у нас этой проблемы нет.
34
+
35
+ ---
36
+
37
+ ## 2. Как он устроен
38
+
39
+ ```
40
+ текст → Qwen3-0.6B (заморожен) → hidden states слоёв 2,9,14,18,23,27 → склейка (B, 256, 6144)
41
+ → адаптер → (B, 256, 7680) → DiT klein
42
+
43
+ y_i = MLP(x_i) + Attention(x)_i
44
+ │ │
45
+ │ └─ residual-ветка: 2 блока self-attention, d=1024, 8 голов, 39.6M
46
+ └─ поточечная MLP: 6144 → 8192 → 8192 → 7680, GELU(tanh), 180.4M
47
+ ```
48
+
49
+ Итого 220.0M параметров. Порядок склейки — послойно-майорный, `[слой2 | слой9 | … | слой27]`,
50
+ ровно так же, как klein собирает свои три слоя (`stack(dim=1)` → `permute`).
51
+
52
+ Применяется адаптер **только к условию учителя**. Наш DiT по-прежнему получает свой 4D-контекст
53
+ `(B, L, 6, 1024)` и обрабатывает его сам (`TextFusion` + `txtmlp`) — адаптер существует для
54
+ взаимодействия с учителем, а не для студента.
55
+
56
+ ### Почему шесть слоёв, а не три
57
+
58
+ Соблазн взять симметрию: klein использует три слоя из 36 (25%, 50%, 75%) — значит, и нам взять
59
+ три. Мы так и начали, и это оказалось хуже по всем замерам:
60
+
61
+ | вход адаптера | параметров | отн. ошибка |
62
+ |---|---|---|
63
+ | 3 слоя → 3072, MLP `3072→4096→7680`, полный промпт | 44M | 36.03% |
64
+ | то же, но `--drop-first 5` и 5 датасетов × 3000 | 44M | 32.04% |
65
+ | 3 слоя → 3072, MLP `3072→6144→6144→7680` | 104M | 29.92% |
66
+ | **6 слоёв → 6144, MLP `6144→8192→8192→7680` + `ln-per-layer`** | **180M** | **25.43%** |
67
+
68
+ Причин у выигрыша шесть-слоёв две, и обе измеримые.
69
+
70
+ **Первая — нормы слоёв растут как 13 → 557.** ��орма выхода слоя Qwen3-0.6B по глубине
71
+ отличается в десятки раз. Если склеить слои как есть, вход адаптера почти целиком состоит из
72
+ глубоких слоёв, а ранний слой даёт **0.05%** — то есть его как будто нет. Именно поэтому
73
+ шесть слоёв идут вместе с `--norm ln-per-layer` (у каждого среза свои статистики, без
74
+ обучаемых параметров): после неё все шесть слоёв сопоставимы по масштабу и каждый начинает
75
+ работать. Отдельного замера «шесть слоёв без нормировки» у нас нет — но при таком разбросе
76
+ норм он выродился бы в «три глубоких слоя», то есть в 104M-строку таблицы выше.
77
+
78
+ **Вторая — глубина чтения.** Выход слоя 9 у 36-слойной модели — это абстракция, снятая после
79
+ девяти блоков. У 28-слойной модели такой же по смыслу абстракции соответствует не слой 7
80
+ (та же доля глубины), а другой слой — и «правильная» глубина чтения зависит от токена: для
81
+ поверхностных вещей (регистр, отдельный тег, эмодзи) информативнее ранние слои, для смысла —
82
+ поздние. Отдав адаптеру **несколько** глубин, мы позволяем ему выбирать чтение самому, вместо
83
+ того чтобы угадывать единственный «правильный» слой. Эмпирика это подтверждает: набор
84
+ `2,9,14,18,23,27` — не равномерная сетка (9 и 14 стоят ближе друг к другу), он выбирался
85
+ по замерам, а не по формуле.
86
+
87
+ **Дополнительный аргумент:** ровно этот набор — то, что ест наш micro-DiT
88
+ (`TextFusion` → `txtmlp`). Условие адаптера и условие студента — это одни и те же фичи,
89
+ разница только в цели (пространство 4B против своего). Для дистилляции это удобно: студент
90
+ учится на том же представлении текста, что и потом использует сам.
91
+
92
+ Почему не взять все 28 слоёв: во-первых, вход первого линейного слоя вырос бы до 28 672
93
+ (первый слой стал бы 235M параметров сам по себе), во-вторых, соседние слои сильно
94
+ коррелированы, в-третьих, студенту нужны именно эти шесть. Это выбор, а не измеренный
95
+ оптимум — сравнения «6 против 12 против 28» у нас нет.
96
+
97
+ ### Почему MLP, а не что-то другое
98
+
99
+ Рассматривались четыре варианта:
100
+
101
+ 1. **Дообучить сам Qwen3-0.6B**, чтобы он имитировал выход 4B. Дороже (трогаем 596M),
102
+ ломает модель для любых других применений и требует учителя «в цикле» при каждом
103
+ использовании — а нам нужен маленький замороженный артефакт.
104
+ 2. **Линейная проекция** `6144 → 7680` (47M). Не проверяли отдельно, но вся лестница
105
+ ёмкости показывает, что одной матрицы тут мало: области значений у энкодеров разной
106
+ глубины и ширины, соответствие нелинейное.
107
+ 3. **MLP** — выбрали его. Дешевле всех в обучении, учится по тексту без картинок, работает
108
+ побатчно и параллельно по токенам.
109
+ 4. **Transformer/Perceiver** — оставили как ступень развития (см. п. 4).
110
+
111
+ Форма MLP — `6144 → 8192 → 8192 → 7680`, три линейных слоя с двумя GELU(tanh):
112
+ лестница ёмкости 44M → 104M → 180M даёт монотонный выигрыш (32.0% → 29.9% → 25.4%).
113
+ В этой лестнице менялись две вещи сразу — ширина и число входных слоёв (3072 против 6144),
114
+ поэтому вклад именно ширины мы не выделяли: выигрыш дают и ёмкость, и более широкий вход.
115
+ Ясно только, что ��альше 180M упирается уже не в параметры, а в данные.
116
+
117
+ ### Почему одной поточечной MLP мало
118
+
119
+ Поточечная MLP обрабатывает каждый токен **независимо** — контекста соседей у неё нет.
120
+ А смысл в тексте распределён по токенам: `a bulldog` токенизатор режет как
121
+ `['a', 'Ġbulld', 'og']`, `a rottweiler` — как `['ro', 'tt', 'we', 'iler']`,
122
+ `striped bowtie` — как `['strip', 'ed', 'Ġbow', 'tie']`. Собрать «бульдога» из `Ġbulld`
123
+ и `og` поточечное отображение не может: у него нет способа посмотреть на соседа.
124
+
125
+ Это видно и в цифрах: если токен совпадает с целым словом, перенос почти идеальный
126
+ (`a red fox` — 23.8%, `an orange cat` — 24.0%), а на обрубках ошибка резко выше
127
+ (`a bulldog` — 26.6%, `a rottweiler` — 33.1%).
128
+
129
+ Было две попытки добавить контекст.
130
+
131
+ **Попытка 1 — окно соседей (v10).** Первый слой получает не один токен, а срез
132
+ `[x_{i-1}, x_i, x_{i+1}]` (281.1M параметров). Соседи действительно обучились:
133
+ нормы блоков `‖W_лев‖ = 15.9`, `‖W_прав‖ = 15.0` против `‖W_центр‖ = 124.0` — то есть модель
134
+ честно использует контекст. Но выигрыш ровный ~1 п.п. **на всех** промптах, включая цельные
135
+ слова, а худший обрубок `og` не сдвинулся вообще (46.1% и до, и после). Вывод: локальное окно
136
+ не решает задачу — «бульдог» собирается не из соседних токенов, а требует внимания
137
+ ко всему предложению.
138
+
139
+ **Попытка 2 — residual-ветка внимания (v11, в v14 она же).** Поверх MLP добавлен
140
+ самостоятельный модуль: `Attention(x) = out(блоки(norm(x)))`, где `out` инициализирован
141
+ **нулями**. Это принципиально: на старте обучения адаптер **побитово равен** старой поточечной
142
+ MLP, то есть новая архитектура не может испортить уже выученное, а обучение начинается
143
+ как точное дообучение. Дальше ветка сама набирает вес.
144
+
145
+ Параметры выбраны скромно: 2 блока, `d=1024`, 8 голов, 39.6M — всего 18% от общего размера.
146
+ Одного блока, скорее всего, хватило бы, но два дали лучший результат из измеренного, а цена
147
+ в 40M при бюджете 220M незначительна. Аттеншн без причинности: текст не автогенеративный,
148
+ ограничивать токен предыдущими незачем.
149
+
150
+ ### Лосс и метрика
151
+
152
+ **Лосс:** MSE по токенам с пер-токенным весом
153
+
154
+ ```
155
+ вес_i = 1 / max(‖y_i‖², 300²), паддинг: 0.1
156
+ ```
157
+
158
+ Без весов лосс на 97% состоит из ошибки **пяти служебных токенов шаблона** (attention-sink):
159
+ их норма ~6000 против ~180 у смысловых. Модель честно оптимизировала бы эти пять токенов,
160
+ а промпт не учила бы вовсе. Поэтому:
161
+
162
+ * `--norm-floor 300` — порог, выше которого токен считается «крупным» и его вклад
163
+ нормируется по норме;
164
+ * `--drop-first 5` — первые пять токенов шаблона не идут ни в адаптер, ни в цель (срезка
165
+ делается **после** адаптера, иначе позиционные ветки видели бы 251 токен на обучении
166
+ и 256 на инференсе);
167
+ * `--pad-weight 0.1` — паддинг-позиции участвуют, но с малым весом.
168
+
169
+ **Метрика:** относительная ошибка по токену `‖pred − y‖ / ‖y‖`, отдельно по трём группам:
170
+
171
+ | группа | что это | токенов | средняя ‖y‖ | отн. ошибка |
172
+ |---|---|---|---|---|
173
+ | обычные | смысловые токены промпта (‖y‖ < 300) | 1499 (16 капшенов) | 180.7 | **21.3%** |
174
+ | крупные | attention-sink (‖y‖ ≥ 300), после `drop-first` их почти нет | 0–1 | ~6000 | — |
175
+ | паддинг | позиции после конца промпта — в последовательности их ~63% | 2517 | 143.5 | **60.0%** |
176
+
177
+ Паддинг не падает и не должен: содержания промпта в нём нет, вес в лоссе 0.1, а цель там —
178
+ обобщённый «режим ассистента» учителя с почти постоянной нормой (разброс по позициям ±18 при
179
+ средней 143.5). Смотреть надо только на «обычные»; ноль на паддинге означал бы зубрёжку
180
+ константы.
181
+
182
+ Отдельно про грабли: **element-wise MSE и R² здесь нельзя использовать**. В референсной
183
+ работе приведён `R² = 0.9995` — но он измерен без маски, и эти 0.9995 на 99.6% состоят из
184
+ подгонки тех самых пяти синк-токенов. Мы через это прошли и получили ровно такую же
185
+ «отличную» цифру при реальной относительной ошибке 36–38%.
186
+
187
+ ---
188
+
189
+ ## 3. Как обучали
190
+
191
+ Команда финального прогона:
192
+
193
+ ```bash
194
+ python3 train_adapter.py --ds-path datasets \
195
+ --max-per-group 3000 --ds-skip testg_320_640_temp --seed 42 \
196
+ --drop-first 5 --attention 2 --attn-dim 1024 --attn-heads 8 \
197
+ --empty-ratio 0.05 --epochs 8 --batch-size 32 --log-every 100 --eval-every 1 \
198
+ --student-layers 2,9,14,18,23,27 --hidden 8192 --proj-layers 3 --norm ln-per-layer \
199
+ --lr 1e-4 --attn-lr 2e-4 --lr-schedule cos --epoch-checkpoints \
200
+ --resume adapter_v12_full.pt --adapter-out adapter_v14_bal.pt
201
+ ```
202
+
203
+ **Без кэша энкодеров.** Оба энкодера живут онлайн в VRAM (учитель 7.5 ГБ в bf16), кэша
204
+ эмбеддингов нет. Это дороже по памяти (пик 18.5 ГиБ), зато любой датасет и любой текст
205
+ тренируются сразу, а не требуют предварительного прогона. Один шаг — 0.68 с (batch 32,
206
+ длина 256), эпоха — 568 шагов, ~7 минут.
207
+
208
+ **Данные и их состав.** Первый прогон был на 13 163 капшенах, потом 64 450, 95 992, 16 157 —
209
+ и каждый раз цифра улучшалась на десятые доли. Главное открытие оказалось не в объёме, а
210
+ в **составе**:
211
+
212
+ * обучение на полном пуле (323 173 капшена, 86% которого — один прозаический датасет)
213
+ даёт модель, которая **уезжает в прозу**: короткие промпты ухудшаются на 2–3 п.п.,
214
+ и на картинках это видно (бульдог становится всё менее похож);
215
+ * поэтому v14 учили на **сбалансированном пуле**: по 3000 капшенов на группу источников
216
+ (`--max-per-group` группирует по верхней папке датасета), включая danbooru-теги
217
+ с вычищенной служебной разметкой `*tags*`;
218
+ * v14 (19 157 капшенов) обошёл v12 (323 173) **по всем** колонкам. Состав важнее размера.
219
+
220
+ **Пустые промпты обязательны.** `--empty-ratio 0.05` — 5% обучающих примеров с пустым
221
+ промптом. Это нужно не для качества условия, а для CFG: у klein-base ветка uncond считается
222
+ по пустому промпту через тот же адаптер, и если её не учить, при `guidance_scale=4.0`
223
+ картинка портится. Наглядно: v11 (без пустых) — ошибка uncond **20.2%**, v12/v14 (с пустыми) —
224
+ **3.0% / 2.3%**. Разница в 8 раз — и именно она решает, будет ли CFG чистым.
225
+
226
+ **Расписание.** Обучение с нуля — 3e-4 с косинусом, дообучение — 1e-4 (MLP) и 2e-4 (ветка
227
+ внимания) с косинусом до нуля. Ветка внимания берёт свой LR: у неё нулевая инициализация,
228
+ и ей нужен чуть больший шаг, чтобы набрать вес за разумное время.
229
+
230
+ **Резюм и падения.** Прогон v14 один раз умер посреди эпохи (оборванный процесс, без
231
+ трейсбека). Чекпоинт `.last.pt` пишется каждую эпоху и содержит веса **и** оптимизатор **и**
232
+ номер эпохи, поэтому продолжение — это одна строка `--resume ...last.pt` при `--epochs`
233
+ больше пройденного. Срезы по эпохам (`--epoch-checkpoints`) пишутся отдельно, чтобы выбирать
234
+ не «последнюю», а лучшую эпоху.
235
+
236
+ ---
237
+
238
+ ## 4. Что получилось
239
+
240
+ ### Версии (одни и те же данные: val 627 капшенов + шесть коротких промптов)
241
+
242
+ | чекпоинт | bulldog | rottweiler | striped | red fox | orange cat | теги | пустой | негатив | val 627 |
243
+ |---|---|---|---|---|---|---|---|---|---|
244
+ | v8 — поточечная 180M | 29.18 | 35.80 | 29.37 | 26.92 | 26.69 | 23.09 | 4.64 | 37.40 | 24.36 |
245
+ | v11 — +внимание, без пустых | 27.17 | 34.18 | 27.06 | 24.60 | 24.11 | 22.40 | 20.23 | 36.85 | 23.73 |
246
+ | v12 — 2 эпохи, полный пул 323k | 29.52 | 35.82 | 30.02 | 26.99 | 27.29 | 23.70 | 2.99 | 38.58 | 23.83 |
247
+ | **v14 — 8 эпох, баланс + теги** | **26.56** | **33.12** | **25.17** | **23.77** | **23.95** | **20.19** | **2.27** | **35.59** | **21.66** |
248
+
249
+ Исторические прогоны (v5–v7) учились на других val-наборах и в эту таблицу не включены:
250
+ их цифры 25.43 / 24.96 / 24.69% несопоставимы с «627» строками.
251
+
252
+ ### Лестница эпох v14 (тот же прогон, те же данные)
253
+
254
+ | эпоха | bulldog | striped | red fox | orange | теги | пустой | val 627 |
255
+ |---|---|---|---|---|---|---|---|
256
+ | ep0 | 27.79 | 26.92 | 25.12 | 25.34 | 21.92 | 3.50 | 22.87 |
257
+ | ep1 | 27.20 | 26.17 | 24.53 | 24.70 | 20.94 | 3.23 | 22.55 |
258
+ | ep2 | 27.04 | 25.81 | 24.32 | 24.62 | 21.02 | 2.93 | 22.21 |
259
+ | ep3 | 26.77 | 25.41 | 24.11 | 24.25 | 20.46 | 2.70 | 22.01 |
260
+ | ep4 | 26.58 | 25.25 | 23.80 | 24.03 | 20.42 | 2.57 | 21.85 |
261
+ | ep5 | 26.61 | 25.30 | 23.87 | 24.12 | 20.32 | 2.40 | 21.73 |
262
+ | ep6 | 26.56 | 25.19 | 23.81 | 24.00 | 20.21 | 2.29 | 21.67 |
263
+ | **ep7** | 26.56 | **25.17** | **23.77** | **23.95** | **20.19** | **2.27** | **21.66** |
264
+
265
+ Кривая монотонна, но к концу насыщается: ep5 → ep7 добавляют по 0.03–0.06 п.п. Восемь эпох
266
+ на сбалансированном пуле — примерно там, где дальнейшее обучение перестаёт платить за себя.
267
+ Побочный приятный эффект: uncond падает с 3.50% до 2.27% — CFG становится чище от эпохи
268
+ к эпохе, ничего специально для этого делать не нужно.
269
+
270
+ ### Что переносится, а что нет (по картинкам)
271
+
272
+ Панели «родной Qwen3-4B | Qwen3-0.6B + адаптер», дистиллированная klein, 4 шага, один сид
273
+ (`media/` в репозитории):
274
+
275
+ * **переносится**: сцена, свет, композиция, поза, одежда, стиль, рендер текста — надпись
276
+ «Qwen3-0.6b» на табличке верна; аниме и danbooru-теги (кошачьи уши, бант, хвост, клыки,
277
+ поза) работают; прозаический портрет женщины совпадает с родным почти неотличимо;
278
+ * **не переносится**: **идентичность объекта**. «Бульдог» выходит терьером, «ротвейлер» —
279
+ тоже не ротвейлером. Это лучшая из наших цифр по бульдогу (26.56 против 29.18 у поточечной),
280
+ и всё равно порода не та.
281
+
282
+ И отдельная методическая вещь, которую стоит держать в голове: **цифра не предсказывает
283
+ картинку**. v12 при лучшем val, чем v11, был хуже по коротким промптам и заметно хуже уводил
284
+ модель в прозу. Поэтому решение о публикации принималось по семплам, а замеры использовались
285
+ как навигация, а не как критерий приёмки.
286
+
287
+ ---
288
+
289
+ ## 5. Что не сработало (и почему это полезно знать)
290
+
291
+ | попытка | результат |
292
+ |---|---|
293
+ | окно соседей `k=1` вместо внимания (v10) | соседи обучились (‖W‖ 15.9/15.0 против 124.0 у центра), выигрыш ровный ~1 п.п., худший обрубок не сдвинулся |
294
+ | негативные промпты в обучении (v9, `--neg-ratio`) | ошибка негативов упала 37.4% → 15.7%, **но** появились синева и пятна на картинках при одном и том же сиде; откатили |
295
+ | обучение на полном пуле 323k (v12) | val улучшился, короткие промпты ухудшились — модель уехала в прозу |
296
+ | валидация по проз��ическому val | скрыла провал на коротких промптах (проза доминировала в цифре) |
297
+ | element-wise MSE / R² как метрика | измеряет пять синк-токенов: «R² 0.9995» при реальной ошибке 36–38% |
298
+ | первые версии без `--drop-first` | 97% лосса — служебные токены шаблона |
299
+ | шесть слоёв **без** `ln-per-layer` | ранний слой даёт 0.05% входа, т.е. шести слоёв как будто нет |
300
+
301
+ ---
302
+
303
+ ## 6. Инженерные грабли
304
+
305
+ * **fp8 для энкодера учителя.** Официальный fp8-файл klein diffusers не читает
306
+ (`chunk expects at least a 1-dimensional tensor` на scale-тензорах). Работает
307
+ `QuantoConfig(weights="float8")` через optimum-quanto (устаревший, но живой);
308
+ torchao-вариант с текущими torch молча не срабатывает.
309
+ * **`is_distilled` в klein.** Дистиллированная klein молча отключает CFG
310
+ (`do_classifier_free_guidance = guidance_scale > 1 and not is_distilled`), а в пайплайне
311
+ нет `negative_prompt` — только `negative_prompt_embeds`. Для экспериментов с CFG нужен
312
+ неизбелённый близнец `FLUX.2-klein-base-4B`; при загрузке из локальной папки
313
+ `model_index.json` надо править на `is_distilled: false`, иначе CFG отключится незаметно.
314
+ * **Два флага `--len`.** Длина условия 256, но первые пять токенов шаблона — синк;
315
+ срезка обязана быть в одном и том же месте на обучении и инференсе, иначе позиционные
316
+ ветки видят разное число токенов.
317
+ * **Скорость.** Дистиллированная klein fp8, 4 шага — 1–2 с на картинку 768×1280;
318
+ base fp8, 50 шагов с CFG 4 — 18–30 с; форвард энкодера учителя — ~0.3 с.
319
+
320
+ ---
321
+
322
+ ## 7. Открытые вопросы
323
+
324
+ 1. **Идентичность объекта** — главный провал. Данные и эпохи его не лечат (v6: +51k капшенов
325
+ и +1 эпоха дали ту же цифру и ту же картинку). Следующие нерадикальные ступени:
326
+ дилатация окна соседей, второй уровень смешивания, token-mixing MLP (ранг 64, +0.2M).
327
+ 2. **Нет критерия приёмки по существу.** Правильный тест — сравнить поле скоростей klein-DiT
328
+ с родным условием и через адаптер (одни латенты, один t) и решать по этой цифре,
329
+ а не по проценту ошибки условия.
330
+ 3. **Нужен ли адаптер вообще.** Если в конкретном пайплайне можно позволить себе Qwen3-4B
331
+ (+7.5 ГБ VRAM, ~0.3 с/шаг), адаптер не нужен: кормите учителя родным энкодером,
332
+ а наш 0.6B оставьте студенту. Адаптер — это размен «качество против VRAM и скорости».
333
+ 4. **Сравнение 6 слоёв против 12 и 28** — не измерено, мы остановились на рабочем наборе.
334
+
335
+ ---
336
+
337
+ ## 8. Как повторить
338
+
339
+ ```bash
340
+ # 1. Обучение с нуля (сбалансированный пул + теги, ветка внимания с нуля)
341
+ python3 src/train_adapter.py --ds-path datasets --max-per-group 3000 --seed 42 \
342
+ --drop-first 5 --attention 2 --attn-dim 1024 --attn-heads 8 --empty-ratio 0.05 \
343
+ --epochs 8 --batch-size 32 --student-layers 2,9,14,18,23,27 \
344
+ --hidden 8192 --proj-layers 3 --norm ln-per-layer --lr 3e-4 --attn-lr 2e-4 \
345
+ --lr-schedule cos --epoch-checkpoints --adapter-out adapter_v14_bal.pt
346
+
347
+ # 2. Экспорт в safetensors (схема пишется в метаданные файла, self-check внутри)
348
+ python3 src/export_safetensors.py --in adapter_v14_bal.pt --attn-heads 8 \
349
+ --student-layers 2,9,14,18,23,27 --drop-first 5 --len 256
350
+
351
+ # 3. Использование: подменяем у пайплайна только расчёт условия
352
+ python3 example.py "A red fox walks through a snowy forest at dusk." fox.png
353
+ ```
354
+
355
+ Требования: GPU с ~24 ГБ (оба энкодера онлайн + адаптер + активации, пик обучения 18.5 ГиБ),
356
+ diffusers с поддержкой `Flux2KleinPipeline`, transformers с Qwen3, и для fp8-учителя —
357
+ optimum-quanto.
src/export_safetensors.py ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Экспорт обученного адаптера в safetensors (+ метаданные схемы внутри файла).
3
+
4
+ python3 export_safetensors.py --in adapter_v6_180M.pt --student-layers 2,9,14,18,23,27 \
5
+ --drop-first 5 --len 256 --dtype bf16
6
+
7
+ Метаданные кладутся в сам файл, поэтому примеру/тесту не нужны флаги --hidden,
8
+ --proj-layers, --norm: они выводятся из весов (adapter_lib.infer_arch).
9
+ """
10
+ import argparse
11
+ import os
12
+
13
+ import torch
14
+
15
+
16
+ def main():
17
+ ap = argparse.ArgumentParser(description="adapter .pt -> .safetensors")
18
+ ap.add_argument("--in", dest="src", required=True)
19
+ ap.add_argument("--out", default=None, help="по умолчанию: то же имя с .safetensors")
20
+ ap.add_argument("--dtype", default="fp32", choices=["fp32", "bf16"],
21
+ help="bf16: файл вдвое меньше; загрузчик поднимает веса в fp32 при инференсе")
22
+ ap.add_argument("--student-layers", default="2,9,14,18,23,27")
23
+ ap.add_argument("--teacher-layers", default="9,18,27")
24
+ ap.add_argument("--drop-first", type=int, default=5)
25
+ ap.add_argument("--len", type=int, default=256)
26
+ ap.add_argument("--attn-heads", type=int, default=8, help="число голов ветки внимания (из весов не выводится)")
27
+ args = ap.parse_args()
28
+
29
+ from adapter_lib import load_adapter, save_safetensors
30
+
31
+ out = args.out or os.path.splitext(args.src)[0] + ".safetensors"
32
+ dtype = torch.float32 if args.dtype == "fp32" else torch.bfloat16
33
+ adapter, meta = load_adapter(args.src, device="cpu", dtype=dtype,
34
+ n_slices=len(args.student_layers.split(",")))
35
+ md = {"student_layers": args.student_layers, "teacher_layers": args.teacher_layers,
36
+ "drop_first": args.drop_first, "len": args.len, "format": "sdxs-micro text adapter",
37
+ "source": os.path.basename(args.src),
38
+ "hidden": meta["hidden"], "proj_layers": meta["proj_layers"], "norm": meta["norm"],
39
+ "attention": meta["attention"], "attn_heads": args.attn_heads}
40
+ if meta["attention"]:
41
+ md["attn_dim"] = meta["attn_dim"]
42
+ save_safetensors(adapter, out, md)
43
+ n = sum(p.numel() for p in adapter.parameters())
44
+ extra = (f", ветка внимания {meta['attention']}x{meta['attn_dim']}/{args.attn_heads} голов"
45
+ if meta["attention"] else "")
46
+ print(f"{out}: {n / 1e6:.1f}M параметров, {os.path.getsize(out) / 2**20:.0f} МБ, "
47
+ f"{args.dtype}, norm={meta['norm']}, {meta['in_dim']} -> {meta['hidden']} -> "
48
+ f"{meta['out_dim']}, слои ТЕ {args.student_layers}, drop_first {args.drop_first}{extra}")
49
+
50
+ # самопроверка: перечитали файл — веса те же (иначе смысла в экспорте нет)
51
+ back, meta2 = load_adapter(out, device="cpu", dtype=dtype)
52
+ d = max((a - b).abs().max().item() for a, b in
53
+ zip(back.state_dict().values(), adapter.state_dict().values()))
54
+ print(f"self-check: max|diff| весов после перечитывания {d:.1e}, "
55
+ f"n_slices из метаданных {meta2['n_slices']}")
56
+ assert d < 1e-6
57
+
58
+
59
+ if __name__ == "__main__":
60
+ main()