基于LSTM的MIDI音乐分类实践:事件流构造与PyTorch实现
2026/9/18 12:12:36 网站建设 项目流程

简介:PDF内容围绕LSTM模型在MIDI格式音乐识别与分类中的应用,面向深度学习研究者及音乐信息检索入门者。针对人工智能生成音乐与人类作曲家作品的区分问题,文档提出将MIDI文件转化为自然语言序列的编码方法,涵盖音高、时值、力度、速度等音乐特征,并采用mLSTM单元结合逻辑回归构建分类模型,基于6000个人工智能生成旋律文件和5742个人类作曲家旋律文件进行训练,通过10折交叉验证达到约90%的平均准确率。文中对数据准备、模型结构、评估过程都有清晰说明,可帮助读者掌握音乐序列建模思路,也可作为相关论文复现与课程设计的参考资料。资源包共1个文件,为PDF格式,大小约420KB,内容紧凑便于阅读。目前已有115人学习下载,适合对音乐AI、LSTM与序列分类感兴趣的读者阅读。

1. 为什么 MIDI 分类比音频分类更适合 LSTM 入门

MIDI 文件和 MP3、WAV 这类音频文件有本质区别:它不保存声波,只保存“在什么时间、用什么音色、按下哪个音、按了多久、松开”的演奏指令。换句话说,MIDI 是符号序列,天然带着时间步长。而 LSTM 恰好是擅长处理“变长时间序列”的循环结构。把两者放在一起,可以避开频谱特征提取、窗函数选择这些信号处理难题,直接在离散符号上做序列建模。这也是拿 LSTM 做音乐识别与分类时,业界更常选 MIDI 而非音频落地的原因。

但这不代表任务变简单。一首 MIDI 曲子同时存在多条轨道(旋律、伴奏、打击乐),每条轨道的事件时间戳是稀疏的,而且不同曲子的速度、调号、音高范围差异很大。直接拿原始 MIDI 文件丢给模型,几乎不可能收敛。常见的做法是先对 MIDI 做事件抽取和序列化,把“多轨乐谱”变成“单轨事件流”,再做分类。这个预处理步骤的合理程度,直接决定 LSTM 的准确率上限。

这篇文章写给两类人:一类是刚开始接触序列模型,想找一个比情感分析更直观的 LSTM 练手场景;另一类是做过图像或表格分类,但对 MIDI 解析、音乐特征组织不熟,想快速搭一个能跑通、能看到指标变化的最小方案。下面所有代码基于 Python 3.8+ 和 PyTorch 2.x,MIDI 解析使用 mido 库。

2. 从 .mid 文件到 LSTM 输入张量:事件流构造与特征编码

2.1 MIDI 文件里到底有什么:从 mido 读取原始消息开始

mido 是 Python 生态里最常用的 MIDI 解析库,跨平台、无系统依赖。安装后可以用MidiFile直接读入文件,遍历每个音轨的每条消息。每条消息都有一个type字段,常见的是note_onnote_offcontrol_changeprogram_changeset_tempo,其中note_onvelocity为 0 时等价于note_off

import mido mid = mido.MidiFile("example.mid") print("格式类型:", mid.type) # 0/1/2,决定轨道语义 print("分辨率:", mid.ticks_per_beat) # 每个四分音符的 tick 数 print("轨道数量:", len(mid.tracks)) for i, track in enumerate(mid.tracks): print(f"--- Track {i} ---") for msg in track: if msg.type in ["note_on", "note_off"]: print(msg.type, "note=", msg.note, "velocity=", msg.velocity, "time=", msg.time)

这段代码做了三件事:打印 MIDI 格式类型、分辨率(ticks_per_beat)和每条音符消息。msg.time的单位是 tick,不是秒,同一首曲子在不同分辨率下数值会不同。如果直接用 tick 做特征,模型会被分辨率差异干扰,所以后续必须换算成秒或者离散时间步。注意mid.type为 1 时表示多轨文件,通常第 0 轨是控制信息,其余才是实际音符。

2.2 事件序列化的三种做法:按 tick 归一、按固定时长切片、按音符事件展开

