Files
H1K0 7b68d86ce5 docs(report): add development chronicle section (§7)
Add a narrative 'development chronicle' to the report notebook, drawn from
the project's commit history: six instructive episodes (BAR-token removal,
key normalization, the shared-bias aliasing bug, --bars vs early EOS,
fail-loud on empty bars, fine-tune lr/epoch search) plus three short notes
(open style tags, corpus hygiene, the 'time' name-shadowing trap). Each
episode ends with a generalizable lesson. Renumber the conclusions section
from 7 to 8 accordingly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 21:43:25 +03:00

308 KiB
Raw Permalink Blame History

hamori — отчёт по проекту

Автор: Владислав Аршинов, группа БСМО-11-25 Дата: июнь 2026

Тема работы — генерация коротких аккордовых последовательностей (я называю их «гармоническими периодами», 4–16 тактов) в моём композиторском стиле с помощью небольшого трансформера.

Название hamori (яп. ハモリ — «вторить, петь вторым голосом») отражает идею проекта: модель не сочиняет музыку с нуля, а предлагает гармонию к уже задуманной композитором линии — добавляет к ней второй голос.

Ноутбук одновременно служит отчётом: текст, код и графики собраны в одном файле, чтобы результаты считались на месте. Для полного прогона нужны обученные чекпоинты в checkpoints/ и данные в data/.

0. Постановка задачи

Я генерирую не целую песню, а один законченный гармонический период — короткий замкнутый фрагмент гармонии на 4–16 тактов. В такте несколько позиций (их число задаёт subdivision); в позиции может стоять аккорд, точка . («держать предыдущий»), NC (без аккорда) или ? (не разобрано).

Конвейер устроен так:

  1. Переношу свои композиции из проектов REAPER в текстовый формат .chord.
  2. Паршу .chord и перевожу в последовательности токенов.
  3. Предобучаю модель на большом внешнем корпусе (McGill Billboard).
  4. Дообучаю на собственном корпусе.
  5. Сэмплирую новые периоды, задавая лад, размер, стиль, функцию и, при желании, начальные аккорды.
  6. Перевожу результат обратно в .chord и в MIDI для REAPER.

Основная гипотеза, которую я проверяю: дообучение на небольшом авторском корпусе сдвигает генерации ближе к моему стилю, не разрушая при этом общие закономерности гармонии, выученные на большом корпусе. Проверяю это тремя способами — перплексией, сравнением распределений и разбором конкретных примеров.

1. Окружение

Ячейка ниже находит корень репозитория (папку с src/), добавляет его в sys.path, фиксирует random seed для воспроизводимости и импортирует модули проекта. Основную логику — парсинг, токенизацию, расчёт перплексии, генерацию — я не дублирую в ноутбуке, а беру готовую из src/.

In [1]:
import sys, csv, random
from pathlib import Path

import numpy as np
import torch
import matplotlib.pyplot as plt
from IPython.display import Image, display

# Найти корень репозитория (каталог, содержащий src/).
ROOT = Path.cwd()
while not (ROOT / "src").exists() and ROOT != ROOT.parent:
    ROOT = ROOT.parent
sys.path.insert(0, str(ROOT))

SEED = 42
random.seed(SEED); np.random.seed(SEED); torch.manual_seed(SEED)
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")

from src.tokenizer import VOCAB, ID_TO_TOKEN, parse_chord_file, tokenize_period
from src.model import ChordTransformer
from src.dataset import ChordDataset
from src.evaluate import compute_perplexity
from src.generate import generate_period

# src.evaluate forces the headless 'Agg' backend on import (used by the CLI to
# save PNGs); re-enable the inline backend so figures render in this notebook.
%matplotlib inline

CKPT = ROOT / "checkpoints"
DATA = ROOT / "data"
OUT  = ROOT / "output" / "eval"

print(f"ROOT      = {ROOT}")
print(f"device    = {DEVICE}")
print(f"vocab     = {len(VOCAB)} токенов")
ROOT      = D:\Projects\Devel\hamori
device    = cpu
vocab     = 84 токенов

2. Данные

Формат .chord

