简介:基于RNN-LSTM的旋律生成机器学习实践作业,是一份面向高校计算机、人工智能等专业课程设计/毕业设计场景的完整项目资料,特别适合想学习序列生成、深度循环网络应用的学生参考或二次开发。整份资源共2000个文件,压缩包仅11.7MB,主要包含3个Python源码文件、模型权重文件、帮助文档、JSON/XML配置,以及303个krn格式音乐数据,按音乐库、处理后数据集、训练模型、生成音乐四类模块清晰组织;源码覆盖数据预处理、模型训练、旋律生成等完整环节,并配有README说明,可快速定位代码逻辑。项目代码经过运行验证,下载后可直接复现从音乐预处理、模型训练到旋律输出的全流程,还能基于已有模型继续调整网络结构、训练更多风格,是理解RNN/LSTM在时间序列建模中作用的高分课设范例。已有187人学习,适合需要快速落地深度学习小项目的初学者,也适合作为结课报告、项目演示的基础。
1. 从“代码能跑”到“旋律能听”:这门课设到底在做什么
把机器学习实践课的作业做成“基于RNN-LSTM的旋律生成”,最直观的理解就是:喂给模型一批 MIDI 格式的现成曲子,让它学会音符之间的转移规律,然后在没有任何人工作曲规则的前提下,从零生成一段听起来像样的旋律。但如果你真动手做过就知道,这个标题里最难的不是“跑通代码”,而是让生成结果具备音乐性——节奏不碎、音高不跳、句子感明显,否则你交上去的只能叫“随机数播放器”。
这门课设的完整形态是:Python 源码 + 文档说明 + 数据 + 模型权重,前后端不用做,算法也不用发明,核心是把 RNN、LSTM、序列生成、词表建模这几件事在一个真实的音乐数据集上串起来。它适合两类人:一类是机器学习入门阶段需要高质量课程设计的学生,另一类是刚接触序列生成、想在音乐领域试水的开发者。它能帮你建立的不是“作曲能力”,而是对序列数据建模、采样策略、过拟合控制这件事的系统手感。
2. 训练数据:旋律在模型眼里不过是一串整数
2.1 为什么选 MIDI 而不是音频文件
旋律生成的第一步是决定喂给模型什么。常见做法是直接用 MIDI 文件,而不是 WAV 或 MP3。原因是音频波形采样率动辄 22050 Hz,一个三分钟的曲子就是几百万个采样点,用 RNN 去建模这种长度的序列,计算量和记忆负担都不可接受。而 MIDI 记录的是“音符事件”,比如“在第 1 拍按下中央 C,持续 0.5 拍后松开”,一条旋律展开成文本只有几十到几百个 token,天然就是序列数据。
你可以用music21这个库来做 MIDI 解析,也可以用mido做底层事件读取。对于课程设计这种体量,music21更合适,因为内置了音符合并、休止符识别和调性分析工具。数据集方面,常见做法是从开源 MIDI 库(比如古典钢琴 MIDI 合集)里挑选 100~200 首单旋律曲子,清洗掉打击乐轨和和弦轨,只保留主旋律。
from music21 import converter, note, stream def midi_to_notes(midi_path): score = converter.parse(midi_path) notes = [] for element in score.flat.notes: if isinstance(element, note.Note): notes.append(str(element.pitch)) # 记录音名,如 'C5' elif isinstance(element, note.Rest): notes.append('rest') return notes # 使用示例 notes = midi_to_notes('data/beethoven.mid') print(notes[:20])这段代码的作用是把一首 MIDI 拍平成一个音符名字符串列表,score.flat.notes会忽略小节线、力度等演奏信息,只保留时值对象。这里我刻意丢弃了时值,只保留音高,是为了让模型首先学会“音高转移”这件事。如果你想让生成结果节奏更稳定,可以把音符对象转成pitch + ':' + duration的形式,比如C5:0.5,但词表会变大接近一倍,训练数据不够时反而容易欠拟合。
2.2 词表构建与序列化:给旋律编号
RNN 不能直接吃字符串,需要先把所有出现过的音符映射成整数 ID。这跟你做 NLP 时建词表是同一个逻辑。词表大小通常取决于数据集里不同音高的种类数。单旋律古典钢琴曲通常在 30~60 个不同音高之间,加上一个休止符,词表非常小,这让训练难度比文本生成低一个量级。
from collections import Counter import pickle def build_vocab(all_notes): counter = Counter(all_notes) vocab = {note: idx for idx, (note, _) in enumerate(counter.most_common())} idx_to_note = {idx: note for note, idx in vocab.items()} return vocab, idx_to_note # 假设 notes_all 是所有 MIDI 解析结果的拼接 vocab, idx_to_note = build_vocab(notes_all) print(f'词表大小: {len(vocab)}') with open('vocab.pkl', 'wb') as f: pickle.dump({'vocab': vocab, 'idx_to_note': idx_to_note}, f)注意这里我用most_common()排序,出现次数多的音符排前面,这样做的目的是让低频音符的 ID 靠后,embedding 层初始化的随机噪音对它们的影响不会太大。词表构建完之后,把整个训练集转成 ID 序列,然后用滑动窗口切成固定长度的训练样本。窗口长度一般是 32~64,太短学不到乐句结构,太长会让 LSTM 的隐状态很难收敛。
sequence_len = 32 step = 1 sequences = [] for i in range(0, len(note_ids) - sequence_len, step): seq_in = note_ids[i:i + sequence_len] seq_out = note_ids[i + 1:i + sequence_len + 1] sequences.append((seq_in, seq_out)) # sequences[0][0] 是输入序列,sequences[0][1] 是目标序列(右移一位) print(f'样本数量: {len(sequences)}')滑动窗口步长设为 1 是为了最大化利用有限数据,相邻两个样本之间重叠了 31 个音符,这会让训练数据高度相关,但序列生成任务本来就是这么干的。如果你只有 50 首曲子,步长设 1 还能勉强训出模型,设成 16 的话样本量直接少一半,很容易欠拟合。
3. 模型搭建:从 Embedding 到 LSTM 输出层
3.1 Embedding 层为什么必须加
模型输入是音符 ID,一个整数。如果直接把这个整数作为 LSTM 的输入特征,模型会默认“音符 23 和音符 24 是相邻的”,但音高 23 和 24 在音乐上确实相差半音,这个假设在某些意义上成立,不过问题在于纯数值表示没有容量去表达“C 大调里 E 和 F 很近但和 C# 很远”这类语义关系。Embedding 层的本质是把每个 ID 映射成一个可学习的稠密向量,向量维度通常是 64~128。训练过程中,音高距离近的、经常相邻出现的音符,向量会自然聚在一起。
import torch.nn as nn class MelodyLSTM(nn.Module): def __init__(self, vocab_size, embed_dim=64, hidden_dim=128, num_layers=2): super().__init__() self.embedding = nn.Embedding(vocab_size, embed_dim) self.lstm = nn.LSTM(embed_dim, hidden_dim, num_layers, batch_first=True) self.fc = nn.Linear(hidden_dim, vocab_size) def forward(self, x): embedded = self.embedding(x) # (batch, seq_len, embed_dim) output, _ = self.lstm(embedded) # (batch, seq_len, hidden_dim) logits = self.fc(output) # (batch, seq_len, vocab_size) return logits3.2 LSTM 层数、隐藏维度与 dropout 的选择
LSTM 的层数我建议在课程设计里用 2 层,不要碰 3 层以上。原因在于你的数据集只有几百个 MIDI 文件,样本量撑不起深层模型的参数容量。第一层 LSTM 学习音符的局部语法(相邻音高的转移概率),第二层在此基础上抽象出短句结构。隐藏维度 128 是一个折中值——64 维在数据量少时更快收敛,但生成旋律的多样性会明显变差;256 维训练时间翻倍,但如果没有足够数据和正则化,验证损失会居高不下。
dropout 加在 LSTM 层之间,不加在输入和输出上。PyTorch 的nn.LSTM构造参数里直接传dropout=0.3,它会在两个 LSTM 层之间随机丢弃隐藏状态单元。有一点容易被忽略:单层 LSTM 传 dropout 参数会直接报错,因为 PyTorch 要求至少两层才有意义。
输出层是全连接层,输出维度等于词表大小,每个位置的值经过 softmax 就是下一个音符的概率分布。这里的交叉熵损失函数会直接对比预测分布和真实的下一个音符 ID。课程设计里不需要自己实现 softmax,直接用nn.CrossEntropyLoss就行,它在内部做了 log_softmax 和 NLLLoss 的合并,数值上比手动分开算更稳定。
import torch device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = MelodyLSTM(vocab_size=len(vocab)).to(device) criterion = nn.CrossEntropyLoss() optimizer = torch.optim.Adam(model.parameters(), lr=0.001)损失函数对 logits 的 shape 有要求:输入(batch, seq_len, vocab_size),目标(batch, seq_len)。PyTorch 的CrossEntropyLoss默认期望输入是(batch, vocab_size)或(batch, seq_len, vocab_size)都行,目标不需要 one-hot。如果你习惯用 Keras,那边需要注意sparse_categorical_crossentropy和categorical_crossentropy的选择,PyTorch 不存在这个问题。
4. 训练策略与旋律生成:从损失下降到能听,中间隔着采样
4.1 训练循环里最容易翻车的三个细节
第一个细节是梯度裁剪。LSTM 在长序列上容易出现梯度爆炸,尤其是序列长度在 64 以上、学习率设置在 0.01 以上时。你会在训练日志里看到 loss 突然从 1.2 跳到 nan,这就是梯度爆炸的典型表现。用clip_grad_norm_把梯度范数限制在 5.0 以内,基本能避免这个问题。
第二个细节是 epoch 与 batch 的配合。旋律生成的数据量小,一个 epoch 可能只有一两千个样本,batch size 设 64 的话,一个 epoch 只有几十次迭代。这种情况下训练曲线的震荡会很剧烈,建议每两个 epoch 打印一次 loss,不要每个 batch 都打。也可以用更大的 batch size 比如 128,但数据量不够时大 batch 会让模型收敛到平滑的局部最优,生成结果会比较“平庸”。
第三个细节是保存最佳模型而不是最后一次迭代的模型。训练后期模型往往开始过拟合训练集,最后几个 epoch 的参数反而比中间的差。常见做法是用验证集计算交叉熵损失,保存验证损失最低的权重。
for epoch in range(epochs): total_loss = 0 for batch_x, batch_y in train_loader: batch_x, batch_y = batch_x.to(device), batch_y.to(device) optimizer.zero_grad() outputs = model(batch_x) # (batch, seq_len, vocab_size) loss = criterion(outputs.transpose(1, 2), batch_y) loss.backward() nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0) optimizer.step() total_loss += loss.item() avg_loss = total_loss / len(train_loader) print(f'Epoch {epoch+1}, Loss: {avg_loss:.4f}') if avg_loss < best_loss: torch.save(model.state_dict(), 'best_model.pt') best_loss = avg_lossoutputs.transpose(1, 2)是为了把维度变成(batch, vocab_size, seq_len),以匹配 CrossEntropyLoss 对 channel 维度在第二位的期望。这个转置操作是我自己写训练循环时最常见的低级错误来源之一,如果你看到 loss 维度报错,优先检查这里。
4.2 生成旋律:温度采样与终止条件
训练完成后,生成旋律是一个自回归过程:从随机或指定的起始音符开始,把当前序列输入模型,拿到下一个音符的概率分布,采样一个音符拼到序列末尾,再截掉最前面的音符,循环往复。这里最关键的一个参数叫 temperature,也叫温度系数。
import torch.nn.functional as F def generate_melody(model, seed_sequence, length=64, temperature=1.0, idx_to_note=None): model.eval() input_seq = seed_sequence.clone().unsqueeze(0).to(device) generated = [] for _ in range(length): with torch.no_grad(): logits = model(input_seq)[0, -1, :] / temperature probs = F.softmax(logits, dim=-1) next_idx = torch.multinomial(probs, 1).item() generated.append(next_idx) input_seq = torch.cat([input_seq[:, 1:, :] if False else input_seq, torch.tensor([[next_idx]]).to(device)], dim=1) input_seq = input_seq[:, -32:] # 只保留最近 32 个音符 return [idx_to_note[idx] for idx in generated]温度参数的行为:温度等于 1.0 时,概率分布保持原样,生成结果多样性适中。温度小于 1.0(比如 0.5)时,概率分布变得更尖锐,模型更倾向于选概率最高的音符,旋律更“保守”但也更像人写的。温度大于 1.5 时分布被拉平,低概率音符更容易被选中,生成结果会越来越随机,听起来像无调性音乐。课程设计报告里建议做一个温度对比实验,把 0.5、1.0、1.5 三个温度下的生成结果分别转成 MIDI,让听感差异直接呈现在报告里。
torch.multinomial是采样函数,它按概率分布抽取一个整数索引,而不是取argmax。如果你想验证模型是否学到了旋律结构,取 argmax 生成的旋律往往平淡无奇、不断重复同一个音符,因为训练集中每个位置下一个音符的最高概率项都相差不大。用采样才能释放模型的多样性。
4.3 把音符序列转回 MIDI 文件
生成结果是音符名字符串列表,music21可以直接把它们写成 MIDI。注意时值全部设为 0.5 拍,先不管节奏变化,确保老师能直接播放。
from music21 import stream, note def notes_to_midi(notes, output_path): midi_stream = stream.Stream() for n in notes: if n == 'rest': midi_stream.append(note.Rest(quarterLength=0.5)) else: midi_stream.append(note.Note(n, quarterLength=0.5)) midi_stream.write('midi', fp=output_path) notes_to_midi(generated_notes, 'generated_output.mid')这段代码生成的 MIDI 每个音符时值相同,节奏机械。如果有余力,可以改造成每个音高同时记录时值,比如把旋律表示成[音高ID, 时值ID]交替输入模型。但课程设计阶段不推荐这么干,因为两个序列同时预测的收敛难度高很多,很容易变成“模型学会了音高但节奏全乱”。
5. 避坑指南:课程设计里 90% 的问题出在这五个地方
5.1 训练到一半 loss 变成 nan
现象:训练日志正常打印了两三个 epoch,忽然某一轮 loss 变成 nan,之后一直 nan。
原因:最常见的两个原因,一是学习率过高导致梯度爆炸,二是序列里出现了词表之外的字符,比如music21在解析某些 MIDI 时返回了chord对象,你没有处理就直接调用了str(element.pitch),产生了词表外的字符串,而你没在预处理时清洗掉,ID 映射时出错。
解决:先给模型加梯度裁剪,这是治标。然后回到数据预处理,明确过滤掉element.isChord的节点,只保留单音符和休止符。另外检查词表构建时是否对全部数据建立了索引,而不是只对训练集建立了索引就直接分割数据集。
5.2 生成长度超过 20 个音符后开始无限重复
现象:旋律开头几个音符有模有样,到第 20 个音符左右开始反复循环“C5 D5 C5 D5”,无限重复。
原因:模型陷入局部最优,学到了训练集里高频出现的短音符组合,也就是旋律的“套话”。这本质上是温度太低 + 模型容量不足 + 数据多样性不够三个因素叠加。
解决:将温度从 0.8 调到 1.2 再试。同时检查数据清洗阶段是否把很多 MIDI 转成了相同的调,比如全是 C 大调,模型只学会了 C 大调音阶的转移。用music21的转调工具把部分曲子移到 G 大调或 F 大调,数据多样性立刻提升。
5.3 验证损失下降但生成旋律等于随机噪音
现象:loss 从 1.8 降到 1.1,听着还行,但是生成的旋律音程跳跃极大,相邻音符动不动跨八度。
原因:CrossEntropyLoss衡量的是下一个音符的预测准确率,它不会惩罚“不和谐”或“音程跳跃过大”。旋律的流畅性靠的是模型隐层对音高转移概率的自发学习,如果训练数据里的旋律本身音程跳跃不大,模型理论上能学会。但如果 loss 降得不够低(比如只有 1.5 左右),模型对低频音符的转移概率还没学好。
解决:增加训练轮数,明确验证 loss 是否还在缓慢下降。如果 loss 降到 0.8 以下生成结果依然乱跳,检查 LSTM 隐藏维度是否太小(64 维可能不够),或者输入序列长度过长(64 个音符起步学习太慢)。课程设计数据量在 100 首 MIDI 左右时,隐藏维度 128 + 序列长度 32 是性价比最高的组合。
5.4 模型在 GPU 上训练比 CPU 还慢
现象:训练时 watch 一下显卡利用率,发现只有 10%,耗时和 CPU 差不多。
原因:序列长度 32、词表大小 50、batch size 64 这个规模的模型,参数量撑死几十万,计算量远小于图像分类任务。数据从 CPU 拷贝到 GPU 的开销占比过高,GPU 大部分时间在等数据。
解决:最简单的方式是不要用 GPU,直接用 CPU 训练,通常二三百首 MIDI 的数据量在 CPU 上十分钟内能跑完。如果坚持用 GPU,把DataLoader的num_workers调高到 4 以上,并确认pin_memory=True。
5.5 文档里没有说明如何复现模型导致答辩扣分
现象:模型文件交上去,但老师运行代码时发现缺少环境依赖,或者没有requirements.txt,跑不起来。
原因:课程设计的评分标准里,文档说明和可复现性占比高于算法创新。你不写环境、不写参数表、不写文件组织说明,等于丢了一部分分。
解决:项目里必须有requirements.txt,至少包含torch、music21、numpy。文档说明部分至少包括三块内容:数据来源与清洗规则、模型参数表(词表大小、embedding 维度、LSTM 层数、隐藏维度、dropout、学习率、温度)、以及训练和生成两个命令的完整示例。把模型文件名写成best_model.pt,文档明确说这是验证损失最低的一轮。给老师省时间,就是给自己省麻烦。
6. 让课程设计从“高分”走向“能打”:评估生成质量的三个硬指标
课程设计交完不是终点,最好回过来做一次定量评估,答辩时用数据说话。第一个指标是音高直方图对比:把训练集所有音符的出现频率统计出来,和生成旋律的音符频率做 KL 散度计算,数值越小说明生成旋律的音符分布在整体上更接近训练集。用scipy.stats.entropy几行代码就能算出结果。
第二个指标是自相关系数衰减速度。把生成旋律转成音高序列(用 MIDI 编号表示),计算lag=1到lag=8的自相关系数。人类写的旋律自相关通常在小间隔上呈梯度衰减,说明存在短句结构。随机噪音的自相关会直接断崖下跌,因为前后音符毫无关系。这个指标不需要额外库,numpy.correlate或pandas.Series.autocorr就能算。
第三个指标是温度敏感性测试。在 0.4 到 2.0 之间每隔 0.2 取一个温度生成 10 段旋律,统计每段的平均音程差。你会发现温度在 0.6 到 1.2 之间时平均音程差缓慢上升,超过 1.5 后急剧上升——这个拐点就是模型对采样分布的“失控点”,写在报告里可以作为参数选择依据。答辩时与其说“我调了一晚上的参数”,不如直接展示这张温度-音程差折线图。我的习惯是模型训练完成后再留出 2 小时做这组实验,因为它们改的是推理脚本,不需要重新训练,出结果很快。做课设最忌讳把训练当终点,生成结果好坏本身才是你真正要交付的东西,希望帮到你。
本文还有配套的精品资源,点击获取