拿到原始消息后,不能直接把 note 编号喂给 LSTM。最常见的问题有三个:多轨并行导致时间重叠、打击乐通道的 note 编号语义和旋律轨道不同、音符时长没有显式表达。业界解决这些问题的思路有三个,按实现复杂度和效果排序如下:

  • 按 tick 归一:把所有轨道按时间合并成一张“钢琴卷帘”矩阵,行是时间步,列是 128 个音高,值代表是否按下。这种方式简单,但音乐是稀疏的,矩阵大部分为 0,LSTM 很难学到音高间的相对关系。
  • 按固定时长切片:把整首曲子按 0.1 秒或一个四分音符切成等长窗口,每个窗口内统计音高直方图、平均力度、音符密度。这种方式特征稳定,但丢失了音符之间的先后依赖。
  • 按音符事件展开:把note_onnote_off按时间排序,转成一个序列,每个元素包含音高、相对上一条事件的间隔、力度。这是最接近语言模型的处理方式,也是当前音乐生成和分类模型的主流做法。

做识别与分类任务时,我一般推荐第三种。它保留了 MIDI 最核心的“谁先谁后”信息,而且序列长度可控。一个 3 分钟的 MIDI 曲目展开后通常有 2000 到 10000 个事件,这个长度正好适合 LSTM 的截断训练。

2.3 特征编码:音高、力度、时值、轨道的向量化

每个音符事件需要编码成特征,不能只放一个 note 编号。一组可靠特征是:

特征范围编码方式理由
note0-127one-hot 或 embed 向量音高本体,128 维 one-hot 稀疏,常用 64 维 embedding
velocity0-127归一化到 [0,1]演奏力度差异在 MIDI 里是重要风格信号
duration0-若干秒log1p 归一化时值分布长尾,直接缩放会被长音符支配
inter-onset0-若干秒log1p 归一化表达节奏疏密,是风格分类的关键
track_role0-3整数区分旋律、伴奏、低音、打击乐轨道

先给定一个事件展开函数,核心逻辑是维护一个“按下但未松开”的字典。遍历完成所有消息,每当遇到note_on就启动一个音符实例;遇到note_off或同 note 的note_on + velocity=0时,计算 duration 并写入事件。

def midi_to_events(mid_path, ticks_per_beat=None): mid = mido.MidiFile(mid_path) tempo = 500000 # 默认 120BPM 的微秒数/四分音符 seconds_per_tick = tempo / 1e6 / mid.ticks_per_beat events = [] pending = {} for track_idx, track in enumerate(mid.tracks): abs_time = 0 for msg in track: abs_time += msg.time if msg.type == "set_tempo": tempo = msg.tempo seconds_per_tick = tempo / 1e6 / mid.ticks_per_beat elif msg.type == "note_on" and msg.velocity > 0: key = (track_idx, msg.channel, msg.note) pending[key] = (abs_time * seconds_per_tick, msg.velocity / 127.0) elif msg.type == "note_off" or (msg.type == "note_on" and msg.velocity == 0): key = (track_idx, msg.channel, msg.note) if key not in pending: continue start, vel = pending.pop(key) dur = abs_time * seconds_per_tick - start events.append({ "note": msg.note, "velocity": vel, "duration": dur, "offset": start, "track_role": min(track_idx, 3), }) events.sort(key=lambda e: e["offset"]) return events

这里有个隐蔽细节:msg.time是相对增量时间,必须用abs_time += msg.time累加为绝对时间。pending字典用(轨道, 通道, 音高)做键,这样多个轨道同时按同一个音时不会互相覆盖。计算seconds_per_tick时,要把tempo从“微秒/四分音符”转成“秒/tick”。

排序后的事件还要计算inter-onset:当前事件与上一个事件起始时间的差值。所有特征拼成向量后,还需要做均值-方差归一化。注意notetrack_role是类别特征,不要参与连续特征归一化,否则 embedding 输入会失衡。

3. LSTM 模型结构设计与超参数选择:从单层到多层

3.1 为什么单层 LSTM 就足以处理 MIDI 分类,而多层反而易过拟合

LSTM 的核心是一个带门控的循环单元,通过输入门、遗忘门、输出门控制信息流的写入、保留和读出。MIDI 事件流的局部依赖(比如前一个音高对后一个音高的影响)通常在几十步以内,单层 hidden size 128 的 LSTM 已经有足够的记忆容量。增加层数确实能学到更高层的时间抽象,但 MIDI 分类数据集通常只有几百到几千首曲子,数据量撑不起深层循环网络的参数量。

在动手写模型之前,建议先定两个基线:随机猜测准确率,和用“按音符直方图 + 逻辑回归”得到的简单基线。如果 LSTM 模型在这个简单基线附近徘徊,问题基本不在模型结构,而在预处理或特征编码上。这是做这类项目最容易被忽略的一点。

