diff --git a/notebooks/report.ipynb b/notebooks/report.ipynb index 9a8a2b2..2e2f3ee 100644 --- a/notebooks/report.ipynb +++ b/notebooks/report.ipynb @@ -776,36 +776,17 @@ "print(\"| \" + \" | \".join(\" \".join(bar) for bar in period.bars) + \" |\")" ] }, + { + "cell_type": "markdown", + "id": "12323a8c", + "source": "## 7. Хроника разработки: на чём я спотыкался\n\nГотовый конвейер выглядит аккуратно, но дорога к нему была неровной. Самые\nпоучительные моменты я собрал ниже — не ради полноты, а потому что каждый из них\nчему-то научил. Все эпизоды восстановлены по истории коммитов, так что это не\nвоспоминания, а задокументированные факты.\n\n### 7.1 Лишний токен на границе такта\n\nВ первой версии формата граница такта (`|`) была отдельным токеном `BAR` в\nсловаре. Логично же: раз в тексте есть разделитель — пусть будет и в\nпоследовательности. Но довольно быстро выяснилось, что это плохая идея. Граница\nтакта полностью определяется арифметикой: на такт приходится ровно\n`time × subdivision` позиций, и где она стоит, можно вычислить, ничего не\nпредсказывая. А раз так, то токен `BAR` не несёт информации — зато даёт модели\nлишнюю возможность ошибиться: поставить границу не туда и выдать кривой такт.\n\nЯ убрал `BAR` из словаря (стало 84 токена вместо 85), а детокенизатор теперь\nрасставляет границы сам, подсчётом позиций; генератор же разрешает `EOS` только\nна границе такта. Урок простой и общий: **не заставляйте модель учить то, что и\nтак гарантировано детерминированным правилом** — пусть тратит ёмкость на\nосмысленные предсказания. Заодно это потребовало поднять версию спецификации\nформата до v2.3: формат — контракт между данными и моделью, и менять его молча\nнельзя.\n\n### 7.2 Двадцать четыре тональности на шестьдесят восемь периодов\n\nКорпус у меня крошечный — 68 периодов, и записаны они в самых разных\nтональностях. Если оставить как есть, модель увидела бы один и тот же оборот\nI–IV–V в C-dur и в Es-dur как две совершенно несвязанные цепочки токенов — и так\nпо всем двенадцати мажорам и минорам. Данные, и без того скудные, размазались бы\nещё в двадцать четыре раза.\n\nПоэтому нормализация тональности (подробно описана в разделе 2) заложена в формат\nс самого начала: всё сводится в C-dur / a-moll, лад остаётся в виде\n`MODE_major` / `MODE_minor`, а после генерации результат транспонируется обратно.\nЭто не оптимизация и не тонкая настройка — это решение, которое на таком объёме\nданных дало больше, чем любые правки самой модели. Урок: **когда данных мало,\nвыгоднее убрать мешающее измерение, чем усложнять модель.**\n\n### 7.3 Качество, утекающее между генерациями\n\nСамая поучительная история — и самая коварная. Через веб-форму при повторных\nгенерациях качество выхода заметно падало: модель всё чаще выдавала точки и `NC`,\nи в какой-то момент при любых параметрах не выдавала ничего, кроме них.\nПерезапуск сервера помогал лишь отчасти.\n\nСначала это сбивало с толку. Веса модели лежат на диске и не меняются; параметры\nсэмплирования на каждый запрос свои. Что вообще могло «накапливаться» между\nнезависимыми вызовами? Ключом оказалась как раз формулировка симптома:\n**ухудшается от вызова к вызову, сбрасывается при перезапуске** — это почерк\nиспорченного состояния в памяти процесса, живущего дольше одного запроса.\n\nВиновником были тензоры грамматических смещений — те самые маски, которые\nзапрещают модели выдавать недопустимые в данной позиции токены. Они создаются\nодин раз при импорте как общие объекты-синглтоны. А цикл генерации правил их\n**на месте**: и блокировку `EOS`, и биграммный штраф за повтор он применял прямо\nк возвращённой ссылке. В результате штраф накапливался по позициям внутри одного\nвызова (выход схлопывался в удержания и `NC`) и протекал в следующие вызовы,\nпока процесс не перезапустят. Один и тот же корень у обоих симптомов — типичный\nдля Python **алиасинг общего изменяемого состояния**.\n\nКоварство в том, что фиксированный seed маскировал проблему: отдельный прогон\nоставался воспроизводимым, и в тестах всё выглядело нормально. Баг вылезал только\nпод реальным повторным использованием. Починка свелась к одной строке — копировать\nсмещение (`.clone()`) на каждом шаге, прежде чем что-то в нём менять. Но прежде\nфикса я, по правилу проекта, написал **сначала падающий тест**: он проверяет, что\nсинглтоны после генерации не изменились и что один и тот же seed даёт один и тот\nже результат независимо от того, сколько генераций было до. На баговом коде он\nпадал, после фикса — зелёный.\n\n### 7.4 Модель, которая хотела закончить слишком рано\n\nПосле дообучения на коротких периодах модель усвоила не только мой стиль, но и\nпривычку быстро ставить точку: просишь восемь тактов — а она норовит закруглиться\nна четвёртом, уверенно выдавая `EOS`. Для генеративной модели это нормально, но\nпользователю нужна заданная длина.\n\nЯ добавил параметр `--bars`, а затем научил его **подавлять `EOS`** (занулять\nвероятность этого токена маской логитов) до тех пор, пока не набрано нужное число\nтактов. Урок: стоит развести две разные вещи — «модель хочет остановиться» и\n«пользователь задал длину N». Управляемость достигается маскированием логитов\nповерх вероятностной генерации, а не переучиванием модели.\n\n### 7.5 Лучше упасть, чем тихо испортить\n\nПри обратной сборке вырожденная цепочка токенов иногда могла дать пустой такт.\nЗаманчиво было просто молча его выкинуть — и идти дальше. Но я сделал наоборот:\nдетокенизатор бросает `ChordFormatError` с указанием места. Это прямо следует из\nпринципа проекта — никогда молча не «подправлять» неразобранное.\n\nУрок касается именно обучающих данных: **молчаливая порча — худший режим\nотказа.** Тихо проглоченная ошибка всплывёт через недели в виде необъяснимо\nплохих генераций, и искать её придётся с нуля; громкое падение с координатами\n(файл, такт, позиция) экономит эти недели.\n\n### 7.6 Подбор режима дообучения\n\nДообучение пришлось настраивать на ощупь, между двумя крайностями. Слишком\nбольшой learning rate или слишком много эпох — и модель забывает гармонические\nзакономерности, выученные на McGill (катастрофическое забывание). Слишком\nосторожно — и стиль не сдвигается вовсе. Первый прогон был аккуратным\n(lr = 1·10⁻⁵, 50 эпох), затем я сравнил его с более бодрым (lr = 3·10⁻⁵, 30 эпох)\nи сделал второй вариант режимом по умолчанию. Итог этого подбора виден в\nразделе 5: перплексия на моей валидации опустилась с 3.58 до 2.15. Урок\nнеоригинален, но выстрадан: **на маленьком целевом корпусе ручки дообучения\nчувствительны** — малый шаг и немного эпох.\n\n### 7.7 Мелочи, тоже оставившие след\n\nТри эпизода поменьше, каждый — на полстроки морали.\n\n- **Открытые теги стиля.** Сначала стиль был зашит одним фиксированным токеном\n `STYLE_user`. Я заменил его открытой системой тегов (мой корпус — `H1K0`), чтобы\n будущие стили (например, J-Pop) можно было добавить, не трогая словарь. Урок:\n оставляй точку расширения там, где заранее знаешь, что данных прибавится.\n- **Гигиена корпуса.** Отдельный коммит ушёл на нормализацию переводов строк\n (CRLF) и исправление ошибок транскрипции. На 68 периодах каждая опечатка весит\n ощутимо: «мусор на входе — мусор на выходе» здесь не метафора.\n- **Ловушка с именем `time`.** Параметр генерации, задающий размер, называется\n `time` — и он затеняет стандартный модуль `time`. Поэтому в `app.py` стоит\n `from time import perf_counter`, а не `import time`: иначе обращение к\n `time.perf_counter()` упало бы на строковом значении размера. Мелкая, но\n показательная причина предпочесть точечный импорт.\n", + "metadata": {} + }, { "cell_type": "markdown", "id": "59c76c8c", "metadata": {}, - "source": [ - "## 7. Выводы и ограничения\n", - "\n", - "**Что получилось.**\n", - "\n", - "- Конвейер работает целиком: `.chord` → токены → обучение → генерация → обратно\n", - " `.chord` и MIDI.\n", - "- Дообучение снизило перплексию на валидации с **3.58** до **2.15** (−39.8 %):\n", - " модель заметно лучше предсказывает мои периоды.\n", - "- Качественно генерации после дообучения ближе к моему стилю.\n", - "\n", - "**Ограничения.**\n", - "\n", - "- Корпус небольшой (68 периодов), поэтому оценки имеют высокую дисперсию.\n", - "- `data/holdout/` не заполнен — перплексия измерена на валидации, а не на честном\n", - " тесте.\n", - "- Модель сводит энгармонию к диезам — это касается записи, а не звучания.\n", - "- Обучен по сути один стиль (`H1K0`); остальные теги стиля есть в словаре, но на\n", - " них модель не обучалась.\n", - "\n", - "**Дальнейшая работа** (за рамками срока).\n", - "\n", - "- Заполнить holdout и пересчитать перплексию на честном тесте.\n", - "- Попробовать дообучение на J-Pop корпусе.\n", - "- Добавить пост-обработку имён аккордов в бемоли для удобства чтения." - ] + "source": "## 8. Выводы и ограничения\n\n**Что получилось.**\n\n- Конвейер работает целиком: `.chord` → токены → обучение → генерация → обратно\n `.chord` и MIDI.\n- Дообучение снизило перплексию на валидации с **3.58** до **2.15** (−39.8 %):\n модель заметно лучше предсказывает мои периоды.\n- Качественно генерации после дообучения ближе к моему стилю.\n\n**Ограничения.**\n\n- Корпус небольшой (68 периодов), поэтому оценки имеют высокую дисперсию.\n- `data/holdout/` не заполнен — перплексия измерена на валидации, а не на честном\n тесте.\n- Модель сводит энгармонию к диезам — это касается записи, а не звучания.\n- Обучен по сути один стиль (`H1K0`); остальные теги стиля есть в словаре, но на\n них модель не обучалась.\n\n**Дальнейшая работа** (за рамками срока).\n\n- Заполнить holdout и пересчитать перплексию на честном тесте.\n- Попробовать дообучение на J-Pop корпусе.\n- Добавить пост-обработку имён аккордов в бемоли для удобства чтения." } ], "metadata": { @@ -829,4 +810,4 @@ }, "nbformat": 4, "nbformat_minor": 5 -} +} \ No newline at end of file