Runnable end-to-end report combining narrative, code, and inline figures: data and .chord format, transformer working principle, two-stage training curves, perplexity (3.58 -> 2.15), distribution-shift plot with a reading legend, qualitative examples, and a generation demo. Written in a first-person student voice. - CLAUDE.md: report is now a Jupyter notebook; GOST formatting dropped - requirements.txt: add nbconvert + ipykernel (optional, for the notebook) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
295 KiB
hamori — отчёт по проекту¶
Автор: Владислав Аршинов, группа БСМО-11-25 Дата: июнь 2026
Тема работы — генерация коротких аккордовых последовательностей (я называю их «гармоническими периодами», 4–16 тактов) в моём композиторском стиле с помощью небольшого трансформера.
Название hamori (яп. ハモリ — «вторить, петь вторым голосом») отражает идею проекта: модель не сочиняет музыку с нуля, а предлагает гармонию к уже задуманной композитором линии — добавляет к ней второй голос.
Ноутбук одновременно служит отчётом: текст, код и графики собраны в одном файле,
чтобы результаты считались на месте. Для полного прогона нужны обученные
чекпоинты в checkpoints/ и данные в data/.
0. Постановка задачи¶
Я генерирую не целую песню, а один законченный гармонический период — короткий
замкнутый фрагмент гармонии на 4–16 тактов. В такте несколько позиций (их число
задаёт subdivision); в позиции может стоять аккорд, точка . («держать
предыдущий»), NC (без аккорда) или ? (не разобрано).
Конвейер устроен так:
- Переношу свои композиции из проектов REAPER в текстовый формат
.chord. - Паршу
.chordи перевожу в последовательности токенов. - Предобучаю модель на большом внешнем корпусе (McGill Billboard).
- Дообучаю на собственном корпусе.
- Сэмплирую новые периоды, задавая лад, размер, стиль, функцию и, при желании, начальные аккорды.
- Перевожу результат обратно в
.chordи в MIDI для REAPER.
Основная гипотеза, которую я проверяю: дообучение на небольшом авторском корпусе сдвигает генерации ближе к моему стилю, не разрушая при этом общие закономерности гармонии, выученные на большом корпусе. Проверяю это тремя способами — перплексией, сравнением распределений и разбором конкретных примеров.
1. Окружение¶
Ячейка ниже находит корень репозитория (папку с src/), добавляет его в
sys.path, фиксирует random seed для воспроизводимости и импортирует модули
проекта. Основную логику — парсинг, токенизацию, расчёт перплексии, генерацию —
я не дублирую в ноутбуке, а беру готовую из src/.
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)} токенов")
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 токена.
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:
example = DATA / "raw_user" / "H1K0" / "celestial_sphere-chorus.chord"
print(example.read_text(encoding="utf-8"))
Тот же период после парсинга и токенизации. Здесь видна нормализация, о
которой я писал выше: в файле тональность Eb_major, но первые токены —
MODE_major (период уже переведён в C-dur), а первый аккорд Eb стал ROOT_C.
Абсолютная тональность исчезла, остался только лад.
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]))
3. Модель¶
Я использую небольшой авторегрессионный трансформер — по сути ту же архитектуру, что и в языковых моделях, только вместо слов здесь аккордовые токены.
Принцип работы¶
Модель учится предсказывать следующий токен по всем предыдущим: она получает начало периода и оценивает, какой токен идёт дальше; ошибка считается кросс-энтропией.
Подробнее о том, что происходит внутри:
- Эмбеддинги. Каждый из 84 токенов превращается в вектор длиной
d_model. К нему добавляется позиционная информация — без неё модель не различала бы порядок токенов. - Self-attention. Основной механизм трансформера. На каждой позиции модель «смотрит» на остальные позиции последовательности и определяет, какие из них сейчас значимы. Например, выдавая очередной аккорд, она может учесть тонику в начале периода и предыдущий аккорд.
- Причинная (causal) маска. Чтобы при обучении модель не использовала будущие токены, внимание ограничено «назад»: токен на позиции i видит только позиции до i включительно. Без этого задача предсказания следующего токена была бы тривиальной — ответ виден заранее.
- FFN и слои. После внимания идёт полносвязный блок, и так несколько раз (2–4 слоя). Каждый слой позволяет уловить более сложные зависимости.
- Выход. На последнем слое модель выдаёт распределение вероятностей по всем 84 токенам — насколько вероятен каждый следующим. При генерации я сэмплирую из этого распределения (раздел 6).
Входные и выходные эмбеддинги связаны (tied embeddings): это экономит параметры и обычно немного помогает на небольших данных.
Конкретные размеры (число слоёв, d_model, число голов) хранятся в чекпоинте —
выведу их и посчитаю число параметров. Модель небольшая и обучается даже на
CPU.
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:,}")
4. Обучение¶
Обучение двухэтапное:
- Предобучение на McGill Billboard. На большом внешнем корпусе модель усваивает общие закономерности: как движется гармония, какие бывают надстройки, насколько часто встречаются обращения. Мой стиль здесь ни при чём — задача в том, чтобы модель в принципе «понимала» гармонию.
- Дообучение на собственном корпусе. Здесь я ставлю заметно меньший learning rate (порядка 3·10⁻⁵ против 3·10⁻⁴ на предобучении) и небольшое число эпох. Цель — сместить модель в сторону моего стиля, но не дать ей забыть выученное на большом корпусе (катастрофическое забывание).
Кривые потерь беру из CSV-логов, сохранённых тренировочным скриптом.
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()
Предобучение сошлось к val-перплексии около 1.32 за 50 эпох — McGill большой и довольно регулярный корпус, поэтому перплексия низкая. Дообучение вышло на плато около 2.15 на моей валидации (лучшая эпоха — 20-я). Более высокая перплексия после дообучения ожидаема: мой корпус меньше и стилистически своеобразнее, предсказывать его сложнее.
5. Оценка¶
Гипотезу я проверяю с трёх сторон:
- Перплексия — насколько хорошо модель предсказывает мои отложенные периоды.
- Распределения — гистограммы по качествам аккордов, надстройкам, обращениям и интервалам между тониками; по ним видно, в какую сторону дообучение двигает генерации.
- Примеры — одно и то же условие, генерация до и после дообучения, сравнение вручную.
О слабом месте эксперимента. Папку
data/holdout/в этой версии я не заполнил, поэтому перплексия считается на валидационном сплите (7 периодов), а не на отдельном тестовом наборе. Валидация косвенно участвовала в выборе эпохи, так что числа стоит читать как направление и порядок величины, а не как строгую несмещённую оценку. При наличии времени я заполню holdout и пересчитаю.
5.1 Перплексия¶
Считаю вживую, на своей валидации, сразу для обеих моделей.
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()
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 — прежде всего по обращениям и надстройкам, которые я использую часто.
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")
Как читать график. Цвета: синий — McGill (корпус предобучения), оранжевый — мой корпус (цель), зелёный — выход pretrained, красный — выход finetuned. Чем ближе красный к оранжевому, тем лучше дообучение поймало мой стиль.
Что видно по факту:
- Движение тоник (панель 2) — главный эффект. До дообучения pretrained
(зелёный) почти в половине случаев топчется на той же тонике (
P1≈ 43 %); после дообучения finetuned (красный) спускается до ≈ 18 %, почти как мой корпус (≈ 14 %), и набирает шаги на тон (M2). Гармония стала заметно подвижнее. - Качества аккордов (панель 1) — finetuned срезал доминантсептаккорды (
7) почти до моего уровня, но перекосил в сторону «голого»maj. - Обращения и надстройки (панели 3–4) — здесь моё ожидание не подтвердилось: finetuned их, наоборот, недодаёт (обращений ≈ 3 % против моих ≈ 9 %). Скорее всего, корпуса слишком мало, чтобы модель уверенно переняла мою привычку к slash-аккордам.
5.3 Качественные примеры¶
Беру одно и то же условие (тональность, размер, стиль, функция, seed) и генерирую двумя моделями. Эталон — мой собственный период, на метаданные которого я опираюсь.
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))
Что видно на этом примере (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(...). Ниже — последний вариант.
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) + " |")
7. Выводы и ограничения¶
Что получилось.
- Конвейер работает целиком:
.chord→ токены → обучение → генерация → обратно.chordи MIDI. - Дообучение снизило перплексию на валидации с 3.58 до 2.15 (−39.8 %): модель заметно лучше предсказывает мои периоды.
- Качественно генерации после дообучения ближе к моему стилю.
Ограничения.
- Корпус небольшой (68 периодов), поэтому оценки имеют высокую дисперсию.
data/holdout/не заполнен — перплексия измерена на валидации, а не на честном тесте.- Модель сводит энгармонию к диезам — это касается записи, а не звучания.
- Обучен по сути один стиль (
H1K0); остальные теги стиля есть в словаре, но на них модель не обучалась.
Дальнейшая работа (за рамками срока).
- Заполнить holdout и пересчитать перплексию на честном тесте.
- Попробовать дообучение на J-Pop корпусе.
- Добавить пост-обработку имён аккордов в бемоли для удобства чтения.