3.2 代码骨架:用 PyTorch 搭建一个可复用的 MIDI-LSTM 分类器

下面给出一个可直接运行的分类器骨架。隐藏层数可以配置,默认是 1,防止过拟合。输入经过一个 embedding 层处理音高编号,其余连续特征拼接到 embedding 输出后面。

import torch import torch.nn as nn class MidiLSTMClassifier(nn.Module): def __init__(self, num_notes=128, embed_dim=64, hidden_size=128, num_layers=1, num_classes=8, dropout=0.3): super().__init__() self.note_embed = nn.Embedding(num_notes, embed_dim) self.lstm = nn.LSTM( input_size=embed_dim + 2, # embed + velocity + duration hidden_size=hidden_size, num_layers=num_layers, batch_first=True, dropout=dropout if num_layers > 1 else 0.0, bidirectional=False, ) self.classifier = nn.Sequential( nn.Linear(hidden_size, 128), nn.ReLU(), nn.Dropout(dropout), nn.Linear(128, num_classes), ) def forward(self, notes, velocity, duration, mask=None): # notes: (B, T);velocity/duration: (B, T, 1) emb = self.note_embed(notes) # (B, T, embed_dim) feat = torch.cat([emb, velocity, duration], dim=-1) # (B, T, embed_dim+2) out, _ = self.lstm(feat) # out: (B, T, hidden) # 用最后一步的隐藏状态做分类 last = out[:, -1, :] return self.classifier(last)

前向传播的两处关键说明:batch_first=True让输入形状是(batch, time, feature),更符合直觉。torch.cat把音高 embedding 与连续特征在最后一维拼接,这样模型可以在同一个时间步内同时看到“是什么音”和“多长、多重”。从out中取[:, -1, :]是拿最后一个时间步的 hidden state 做整曲表示,这在序列分类里是最简单的做法。

但有个细节要注意:MIDI 事件流长度差异很大,一个 batch 内可能需要 pad 到相同长度。对 LSTM 来说,pad 的位置如果参与计算,会引入大量无效状态。用 PyTorch 的pack_padded_sequence可以解决,或者更偷懒但可靠的做法是:训练时每个 batch 内只采样长度相近的事件流,配合mask把 padding 位置的 loss 置零。后者代码量少,适合第一版。

3.3 损失函数与优化器选择

分类任务首选交叉熵损失。MIDI 分类的类别通常不均衡,比如风格分类里流行曲目远多于古典曲目,这时要给每个类别设置权重,让模型对小样本类别更敏感。

from torch.nn.utils.rnn import pad_sequence def collate_with_padding(batch): notes, velocity, dur, label = zip(*batch) notes_pad = pad_sequence(notes, batch_first=True, padding_value=0) vel_pad = pad_sequence(velocity, batch_first=True, padding_value=0.0) dur_pad = pad_sequence(dur, batch_first=True, padding_value=0.0) return notes_pad, vel_pad, dur_pad, torch.tensor(label) class_weights = torch.tensor([1.0, 2.0, 1.5, 1.0, 2.0, 1.0, 1.2, 1.5]) criterion = nn.CrossEntropyLoss(weight=class_weights) optimizer = torch.optim.Adam(model.parameters(), lr=3e-4)

pad_sequence默认按 batch 内最长序列补齐,并且会在后面追加一个 batch 维,所以要先zip(*batch)再传入。优化器用 Adam,学习率 3e-4 是序列分类任务里比较稳妥的起点;如果训练曲线震荡,降到 1e-4。不要一开始就用 SGD,MIDI 特征空间复杂,SGD 收敛速度慢,调试期效率太低。

4. 构建训练与评估闭环:数据集划分、准确率陷阱和混淆矩阵

4.1 划分数据集时最容易犯的错误:同一首歌的多个 MIDI 变体会泄漏

MIDI 文件有个常见现象:同一首曲子存在多个版本、多个转调、多种 midi 格式封装。如果随机划分训练集和验证集,可能同源曲目同时出现在两边,导致验证集指标虚高。一个简单的应对方式是按文件名前缀或作曲家信息做分组划分,保证同一个来源只属于一侧。

第二个容易踩的坑是:MIDI 分类的验证集不能直接用“准确率”一个指标。类别不均衡时,模型可能把大部分样本预测为多数类,准确率看着不低,但少数类全错。建议同时看每个类别的 recall 和 macro-F1。这两者才是判断模型有没有真正“学会区分”的依据。

4.2 训练脚本的最小可用版:早停、梯度裁剪与学习率预热

MIDI 数据集的规模通常不会太大,训练时间不长,所以早停和梯度裁剪就够用,不必上复杂的调度器。

def train_one_epoch(model, dataloader, optimizer, criterion, clip=1.0): model.train() total_loss, correct, total = 0.0, 0, 0 for batch in dataloader: notes, vel, dur, labels = [x.to(device) for x in batch] optimizer.zero_grad() logits = model(notes, vel.unsqueeze(-1), dur.unsqueeze(-1)) loss = criterion(logits, labels) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), clip) optimizer.step() total_loss += loss.item() * len(labels) correct += (logits.argmax(dim=1) == labels).sum().item() total += len(labels) return total_loss / total, correct / total

