资讯动态

基于RNN-LSTM的旋律生成:从序列建模到PyTorch实践

发布时间:2026/9/13 2:08:49 来源:尧图企业网站定制
简介基于RNN-LSTM的音乐旋律生成完整项目来自机器学习实践课高分课程设计面向人工智能、计算机等相关专业学生也适合对音乐生成感兴趣的学习者进阶。压缩包共2000个文件体积约11.7MB主要包含303个krn格式音乐文件、3个Python源码、XML配置文件以及大量无扩展名的旋律数据文件并附有训练集、模型和生成音乐样本。项目按musicset音乐库、dataset处理数据、models模型、generated_musics生成音乐等模块组织代码已测试运行成功。下载后可对照说明文档快速上手通过阅读源码可学习RNN/LSTM在序列生成任务中的数据预处理、特征提取、模型训练与采样生成等具体方法也可在此基础上修改扩展用作课程设计、毕业设计或项目演示。已有186人学习下载作为深度学习与音乐生成交叉方向的入门参考具备不错的实践价值。1. 用RNN-LSTM生成旋律课设选题的经典解把旋律生成当成一门机器学习实践课的作业核心就三件事把音乐变成模型能吃的序列数据用RNN-LSTM把音符的先后依赖关系学出来再在生成阶段想办法让输出既不像复读机也不像乱码。这个题目的价值在于它比常见的文本分类、图像识别更贴近序列生成这个真实场景——你不仅要预测对还要让整段输出在时间维度上连贯。适合需要交一个可复现、可现场演示项目的课程设计也适合想搞懂生成式模型基础套路的开发者。这篇文会沿着数据表示、模型搭建、训练参数、生成采样和评估验收这条线展开中间会直接给出可运行的PyTorch代码和关键参数说明。既然标题里挂着高分课程设计那我会额外强调哪些环节是答辩时老师真正会追问的。2. 旋律数据表示从MIDI到可训练序列2.1 用MIDI解析出音符序列与事件流常见做法是拿MIDI文件作为原始数据源。MIDI本身记录的是note on/off事件、力度和时长不直接是五线谱所以第一步是把MIDI解成带时间戳的事件序列。推荐用mido库做底层解析pretty_midi做高级封装后者能直接提取每个音轨的起始时间、结束时间和音高省去手动处理通道和节拍的麻烦。import pretty_midi def midi_to_notes(midi_path): midi pretty_midi.PrettyMIDI(midi_path) notes [] for instrument in midi.instruments: if instrument.is_drum: continue for note in instrument.notes: notes.append({ pitch: note.pitch, # MIDI音高范围0-127 start: note.start, # 绝对起始时间秒 end: note.end, # 绝对结束时间秒 velocity: note.velocity # 力度一般不用 }) return sorted(notes, keylambda x: x[start])这段代码的关键在于按时间排序和过滤鼓组音轨。很多课设翻车不是因为模型而是因为把多音轨、多乐器的MIDI混在一起当成单音旋律来训练模型学到的分布全是噪音。如果是单旋律项目建议只保留主旋律音轨或者在解析后做一次单音化处理——同一时刻只保留音高最高的那个音符。数据干净后面模型才有意义。2.2 音符到整数的编码方案pitch、duration与事件表拿到音符列表后要决定模型预测什么。旋律生成一般有两种表示一是直接预测下一个音符的pitch把音高当作类别时长另起一个头预测二是把音符切分成更细的事件流比如NOTE_ON、NOTE_OFF、TIME_SHIFT三种事件各占一个token。课程设计建议用第一种简单直接训练稳定答辩容易讲清楚。代码实现上要维护一个pitch → index的映射表同时把每个音符的时值离散化到固定几个档位比如16分音符1、8分2、4分4、附点8、2分8具体数值根据数据集的分辨率调整。离散化的目的是把连续的时长变成有限的类别否则模型输出的是一个连续回归值训练难度完全不同。duration_quantize {0.25: 1, 0.5: 2, 1.0: 4, 1.5: 6, 2.0: 8} def notes_to_sequence(notes, pitch2idx, duration2idx): seq [] for n in notes: pitch n[pitch] dur round(n[end] - n[start], 2) if pitch not in pitch2idx or dur not in duration_quantize: continue seq.append(pitch2idx[pitch]) seq.append(duration2idx[duration_quantize[dur]]) return seq为什么不直接只编码pitch因为只预测音高会把旋律的节奏感抹掉生成出来的东西音高变化再合理节奏也是乱的。把时值类别的索引穿插在pitch索引后面相当于把音高时长作为一个联合序列让LSTM去建模。生成时两个索引交替解码就能同时得到音高和时值。注意编码时过滤掉了训练集中出现次数过少的pitch这能显著缩小vocab规模减少LSTM输出层的参数压力。2.3 序列窗口切分与训练集/验证集划分编码后的完整序列需要切成固定长度的窗口才能进入LSTM。窗口长度取决于数据集音乐的平均长度一般取64或128个token。太长训练慢且梯度回传容易出问题太短学不到长程结构。切片时用滑动窗口步长设为窗口的一半这样同一个乐句会在不同的偏移下出现多次相当于数据增强。def create_sequences(seq, window_size64, stride32): xs, ys [], [] for i in range(0, len(seq) - window_size, stride): x seq[i:i window_size] y seq[i 1:i window_size 1] # 预测下一个token xs.append(x) ys.append(y) return np.array(xs), np.array(ys)训练时要按曲子划分子集不能随机打乱后切分否则同一首歌的片段同时出现在训练集和验证集里loss会虚低答辩时一问泛化能力就露馅。我的做法是把每首歌的序列片段打上曲目标识按曲目ID做分层划分例如80%的曲目进训练集20%进验证集。验证指标用perplexity也就是交叉熵损失的指数形式它比loss本身更直观——代表了模型在每一步预测时面临的平均候选数。3. 从零搭建RNN-LSTM模型结构、损失与训练循环3.1 网络结构Embedding LSTM堆叠 全连接输出LSTM相比基础RNN的改进在于门控机制。RNN在每个时间步直接计算h_t tanh(Wx Uh_{t-1})误差反向传播时梯度经过多个时间步会反复乘以U的幂容易指数衰减或爆炸。LSTM引入遗忘门、输入门和输出门让信息能通过cell state以近乎线性的方式跨时间步传递梯度衰减被有效缓解。这也是为什么旋律这种靠长程结构撑起来的序列任务LSTM的默认效果一定会比裸RNN好。用PyTorch实现一个两层LSTM生成模型很简单但细节决定训练效果。Embedding层要把离散的token索引变成稠密向量维度一般取128或256。LSTM的hidden_size通常取256层数取2或3。最后一层LSTM的输出通过一个Linear层映射到vocab大小得到每个token的logits。import torch import torch.nn as nn class MelodyLSTM(nn.Module): def __init__(self, vocab_size, embed_size128, hidden_size256, num_layers2): super().__init__() self.embedding nn.Embedding(vocab_size, embed_size) self.lstm nn.LSTM(embed_size, hidden_size, num_layers, batch_firstTrue) self.fc nn.Linear(hidden_size, vocab_size) def forward(self, x, hiddenNone): x self.embedding(x) # (B, T, E) out, hidden self.lstm(x, hidden) # out: (B, T, H) logits self.fc(out) # (B, T, V) return logits, hidden这里有一个容易被忽略的设计nn.LSTM的默认初始化是均匀分布对深层网络来说初始状态的影响会被放大。我一般在初始化时对embedding层采用正态分布标准差设为0.1LSTM的权重用正交初始化。代码量不大但对训练初期的稳定性帮助明显。Embedding维度不要盲目加大128对旋律任务足够了加大到512只会增加过拟合风险和显存占用。3.2 损失函数用交叉熵与生成目标的对应关系模型输出的logits要被转换成语义上的概率分布然后计算交叉熵损失。PyTorch的nn.CrossEntropyLoss已经把softmax计算和损失计算合并了不需要在模型里额外接softmax。要注意的是输入形状logits的形状是(B, T, V)target的形状是(B, T)两者不能直接算loss需要把T维度合并到batch维度上。criterion nn.CrossEntropyLoss() optimizer torch.optim.Adam(model.parameters(), lr1e-3) def train_step(batch_x, batch_y): logits, _ model(batch_x) # (B, T, V) loss criterion(logits.reshape(-1, vocab_size), batch_y.reshape(-1)) optimizer.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 5.0) optimizer.step() return loss.item()交叉熵本质上是让模型预测的分布和真实音符分布的KL散度最小化对应的度量单位是信息量。这里不能用MSE因为MSE假设输出是连续值且误差服从高斯分布而音符索引是离散类别类别之间没有欧氏距离的语义——pitch60和pitch61是相邻半音pitch60和pitch100跨度很大但索引数值本身不代表距离。交叉熵不关心类别间的数值关系只关心概率分配天然适合这种离散token预测。训练时对梯度进行裁剪很重要LSTM的梯度范数经常莫名飙升到几百不裁剪loss会直接变NaN。3.3 teacher forcing、学习率调度与模型保存训练生成模型时有两个常用策略teacher forcing和free running。前者在每一步都用真实的目标token作为下一步的输入收敛快但会让模型过度依赖正确答案后者完全用模型自己的预测作为输入更接近推理时的真实状态但训练不稳定。常见做法是前30个epoch用teacher forcing之后逐步以一定概率替换成模型自己的输出概率从0.3线性增大到0.7。课程设计里用一个固定比例即可不要搞太复杂。学习率调度推荐用余弦退火或ReduceLROnPlateau。Adam本身有自适应学习率但对LSTM这种深层结构初始学习率1e-3跑几个epoch后loss波动变大这时候把学习率在验证loss不再下降时减半能让loss曲线更好看。模型保存的时机要看验证loss而不是训练loss。保存时除了state_dict还要把vocab映射表、窗口长度和模型超参一起存成JSON否则加载模型时根本没有办法把输出索引解码回音符。torch.save({ model: model.state_dict(), pitch2idx: pitch2idx, idx2pitch: idx2pitch, duration2idx: duration2idx, config: {embed_size: 128, hidden_size: 256, num_layers: 2} }, melody_model.pt)这里踩过最大的坑是训练后只保存模型权重丢了vocab映射。加载时用torch.load拿到权重却不知道索引0代表哪个音高整个模型变成垃圾。把映射表和配置一起存进checkpoint是课程设计中文档说明部分最值得写的细节。4. 训练与生成数据增强、采样策略与规则过滤4.1 数据增强移调与速度变换机器学习的课设数据集通常不大旋律生成项目经常会面临几十首MIDI的尴尬局面直接把模型训练30个epoch得到的结果基本是在背谱。常见的数据增强手段有两个移调transpose和变速tempo change。移调是把整首曲子整体上下平移若干个半音比如C大调移到D大调这样同一段旋律在数据集中出现12个不同版本模型不会把调性当成固定特征。def transpose_notes(notes, shift): new_notes [] for n in notes: new_pitch n[pitch] shift # 音高范围限制在MIDI 48~84之间防止过低或过高 if 48 new_pitch 84: new_notes.append({...}) return new_notes变速的语义要更小心。MIDI里音符的时长是绝对值把速度翻倍会让所有duration变成原来的一半已经做完的离散化映射就会错位。正确做法是在时长量化之后再做速度变化比如量化的dur值整体乘0.5或2。两种增强配合使用可以把一个100首歌的数据集扩张到1000首级别。但要注意验证集绝对不能做增强否则评估结果会失真。4.2 温度采样与Top-K过滤打破argmax的死板输出训练完成后生成时直接取每步概率最大的token会得到重复率极高的旋律。原因是交叉熵训练出的模型对高频音符有天然的偏好argmax会把这种偏好放大成永远倾向于走最熟悉的路。解决方法是采样时对logits除以一个温度参数temperature再经过softmax然后在这个概率分布中随机抽取一个token。def sample_from_logits(logits, temperature1.0, top_k5): logits logits / temperature probs torch.softmax(logits, dim-1).squeeze() if top_k 0: values, indices torch.topk(probs, top_k) probs torch.zeros_like(probs).scatter_(0, indices, values) probs probs / probs.sum() return torch.multinomial(probs, 1).item()温度参数的作用是平滑概率分布。温度低如0.5会让高概率token更突出输出更保守、更有原曲味道但容易重复温度高如1.5会让低概率token也有机会被选中输出更随机、更有新意但可能难听。Top-K的作用是把概率过低的token直接踢出候选池避免极端情况下选到完全离谱的音符。课程设计里做一个可交互的温度调节滑块会非常加分经验范围是0.6到1.2超过1.5基本就听不出调性了。生成时另一个细节是hidden state的初始值。如果不做任何处理直接调用model(x)LSTM的初始状态默认是全零向量生成的前几个音符通常质量很差。我一般会在生成序列的开头拼接一小段真实的旋律片段作为种子让模型先在这段上下文上跑几步再切换到采样模式。种子的长度取8到16个token即可太短模型还没进入状态太长又会限制输出的多样性。这个技巧在答辩现场演示时效果非常直观——给老师对比一下有种子和无种子生成的melody一段像模像样另一段开头几个音是蒙的高下立判。5. 模型评估、调参优化与实践课加分项5.1 客观评估指标与生成结果的验证方法课程设计的报告里不能只放训练loss曲线还要有一个生成结果是否合理的评估环节。常用两套指标一个是内部指标在验证集上计算perplexity公式是exp(loss)反映模型对下一个音符预测的不确定性另一个是外部统计指标对生成的旋律做音符分布直方图、音程跳进分布、时长分布三个维度的统计与训练集的分布对比差异越小说明生成结果风格越贴合原始数据。指标名称计算方式合理范围说明Perplexityexp(cross_entropy_loss)5~15越低说明预测越有把握音符分布相似度两直方图的余弦相似度0.6风格贴合程度的粗粒度指标重复串比例生成序列中最长重复片段的占比20%高于这个值说明采样温度太低验证方法上把生成的MIDI转成音频播放人耳试听是最终裁决。但答辩时只放音频不够建议把生成序列的pitch曲线和训练集里真实旋律的pitch曲线画在同一张图里对比老师一眼就能看出模型学到的音符波动范围和真实数据的差异。用matplotlib画折线图横轴是时间步纵轴是MIDI音高这个图放在报告里很有说服力。5.2 针对课设的调参方向与答辩加分技巧如果时间有限只需要调三个参数hidden_size、num_layers和temperature。hidden_size在128到512之间试三个档位每档训练到验证loss收敛后对比perplexity层数超过3层在旋律这种短序列任务上收益很小反而会明显变慢。温度参数不用在训练阶段调直接在生成阶段试听决定。最后如果结果还是不理想优先检查数据预处理而不是模型结构——常见问题包括多音轨混用、时长量化粒度过粗、没有做移调增强。答辩时容易被问到的点主要有三个为什么LSTM能处理长序列而RNN不能、为什么采样时不能用argmax、vocab中pitch和duration怎么区分。提前在文档说明里把这三个问题的推导写清楚配合代码里的注释基本能扛住追问。另外把数据集里实际出现的pitch范围、时长分布和窗口长度选择理由也写进去体现工程细节的完整度。生成结果的最终验证可以做一个简单粗暴的落点检查把生成的token序列还原成音符检查相邻音符之间的音程跳进是否超过两个八度即跳跃超过24个半音。如果频繁出现说明模型还没学到旋律的平滑性约束可以在采样时直接拒绝这种跳跃重新采样一次。这个规则过滤的做法比单纯调温度更快见效而且答辩时可以作为规则与概率模型结合的亮点讲出来。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价