Files
hamori/notebooks/report.ipynb
T
H1K0 c56397df54 docs: add Russian project report notebook (notebooks/report.ipynb)
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>
2026-06-04 17:07:00 +03:00

295 KiB
Raw 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. Выводы и ограничения

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

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

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

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

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

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