这段训练逻辑每一步都是必须的:clip_grad_norm_解决 LSTM 训练时的梯度爆炸问题,gradient clipping的阈值设为 1.0 在 MIDI 任务上表现稳定。labels来自 DataLoader,不需要手动做 one-hot,CrossEntropyLoss 会内部处理。

评估函数与训练函数的主要区别是torch.no_grad()model.eval()。LSTM 的 dropout 在评估时必须关闭,但 BatchNorm(如果有)仍要用训练阶段累积的统计量,这两者容易混淆。ID3 标准 MIDI 分类场景不用 BatchNorm,但如果你用了,千万不要忘记model.eval()

def evaluate(model, dataloader, criterion): model.eval() total_loss, correct, total = 0.0, 0, 0 all_preds, all_labels = [], [] with torch.no_grad(): for batch in dataloader: notes, vel, dur, labels = [x.to(device) for x in batch] logits = model(notes, vel.unsqueeze(-1), dur.unsqueeze(-1)) loss = criterion(logits, labels) total_loss += loss.item() * len(labels) correct += (logits.argmax(dim=1) == labels).sum().item() total += len(labels) all_preds.extend(logits.argmax(dim=1).tolist()) all_labels.extend(labels.tolist()) return total_loss / total, correct / total, all_preds, all_labels

4.3 初版跑通后的性能检查清单

当第一个版本跑通之后,先记录训练集准确率和验证集准确率,再判断下一步方向:

现象概率原因处理方向
训练集准确率很低(<80%)特征编码丢失信息检查 duration 和 inter-onset 是否归一化正确;能否看到钢琴卷帘可视化
训练准确率 100%,验证准确率低过拟合增大 dropout、加正则、减少 hidden_size、缩小 embedding 维数
验证曲线震荡严重学习率过高调低到 1e-4 或增加 warmup
某个类别 recall 始终为 0样本太少对该类做采样增强,或合并语义相近类别
首轮迭代 loss 没有下降归一化或数据顺序有问题打印一个 batch 的输入统计值,检查是否有 NaN

这张表比任何调参技巧都实用。MIDI 分类模型的结构差异对结果影响远小于预处理和标签质量的影响,先把事件流构造对,再谈模型优化。

5. 进阶:用双向 LSTM 与注意力机制提升风格分类上限

5.1 从“只看过去”到“前后文都看”

前面用的单向 LSTM 只能利用历史信息。但音乐有很多“回应式”结构,比如一段旋律后面跟着相似的伴旋律,或者某个主题在后面被变奏。单看前文无法建模这种关系。把 LSTM 改为双向结构,让每个时间步同时编码前向和后向的上下文,在风格分类、作曲家识别这类“全局判断”任务上通常有 2 到 5 个百分点的提升。

要实现双向模型,只需要把bidirectional=True,同时在forward函数里调整输出拼接方式。nn.LSTM在双向模式下会返回两个方向的隐藏状态拼接,out[:, -1, :]就变成了“前向最后一步 + 反向倒数最后一步”的拼接,正好抓住整曲的全局信息。注意 hidden_size 需要翻倍,否则 classifier 输入维度对不上。

5.2 给最后一步加一层注意力池化

MIDI 序列里不是每个时间步都同等重要。有的段落只是长音背景,有的段落是标志性旋律。取最后一步直接丢给分类器,相当于把整个序列压缩到最后一个状态里,信息损失很大。常见做法是把所有时间步的 hidden state 做一个加权平均,权重由一个小网络学出来,这叫注意力池化。