Один файл — один период. Сверху заголовок (строки с #): title, key, time, subdivision, style, function. Дальше тело — такты через |, по subdivision позиций в каждом.

Аккорд записывается как <тоника><качество?><надстройка?>(/<бас>)?. Качеств 18, надстроек 7, обращения (через слеш, например C/E) — обязательная часть записи: для моей музыки линия баса важна, поэтому я её не отбрасываю.

Нормализация тональности

Перед токенизацией я транспонирую весь период: мажор → в C-dur, минор → в a-moll. Так модель не видит абсолютную тональность — только лад (MODE_major / MODE_minor). Это сделано намеренно: периодов всего 68, и они в разных тональностях. Если свести их к одной, все они учат одну и ту же гармоническую «грамматику», а не размазываются по 24 тональностям. После генерации результат транспонируется обратно в нужную тональность.

Токенизация

Каждый новый аккорд кодируется ровно 4 токенами: ROOT_* (тоника), QUAL_* (качество), EXT_* (надстройка), BASS_* (бас). Удержание — один токен HOLD. Границы тактов токенами не являются: при обратной сборке они восстанавливаются подсчётом позиций (time × subdivision). Плюс метатокены в начале. Итоговый словарь — 84 токена.

In [2]:
user_files   = sorted((DATA / "raw_user" / "H1K0").glob("*.chord"))
mcgill_train = sorted((DATA / "processed" / "mcgill" / "train").glob("*.pt"))
user_train   = sorted((DATA / "processed" / "user" / "train").glob("*.pt"))
user_val     = sorted((DATA / "processed" / "user" / "val").glob("*.pt"))

print(f"Корпус автора (.chord) : {len(user_files)} периодов")
print(f"  → train / val        : {len(user_train)} / {len(user_val)}")
print(f"McGill (предобучение)  : {len(mcgill_train)} периодов (train-сплит)")
Корпус автора (.chord) : 68 периодов
  → train / val        : 61 / 7
McGill (предобучение)  : 4694 периодов (train-сплит)

Ниже — один период «как есть», сырой текст файла .chord:

In [3]:
example = DATA / "raw_user" / "H1K0" / "celestial_sphere-chorus.chord"
print(example.read_text(encoding="utf-8"))
# title: Celestial Sphere
# key: Eb_major
# time: 4/4
# subdivision: 4
# style: H1K0
# function: chorus

| Eb . Bb . | Ab . . Bb | Eb . Eb/G . | Fm . Bb . | Eb . . . | Ab . Bb . | Fm . . . | Bb . . . |

Тот же период после парсинга и токенизации. Здесь видна нормализация, о которой я писал выше: в файле тональность Eb_major, но первые токены — MODE_major (период уже переведён в C-dur), а первый аккорд Eb стал ROOT_C. Абсолютная тональность исчезла, остался только лад.

In [4]:
period = parse_chord_file(example)
ids    = tokenize_period(period)
toks   = [ID_TO_TOKEN[i] for i in ids]

print(f"Тональность в файле : {period.key}")
print(f"Тактов              : {len(period.bars)}")
print(f"Токенов всего       : {len(ids)}")
print()
print("Первые 14 токенов (метаданные + первый аккорд):")
print("  " + " ".join(toks[:14]))
Тональность в файле : Eb_major
Тактов              : 8
Токенов всего       : 78

Первые 14 токенов (метаданные + первый аккорд):
  <BOS> MODE_major TIME_4/4 SUB_4 STYLE_H1K0 FUNC_chorus ROOT_C QUAL_maj EXT_none BASS_root HOLD ROOT_G QUAL_maj EXT_none

3. Модель

Я использую небольшой авторегрессионный трансформер — по сути ту же архитектуру, что и в языковых моделях, только вместо слов здесь аккордовые токены.

Принцип работы

Модель учится предсказывать следующий токен по всем предыдущим: она получает начало периода и оценивает, какой токен идёт дальше; ошибка считается кросс-энтропией.

Подробнее о том, что происходит внутри:

  • Эмбеддинги. Каждый из 84 токенов превращается в вектор длиной d_model. К нему добавляется позиционная информация — без неё модель не различала бы порядок токенов.
  • Self-attention. Основной механизм трансформера. На каждой позиции модель «смотрит» на остальные позиции последовательности и определяет, какие из них сейчас значимы. Например, выдавая очередной аккорд, она может учесть тонику в начале периода и предыдущий аккорд.
  • Причинная (causal) маска. Чтобы при обучении модель не использовала будущие токены, внимание ограничено «назад»: токен на позиции i видит только позиции до i включительно. Без этого задача предсказания следующего токена была бы тривиальной — ответ виден заранее.
  • FFN и слои. После внимания идёт полносвязный блок, и так несколько раз (2–4 слоя). Каждый слой позволяет уловить более сложные зависимости.
  • Выход. На последнем слое модель выдаёт распределение вероятностей по всем 84 токенам — насколько вероятен каждый следующим. При генерации я сэмплирую из этого распределения (раздел 6).

Входные и выходные эмбеддинги связаны (tied embeddings): это экономит параметры и обычно немного помогает на небольших данных.

Конкретные размеры (число слоёв, d_model, число голов) хранятся в чекпоинте — выведу их и посчитаю число параметров. Модель небольшая и обучается даже на CPU.

In [5]:
def load_model(path: Path):
    ckpt  = torch.load(path, map_location=DEVICE, weights_only=True)
    model = ChordTransformer(**ckpt["model_config"])
    model.load_state_dict(ckpt["model_state"])
    model.to(DEVICE).eval()
    return model, ckpt["model_config"]

pretrained, cfg = load_model(CKPT / "pretrained.pt")
finetuned,  _   = load_model(CKPT / "finetuned.pt")

n_params = sum(p.numel() for p in finetuned.parameters())
print("Конфигурация модели:")
for k, v in cfg.items():
    print(f"  {k:18s} = {v}")
print(f"\nПараметров (с привязанными эмбеддингами): {n_params:,}")
Конфигурация модели:
  vocab_size         = 84
  d_model            = 192
  n_layers           = 3
  n_heads            = 6
  d_ff               = 768
  max_seq_len        = 320
  dropout            = 0.1

Параметров (с привязанными эмбеддингами): 1,412,544

4. Обучение

Обучение двухэтапное:

  1. Предобучение на McGill Billboard. На большом внешнем корпусе модель усваивает общие закономерности: как движется гармония, какие бывают надстройки, насколько часто встречаются обращения. Мой стиль здесь ни при чём — задача в том, чтобы модель в принципе «понимала» гармонию.
  2. Дообучение на собственном корпусе. Здесь я ставлю заметно меньший learning rate (порядка 3·10⁻⁵ против 3·10⁻⁴ на предобучении) и небольшое число эпох. Цель — сместить модель в сторону моего стиля, но не дать ей забыть выученное на большом корпусе (катастрофическое забывание).

Кривые потерь беру из CSV-логов, сохранённых тренировочным скриптом.

In [6]:
def load_log(path: Path):
    e, tr, vl, pp = [], [], [], []
    with open(path, encoding="utf-8") as fh:
        for row in csv.DictReader(fh):
            e.append(int(row["epoch"]))
            tr.append(float(row["train_loss"]))
            vl.append(float(row["val_loss"]))
            pp.append(float(row["val_ppl"]))
    return e, tr, vl, pp

fig, axes = plt.subplots(1, 2, figsize=(13, 4.5))
for ax, name, title in [
    (axes[0], "pretrained", "Предобучение (McGill)"),
    (axes[1], "finetuned",  "Дообучение (корпус автора)"),
]:
    e, tr, vl, pp = load_log(CKPT / f"{name}.log.csv")
    ax.plot(e, tr, label="train loss")
    ax.plot(e, vl, label="val loss")
    ax.set_xlabel("эпоха"); ax.set_ylabel("loss")
    ax.set_title(f"{title} · лучшая val-PPL = {min(pp):.2f}")
    ax.grid(alpha=0.3); ax.legend()
fig.tight_layout(); plt.show()
No description has been provided for this image

Предобучение сошлось к val-перплексии около 1.32 за 50 эпох — McGill большой и довольно регулярный корпус, поэтому перплексия низкая. Дообучение вышло на плато около 2.15 на моей валидации (лучшая эпоха — 20-я). Более высокая перплексия после дообучения ожидаема: мой корпус меньше и стилистически своеобразнее, предсказывать его сложнее.

5. Оценка

Гипотезу я проверяю с трёх сторон:

  1. Перплексия — насколько хорошо модель предсказывает мои отложенные периоды.
  2. Распределения — гистограммы по качествам аккордов, надстройкам, обращениям и интервалам между тониками; по ним видно, в какую сторону дообучение двигает генерации.
  3. Примеры — одно и то же условие, генерация до и после дообучения, сравнение вручную.

О слабом месте эксперимента. Папку data/holdout/ в этой версии я не заполнил, поэтому перплексия считается на валидационном сплите (7 периодов), а не на отдельном тестовом наборе. Валидация косвенно участвовала в выборе эпохи, так что числа стоит читать как направление и порядок величины, а не как строгую несмещённую оценку. При наличии времени я заполню holdout и пересчитаю.

5.1 Перплексия

Считаю вживую, на своей валидации, сразу для обеих моделей.

In [7]:
loader = torch.utils.data.DataLoader(
    ChordDataset(DATA / "processed" / "user" / "val",
                 max_length=pretrained.max_seq_len),
    batch_size=8, shuffle=False, num_workers=0,
)

ppl_pre = compute_perplexity(pretrained, loader, DEVICE)
ppl_ft  = compute_perplexity(finetuned,  loader, DEVICE)
improve = (ppl_pre - ppl_ft) / ppl_pre * 100

print(f"Перплексия на валидации автора ({len(user_val)} периодов):")
print(f"  pretrained = {ppl_pre:.2f}")
print(f"  finetuned  = {ppl_ft:.2f}")
print(f"  улучшение  = {improve:+.1f}%")

fig, ax = plt.subplots(figsize=(4.6, 4))
bars = ax.bar(["pretrained", "finetuned"], [ppl_pre, ppl_ft],
              color=["#999999", "#3b7dd8"])
for rect, v in zip(bars, [ppl_pre, ppl_ft]):
    ax.text(rect.get_x() + rect.get_width() / 2, v + 0.03,
            f"{v:.2f}", ha="center", va="bottom")
ax.set_ylabel("перплексия (ниже — лучше)")
ax.set_title("Перплексия: pretrained vs finetuned")
ax.grid(axis="y", alpha=0.3)
plt.show()
Перплексия на валидации автора (7 периодов):
  pretrained = 3.58
  finetuned  = 2.15
  улучшение  = +39.8%
No description has been provided for this image

5.2 Распределения

Картинку строит скрипт scripts/evaluate.py: он сравнивает четыре группы — McGill (предобучение), мой корпус (цель), выход модели до дообучения и после. Панели:

  • Chord quality — распределение качеств аккордов;
  • Root motion intervals — интервал между тониками соседних аккордов (0–11 полутонов);
  • Extension presence — доля аккордов с надстройками;
  • Inversion frequency — доля обращений (бас не тоника).

Ниже — готовая картинка output/eval/distributions.png. Команда для пересборки:

python scripts/evaluate.py --pretrained checkpoints/pretrained.pt \
    --finetuned checkpoints/finetuned.pt

Ожидаемый результат: группа finetuned ближе к «моему корпусу», чем pretrained — прежде всего по обращениям и надстройкам, которые я использую часто.

In [8]:
dist_png = OUT / "distributions.png"
if dist_png.exists():
    display(Image(str(dist_png)))
else:
    print("Файл не найден. Сгенерируйте его командой:")
    print("  python scripts/evaluate.py --pretrained checkpoints/pretrained.pt "
          "--finetuned checkpoints/finetuned.pt")
No description has been provided for this image

Как читать график. Цвета: синий — McGill (корпус предобучения), оранжевый — мой корпус (цель), зелёный — выход pretrained, красный — выход finetuned. Чем ближе красный к оранжевому, тем лучше дообучение поймало мой стиль.

Что видно по факту:

  • Движение тоник (панель 2) — главный эффект. До дообучения pretrained (зелёный) почти в половине случаев топчется на той же тонике (P1 ≈ 43 %); после дообучения finetuned (красный) спускается до ≈ 18 %, почти как мой корпус (≈ 14 %), и набирает шаги на тон (M2). Гармония стала заметно подвижнее.
  • Качества аккордов (панель 1) — finetuned срезал доминантсептаккорды (7) почти до моего уровня, но перекосил в сторону «голого» maj.
  • Обращения и надстройки (панели 3–4) — здесь моё ожидание не подтвердилось: finetuned их, наоборот, недодаёт (обращений ≈ 3 % против моих ≈ 9 %). Скорее всего, корпуса слишком мало, чтобы модель уверенно переняла мою привычку к slash-аккордам.

5.3 Качественные примеры

Беру одно и то же условие (тональность, размер, стиль, функция, seed) и генерирую двумя моделями. Эталон — мой собственный период, на метаданные которого я опираюсь.

In [9]:
def one_line(path: Path) -> str:
    p = parse_chord_file(path)
    return "| " + " | ".join(" ".join(bar) for bar in p.bars) + " |"

ref = DATA / "raw_user" / "H1K0" / "celestial_sphere-chorus.chord"
pre = OUT / "examples" / "pretrained_01_celestial_sphere-chorus.chord"
ftn = OUT / "examples" / "finetuned_01_celestial_sphere-chorus.chord"

print("Эталон (автор):")
print("  ", one_line(ref))
print("\npretrained:")
print("  ", one_line(pre))
print("\nfinetuned:")
print("  ", one_line(ftn))
Эталон (автор):
   | Eb . Bb . | Ab . . Bb | Eb . Eb/G . | Fm . Bb . | Eb . . . | Ab . Bb . | Fm . . . | Bb . . . |

pretrained:
   | D#maj . D#maj . | G#maj/D# . A#maj . | D#maj . D#maj/G . | G#maj/D# . A#maj . |

finetuned:
   | D#maj . G#maj . | A#maj . D#maj/G . | G#maj . G#maj . | A#maj . D#maj . |

Что видно на этом примере (celestial_sphere, припев, Eb-dur):

  • Обе модели держатся в тональности — правда, генерация заякорена на тонику, так что это отчасти заслуга условия, а не модели.
  • Энгармония. Модель выдаёт диезы (D#maj, G#maj, A#maj), а я тот же звук записал бемолями (Eb, Ab, Bb). Это не ошибка: в словаре все 12 ступеней хранятся только в диезной записи (ROOT_* — sharps only), и при обратной сборке выбирается диезное имя. Звучит идентично; при желании имена можно перевести в бемоли отдельной пост-обработкой.
  • finetuned движется заметнее и ближе к моему стилю (тоника → субдоминанта → доминанта → тоника), тогда как pretrained чаще повторяет тонику. Это всего один пример, поэтому вывод осторожный.

6. Генерация

Сэмплирую через nucleus / top-p (≈ 0.9) с температурой около 1.0. Beam search не использую — на генеративных задачах он обычно ухудшает результат, делая его однообразным. Дополнительные параметры:

  • tonic anchor — по умолчанию ставлю в начало тонику, чтобы генерация не уходила из тональности;
  • repetition penalty — биграммный штраф за повтор переходов между тониками (по умолчанию выключен, разумный диапазон 0.5–1.5);
  • n_bars — позволяет зафиксировать точное число тактов.

Запустить генерацию можно тремя способами: из командной строки (scripts/generate.py), через веб-форму (app.py, небольшой Gradio) или напрямую вызовом generate_period(...). Ниже — последний вариант.

In [10]:
period = generate_period(
    model=finetuned,
    mode="major", key="C_major",
    time="4/4", subdivision=4,
    style="H1K0", function="chorus",
    prefix=["C"],                 # якорь на тонику
    temperature=1.0, top_p=0.9,
    repetition_penalty=0.8,       # сбиваю залипание на I–V
    n_bars=8, seed=4,
)
print(f"Сгенерировано ({len(period.bars)} тактов, тональность {period.key}):")
print("| " + " | ".join(" ".join(bar) for bar in period.bars) + " |")
Сгенерировано (8 тактов, тональность C_major):
| Cmaj . . . | Cmaj . . . | Cmaj . . . | Em7 . . . | Dm7 . . . | Em7 . . . | Fmaj . Gmaj . | Cmaj . Gmaj . |

7. Хроника разработки: на чём я спотыкался

Готовый конвейер выглядит аккуратно, но дорога к нему была неровной. Самые поучительные моменты я собрал ниже — не ради полноты, а потому что каждый из них чему-то научил. Все эпизоды восстановлены по истории коммитов, так что это не воспоминания, а задокументированные факты.

7.1 Лишний токен на границе такта

В первой версии формата граница такта (|) была отдельным токеном BAR в словаре. Логично же: раз в тексте есть разделитель — пусть будет и в последовательности. Но довольно быстро выяснилось, что это плохая идея. Граница такта полностью определяется арифметикой: на такт приходится ровно time × subdivision позиций, и где она стоит, можно вычислить, ничего не предсказывая. А раз так, то токен BAR не несёт информации — зато даёт модели лишнюю возможность ошибиться: поставить границу не туда и выдать кривой такт.

Я убрал BAR из словаря (стало 84 токена вместо 85), а детокенизатор теперь расставляет границы сам, подсчётом позиций; генератор же разрешает EOS только на границе такта. Урок простой и общий: не заставляйте модель учить то, что и так гарантировано детерминированным правилом — пусть тратит ёмкость на осмысленные предсказания. Заодно это потребовало поднять версию спецификации формата до v2.3: формат — контракт между данными и моделью, и менять его молча нельзя.

7.2 Двадцать четыре тональности на шестьдесят восемь периодов

Корпус у меня крошечный — 68 периодов, и записаны они в самых разных тональностях. Если оставить как есть, модель увидела бы один и тот же оборот I–IV–V в C-dur и в Es-dur как две совершенно несвязанные цепочки токенов — и так по всем двенадцати мажорам и минорам. Данные, и без того скудные, размазались бы ещё в двадцать четыре раза.

Поэтому нормализация тональности (подробно описана в разделе 2) заложена в формат с самого начала: всё сводится в C-dur / a-moll, лад остаётся в виде MODE_major / MODE_minor, а после генерации результат транспонируется обратно. Это не оптимизация и не тонкая настройка — это решение, которое на таком объёме данных дало больше, чем любые правки самой модели. Урок: когда данных мало, выгоднее убрать мешающее измерение, чем усложнять модель.

7.3 Качество, утекающее между генерациями

Самая поучительная история — и самая коварная. Через веб-форму при повторных генерациях качество выхода заметно падало: модель всё чаще выдавала точки и NC, и в какой-то момент при любых параметрах не выдавала ничего, кроме них. Перезапуск сервера помогал лишь отчасти.

Сначала это сбивало с толку. Веса модели лежат на диске и не меняются; параметры сэмплирования на каждый запрос свои. Что вообще могло «накапливаться» между независимыми вызовами? Ключом оказалась как раз формулировка симптома: ухудшается от вызова к вызову, сбрасывается при перезапуске — это почерк испорченного состояния в памяти процесса, живущего дольше одного запроса.

Виновником были тензоры грамматических смещений — те самые маски, которые запрещают модели выдавать недопустимые в данной позиции токены. Они создаются один раз при импорте как общие объекты-синглтоны. А цикл генерации правил их на месте: и блокировку EOS, и биграммный штраф за повтор он применял прямо к возвращённой ссылке. В результате штраф накапливался по позициям внутри одного вызова (выход схлопывался в удержания и NC) и протекал в следующие вызовы, пока процесс не перезапустят. Один и тот же корень у обоих симптомов — типичный для Python алиасинг общего изменяемого состояния.

Коварство в том, что фиксированный seed маскировал проблему: отдельный прогон оставался воспроизводимым, и в тестах всё выглядело нормально. Баг вылезал только под реальным повторным использованием. Починка свелась к одной строке — копировать смещение (.clone()) на каждом шаге, прежде чем что-то в нём менять. Но прежде фикса я, по правилу проекта, написал сначала падающий тест: он проверяет, что синглтоны после генерации не изменились и что один и тот же seed даёт один и тот же результат независимо от того, сколько генераций было до. На баговом коде он падал, после фикса — зелёный.

7.4 Модель, которая хотела закончить слишком рано

После дообучения на коротких периодах модель усвоила не только мой стиль, но и привычку быстро ставить точку: просишь восемь тактов — а она норовит закруглиться на четвёртом, уверенно выдавая EOS. Для генеративной модели это нормально, но пользователю нужна заданная длина.

Я добавил параметр --bars, а затем научил его подавлять EOS (занулять вероятность этого токена маской логитов) до тех пор, пока не набрано нужное число тактов. Урок: стоит развести две разные вещи — «модель хочет остановиться» и «пользователь задал длину N». Управляемость достигается маскированием логитов поверх вероятностной генерации, а не переучиванием модели.

7.5 Лучше упасть, чем тихо испортить

При обратной сборке вырожденная цепочка токенов иногда могла дать пустой такт. Заманчиво было просто молча его выкинуть — и идти дальше. Но я сделал наоборот: детокенизатор бросает ChordFormatError с указанием места. Это прямо следует из принципа проекта — никогда молча не «подправлять» неразобранное.

Урок касается именно обучающих данных: молчаливая порча — худший режим отказа. Тихо проглоченная ошибка всплывёт через недели в виде необъяснимо плохих генераций, и искать её придётся с нуля; громкое падение с координатами (файл, такт, позиция) экономит эти недели.

7.6 Подбор режима дообучения

Дообучение пришлось настраивать на ощупь, между двумя крайностями. Слишком большой learning rate или слишком много эпох — и модель забывает гармонические закономерности, выученные на McGill (катастрофическое забывание). Слишком осторожно — и стиль не сдвигается вовсе. Первый прогон был аккуратным (lr = 1·10⁻⁵, 50 эпох), затем я сравнил его с более бодрым (lr = 3·10⁻⁵, 30 эпох) и сделал второй вариант режимом по умолчанию. Итог этого подбора виден в разделе 5: перплексия на моей валидации опустилась с 3.58 до 2.15. Урок неоригинален, но выстрадан: на маленьком целевом корпусе ручки дообучения чувствительны — малый шаг и немного эпох.

7.7 Мелочи, тоже оставившие след

Три эпизода поменьше, каждый — на полстроки морали.

  • Открытые теги стиля. Сначала стиль был зашит одним фиксированным токеном STYLE_user. Я заменил его открытой системой тегов (мой корпус — H1K0), чтобы будущие стили (например, J-Pop) можно было добавить, не трогая словарь. Урок: оставляй точку расширения там, где заранее знаешь, что данных прибавится.
  • Гигиена корпуса. Отдельный коммит ушёл на нормализацию переводов строк (CRLF) и исправление ошибок транскрипции. На 68 периодах каждая опечатка весит ощутимо: «мусор на входе — мусор на выходе» здесь не метафора.
  • Ловушка с именем time. Параметр генерации, задающий размер, называется time — и он затеняет стандартный модуль time. Поэтому в app.py стоит from time import perf_counter, а не import time: иначе обращение к time.perf_counter() упало бы на строковом значении размера. Мелкая, но показательная причина предпочесть точечный импорт.

8. Выводы и ограничения

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

  • Конвейер работает целиком: .chord → токены → обучение → генерация → обратно .chord и MIDI.
  • Дообучение снизило перплексию на валидации с 3.58 до 2.15 (−39.8 %): модель заметно лучше предсказывает мои периоды.
  • Качественно генерации после дообучения ближе к моему стилю.

Ограничения.

  • Корпус небольшой (68 периодов), поэтому оценки имеют высокую дисперсию.
  • data/holdout/ не заполнен — перплексия измерена на валидации, а не на честном тесте.
  • Модель сводит энгармонию к диезам — это касается записи, а не звучания.
  • Обучен по сути один стиль (H1K0); остальные теги стиля есть в словаре, но на них модель не обучалась.

Дальнейшая работа (за рамками срока).

  • Заполнить holdout и пересчитать перплексию на честном тесте.
  • Попробовать дообучение на J-Pop корпусе.
  • Добавить пост-обработку имён аккордов в бемоли для удобства чтения.