Lab 1 · 深度学习入门与音乐生成
本实验对应第 1、2 讲:先用一层网络把「前向 → 损失 → 反向 → 更新」跑通,建立张量与自动微分的手感;再用字符级 RNN 学习一段旋律的序列规律,并从初始片段续写出新乐句。全程不需要大数据集。
1. 环境与数据
- 运行环境:Python 3.9+,TensorFlow 或 PyTorch 任选其一(本书示例给出 PyTorch 版本,思路与框架无关)。
- 数据:一小段符号化的旋律(如 ABC 记谱或 MIDI 转成的音符事件序列)。把它当作一串 token——这正是第 2 讲把序列当作离散符号序列的翻版。
本实验的目的是看清训练循环与序列模型的行为,不是训练一个能谱曲的模型。数据集只有几 KB 时,模型能学会局部的音符搭配与节奏模式就已经达到目标。判断标准是"续写像训练数据",不是"续写好不好听"。
2. 第一部分:用自动微分训练一层网络
把一个简单函数(如 在若干采样点上)当作回归问题,用一层网络拟合。每一步都对应第 1 讲的四个动作:
import torch, torch.nn as nn
model = nn.Sequential(nn.Linear(1, 64), nn.ReLU(), nn.Linear(64, 1))
opt = torch.optim.Adam(model.parameters(), lr=1e-2)
loss_fn = nn.MSELoss()
for step in range(2000):
pred = model(x) # ① 前向:得到预测
loss = loss_fn(pred, y) # ② 损失:MSE(回归用 MSE,见第 1 讲 1.4)
opt.zero_grad() # 清空上一轮梯度
loss.backward() # ③ 反向:自动微分求梯度(定理 1.2)
opt.step() # ④ 更新:θ ← θ − η∇L
model(x) = 定义 1.1 的前向;loss_fn = 定义 1.2 的 MSE;loss.backward() = 定理 1.2 的反向传播;opt.step() = 定义 1.3 的梯度下降。把这条对应关系记住,你就掌握了所有深度学习的骨架。
把 nn.ReLU() 删掉再训一次,拟合效果如何?为什么?(提示:见第 1 讲 1.2的"线性变换 + 非线性"。)
拟合会退化——两层线性等价于一层线性,无法表达 的弯曲。损失停留在一个较高的平台,加大训练步数也无济于事。这从反面印证了激活函数的作用。
∎3. 第二部分:字符级 RNN 生成旋律
建模思路(第 2 讲):把旋律表示成 token 序列 ,模型在每个时刻读入当前 token 的 one-hot,输出下一个 token 的概率分布 。
class RNN(nn.Module):
def __init__(self, vocab, hidden=128):
super().__init__()
self.emb = nn.Embedding(vocab, 32)
self.rnn = nn.GRU(32, hidden, batch_first=True) # 第 2 讲 2.3
self.out = nn.Linear(hidden, vocab)
def forward(self, x, h=None):
z, h = self.rnn(self.emb(x), h)
return self.out(z), h # 每个时刻的下一 token logits
# 训练目标:交叉熵(分类损失,第 1 讲 1.4)
loss = nn.functional.cross_entropy(logits[:, :-1].reshape(-1, V), tokens[:, 1:].reshape(-1))
训练方式叫 teacher forcing:每个时刻用真实的上一 token 作为输入,预测下一个 token,与真值算交叉熵。
4. 生成:温度采样
生成时从初始片段开始,把模型输出的分布采样出下一个 token,再把采样的 token 作为下一步输入(自回归)。采样用温度 调节:
| 温度 | 分布形态 | 生成风格 |
|---|---|---|
| 趋近 one-hot(argmax) | 保守:重复、单调,但不易出错 | |
| softmax 原分布 | 均衡 | |
| 更平坦 | 冒险:多样、惊喜,也更容易跑调 |
低温度下模型只敢选概率最高的音符,于是容易陷入循环("总在同一个音上打转");高温度下给低概率音符更多机会,旋律更多变,但也可能蹦出训练数据里没出现过的怪组合。温度不是一个要"调对"的参数,而是一个风格旋钮。
5. 排错清单(训练不收敛时按序查)
- 数据编码:token→id 的映射有没有错位(差一位的 off-by-one 是序列任务最常见的 bug)?
- 损失配对:预测
logits[:, :-1]对真值tokens[:, 1:]——即“用 预测 “了吗? - 学习率:太大→损失震荡/NaN;太小→几千步不动。先试 与 。
- 隐状态:RNN 的 是否正确初始化、是否在 epoch 之间断开(detach)?
- 序列长度:截断太短→学不到长程模式;太长→显存爆。
6. 提交与自检
- 一张训练损失曲线(应单调下降);
- 一段续写样例(给定初始 8 个 token,续写 ≥32 个 token);
- 一段温度对比( 与 各生成一段),并用一两句话解释差异。
自检:能说清「前向 / 损失 / 反向 / 更新」在代码的哪几行;能解释为什么回归用 MSE、分类用交叉熵;能说明温度如何改变采样分布。做到这三条,本实验达标。
7. 代码 ↔ 讲次对照表
| 代码 | 对应结论 |
|---|---|
nn.Linear(1,64) → ReLU → Linear(64,1) | 1.2 多层感知机与通用逼近:非线性让深度有意义 |
loss_fn = nn.MSELoss() | 1.4 损失函数:回归用 MSE、分类用交叉熵 |
loss.backward() | 1.5 反向传播:链式法则的线性时间实现 |
opt.step() + zero_grad() | 1.6 优化器:Adam 的自适应步长;梯度默认累积 |
nn.GRU(32, 128) | 2.3 门控:门控与细胞/隐状态的作用 |
logits[:, :-1] 对 tokens[:, 1:] | 2.2 BPTT 与教师强制:用 预测 |
| 温度采样 | 2.8 应用导读: 是风格旋钮 |
8. 扩展任务(选做 1–2 项)
- 换结构对比:把 GRU 换成 LSTM、纯 RNN 与单层 Transformer(2.5),在同参数量下比收敛速度与生成质量——你会发现「结构选择」不是免费的。
- 采样策略对比:实现 top-k 与 top-p(核采样),与温度采样对照(2.8):哪种策略在「保持音乐性」上更稳?
- 序列长度实验:把 BPTT 的截断长度从 16 改到 64,观察损失与生成质量;再打开梯度裁剪(2.2),记录梯度范数曲线。
- 困惑度报告:计算验证集困惑度 (6.1),并解释它与损失的关系;对比不同温度的 PPL。
- 从零写一遍反向传播:用 numpy 手写这一层网络的 forward/backward,与 PyTorch 的
x.grad逐元素对照(误差 < 1e-6)——这是最有效的一次「1.5 验收测试」。