class AttentionPooling(nn.Module): def __init__(self, hidden_size): super().__init__() self.attn = nn.Sequential( nn.Linear(hidden_size, hidden_size // 2), nn.Tanh(), nn.Linear(hidden_size // 2, 1) ) def forward(self, lstm_out): # lstm_out: (B, T, hidden_size) scores = self.attn(lstm_out).squeeze(-1) # (B, T) weights = torch.softmax(scores, dim=-1) return torch.sum(weights.unsqueeze(-1) * lstm_out, dim=1)

这个模块的输入是所有时间步的 LSTM 输出,中间用一层隐藏层把每个时间步映射为一个标量分数。分数经过softmax后变成权重,再对所有时间步的 hidden state 做加权求和。weights.unsqueeze(-1)是为了让权重从(B, T)扩展成(B, T, 1),能与lstm_out相乘。这个技巧的代码量很小,但能明显提升在长序列上的分类效果,代价是增加大约 10% 的计算量。

5.3 训练策略的升级:梯度裁剪、warmup 与循环学习率

当模型从单层升级到双向加注意力后,参数量变大,训练难度也变大,原本的固定学习率策略需要调整。常见做法是先让学习率从接近 0 线性升到目标值,再按余弦曲线下降。PyTorch 自带的OneCycleLR可以一步完成这个调度。

steps_per_epoch = len(train_loader) scheduler = torch.optim.lr_scheduler.OneCycleLR( optimizer, max_lr=5e-4, epochs=num_epochs, steps_per_epoch=steps_per_epoch, pct_start=0.1, # 前 10% 的 step 做预热 anneal_strategy="cos" )

使用OneCycleLR有个容易忽略的点:每个优化器 step 之后都要调用scheduler.step(),不是像ReduceLROnPlateau那样在 epoch 结束时调用。如果漏了,学习率会一直停在初始值附近。anneal_strategy="cos"表示余弦退火,比线性退火在序列任务上效果更平滑。

5.4 扩展:从分类到 MIDI 生成与音乐相似度检索

分好类之后,LSTM 学到的序列表示还有一个等价用途:把中间层的 hidden state 当成整首曲子的 embedding,做相似乐曲检索。这个场景在音乐推荐、翻唱检测里很常见。

一个可运行的检索流程是:用训练好的模型,跑一遍所有库内音乐的前向传播,取出注意力池化层的输出作为 128 维向量,存入向量数据库。查询时对输入 MIDI 做同样处理,然后计算向量余弦相似度,取 top 10。关键点是训练模型时不要用类别标签之外的任何信息修改模型结构,否则 embedding 的语义会混入分类偏置,检索效果不稳定。

6. 落地验证技巧:用 MIDI 编辑器和数据可视化反查模型错误

最容易被忽视的验证手段不是指标,而是把模型分错的样本拿出来,生成一个可视化的钢琴卷帘,观察模型到底被什么迷惑了。用 Python 的pretty_midi库可以把错误样本转成 MIDI 并绘制音高-时间图。

import pretty_midi def visualize_sample(mid_path, save_path): pm = pretty_midi.PrettyMIDI(mid_path) piano_roll = pm.get_piano_roll(fs=10) # 10Hz 采样 import matplotlib.pyplot as plt plt.figure(figsize=(16, 6)) plt.imshow(piano_roll, aspect="auto", origin="lower", cmap="magma", vmin=0, vmax=80) plt.xlabel("time / 100ms") plt.ylabel("pitch") plt.savefig(save_path, dpi=100, bbox_inches="tight") plt.close()

get_piano_roll(fs=10)返回的矩阵行是音高、列是时间(每列代表 0.1 秒),数值是力度或音符有无。把预测错误的样本可视化之后,观察重点有三个:

  • 模型是否被密集音符主导,把快节奏曲目都分到同一类
  • 是否是低音区和打击乐轨道干扰了判断
  • 同一类内的 MIDI 是否本身标注质量就差,比如风格标签有争议

如果发现特定轨道(比如打击乐)影响过大,常见的处理是解析 MIDI 时把channel == 9的轨道直接过滤掉,或者把所有轨道按track_role特征加权,让旋律轨信息主导序列。也可以用 midi 编辑器直接手工删除某条轨道,对比删除前后模型输出差异,确认影响来源。这个思路在调优阶段比盲目加网络层更高效。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询