LSTM歌词生成:从字符建模到艺术家风格控制实践
简介基于 TensorFlow 构建的 LSTM 歌词生成项目能学习任意艺术家的歌词风格并生成新歌词适合对深度学习、自然语言处理与音乐 AI 感兴趣的 Python 开发者入手实践。项目完整覆盖歌词数据获取、文本清洗、词频统计、LSTM 模型定义、训练、种子词生成和样本保存等环节既有可运行的 Python 脚本也有 README 文档辅助上手能帮助读者理解 RNN 在序列文本生成中的实际用法。压缩包共 22 个文件主体是 14 个 py 脚本负责数据处理与模型调用的各个模块另有少量 AI 设计源文件、PNG 示意图、说明文档及开源协议文件整体体积仅 3.33MB结构紧凑且各目录职责清晰。已有 323 人学习浏览适合作为歌词生成类 NLP 项目的入门参考也可直接在其基础上替换数据集、调整超参数来适配其他艺术家风格。1. LSTM 生成歌词不是文字接龙先解决“学风格”还是“背歌词”跑 LSTM 生成歌词的人大多栽过同一个跟头训练几十轮后模型能“背诵”训练集里的原句换一个开头却立刻胡言乱语。其实这不怪 LSTM而是你把“按字符续写”和“按风格生成”两件事混在了一起。我一般做这类需求会先把目标拆成两个词法通顺靠 LSTM 的语言建模能力风格差异靠一列独立的艺术家特征向量artist embedding去“条件化”生成过程。只有这样生成新歌词才不是复读原词库而是模型在字符转移概率上真正押中了某个歌手的用词与断句习惯。这篇文章给出一个可直接复现的方案数据清洗、字符级序列化、带条件输入的 TensorFlow 模型、温度采样最后落到最小样本冷启动新风格。适合正在做文本生成、想从“能跑通”到“能控风格”的 Python 工程师。2. 把歌词变成训练数据字符集、序列化和艺术家条件注入2.1 为什么我用字符级建模而不是词级做歌词生成时词级模型听起来更合理——词是语义单位。但歌词文本有两个特点单首歌通常只有一两百到四五百词语料总量远小于新闻语料歌词的韵味很大程度来自韵脚、音节重复和断句这些特征在字符粒度上更容易被 LSTM 捕获。另一个现实因素是字符表很小一般英文歌词按小写字母加标点算字符表不到 100中文歌词按字建模也不会超过 4000。小词表意味着 Embedding 参数少、训练快、不容易过拟合这对一个可能只有几万首歌的数据集非常关键。词级模型虽然能保持更完整的语义但想让它学出“艺术家 A 喜欢用长句、艺术家 B 爱在句尾押韵”需要很大的语料去支撑。字符级模型正好相反序列长度不用太长就能覆盖足够的跨词上下文。我一般取 80 个字符作为输入长度对应英文歌词大约一行半中文大约一句半。这个长度下 LSTM 足以学到短语搭配又不会因为句子太长导致梯度传播困难。2.2 数据清洗去掉噪音比调模型参数更重要想生成“任何艺术家的风格”别急着训练。先看你的源数据长什么样。用公开的歌词数据集比如 Kaggle 上的 lyrics.csv 这类带 artist 字段的表格时常见的噪音有四种专辑附注、曲目序号、HTML 转义字符、以及大量重复的间奏词像“na na na”。这些噪音如果混进训练样本LSTM 学到的根本不是“风格”而是噪音的规律。我清洗时会保留原始换行并按行过滤——单行少于 3 个字符的直接丢弃多于 200 字符的行多半是歌词页面的页脚说明也一并去掉。大小写统一成小写标点只保留, . ! ? ( ) -这些常见符号。清洗后把同一艺术家的全部歌词拼成一个大字符串。拼的时候注意保留原有的换行符因为换行在歌词里代表断句是风格的一部分。每首歌中间加一个特殊的换行分隔标记防止模型学到“一首歌结束”后还继续跨歌续写。这一步做完你应该得到一张表一列是艺术家 ID一列是这个艺术家的全部歌词长字符串。2.3 用滑窗构造训练样本把艺术家 ID 变成条件输入现在把每个艺术家的长字符串切成长度为seq_len的输入块。滑窗步长我习惯取 2而不是 1。步长为 1 会让相邻样本大量重叠训练集膨胀好几倍但信息增量有限步长为 2 既保留足够样本量又让训练更快。每个样本由三部分组成输入字符序列、下一个字符作为标签、以及所属艺术家的 ID。艺术家 ID 不是文本的一部分而是作为额外的条件字段送入模型。下面这段代码把构造逻辑固定下来import numpy as np def build_samples(text, artist_id, char2idx, seq_len80, stride2): seqs, artists, targets [], [], [] ints [char2idx.get(c, 0) for c in text] if len(ints) seq_len: return seqs, artists, targets for i in range(0, len(ints) - seq_len - 1, stride): seqs.append(ints[i:i seq_len]) artists.append(artist_id) targets.append(ints[i seq_len]) return seqs, artists, targetschar2idx是把字符映射到整数索引的字典事先由全量字符集构建。artist_id 同样从 0 开始编号模型里会单独为它做一次 Embedding。这里有个容易忽略的点字符向量和艺术家向量是两个独立的 Embedding 表后面在特征维度上拼接。这样设计是为了让模型知道“同一个词在不同人口中应该有不同的延续倾向”而不是把风格信息混在字符向量里。构造好序列后用tf.data.Dataset.from_tensor_slices把它们做成 Dataset并设置shuffle和batch。shuffle 的 buffer 至少要大于一个艺术家的样本量才能真正打乱不同风格的样本顺序否则同一个 batch 里可能全是同一个歌手的歌词batch 内部梯度会有偏置。import tensorflow as tf dataset tf.data.Dataset.from_tensor_slices(({ chars: np.array(seqs), artist_id: np.array(artists) }, np.array(targets))) dataset dataset.shuffle(buffer_size10000).batch(128).prefetch(tf.data.AUTOTUNE)prefetch(AUTOTUNE)让 GPU 训练时数据准备和反向传播重叠执行这一步在数据量达到几十万样本时收益明显。batch 大小取 128对字符级模型来说是一个稳妥起点。如果你用的是 CPU 训练可以降到 64 缓解内存压力。3. 用 TensorFlow 定义带艺术家条件的 LSTM 生成模型3.1 模型结构字符 Embedding、艺术家 Embedding 与 LSTM 拼接模型的核心思路是双路输入一路是字符序列经过 Embedding 得到的稠密向量另一路是艺术家 ID 经过 Embedding 得到的风格向量。两路向量在时间步维度上拼接后送入 LSTM。这里的拼接位置有讲究把艺术家向量重复到每个时间步再拼接而不是只在第一个时间步拼接。原因是歌词的风格偏好不是只在起句时起作用而是每一处选字都受它影响。重复拼接会让 LSTM 在每个时间步都能同时看到“当前字符”和“当前是谁在写”。用 Keras 的函数式 API 实现如下from tensorflow.keras import layers, models char_vocab_size 128 # 字符表大小 artist_count 500 # 艺术家数量 seq_len 80 char_emb_dim 96 artist_emb_dim 24 lstm_units 256 char_input layers.Input(shape(seq_len,), namechars) artist_input layers.Input(shape(1,), nameartist_id) char_emb layers.Embedding(char_vocab_size, char_emb_dim)(char_input) artist_emb layers.Embedding(artist_count, artist_emb_dim)(artist_input) artist_emb layers.Flatten()(artist_emb) artist_emb layers.RepeatVector(seq_len)(artist_emb) merged layers.Concatenate(axis-1)([char_emb, artist_emb]) lstm_out layers.LSTM(lstm_units, return_sequencesFalse)(merged) dropout_out layers.Dropout(0.2)(lstm_out) out layers.Dense(char_vocab_size, activationsoftmax)(dropout_out) model models.Model(inputs[char_input, artist_input], outputsout)这里有几个参数值得展开。char_emb_dim96对字符级任务足够用盲目增大到 256 反而容易让 Embedding 层记住训练集中的固定搭配泛化到新文本时表现下降。artist_emb_dim24对应 500 个艺术家的场景每个艺术家浓缩成 24 维向量这维度表达“用词粗粝或细腻、押韵密集或松散”这种抽象特征已经够用艺术家越多这个维度可以适当调大到 32 或 48。LSTM 隐层 256 是一个平衡点低于 128 难以捕捉长距离依赖高于 512 在字符级任务上容易过拟合。注意return_sequencesFalse因为每个样本的目标只是下一个字符不需要输出整个时间步序列。3.2 损失函数为什么用 sparse_categorical_crossentropy下一个字符预测是一个多分类问题类别数等于字符表大小。标签是整数索引恰好对应sparse_categorical_crossentropy不需要做 one-hot 编码。这会省不少内存如果字符表有 100 个符号用 categorical_crossentropy 需要把标签扩展成 100 维的稠密矩阵而 sparse 版本直接用(batch,)的整数向量即可。模型最后一层的 softmax 输出(batch, char_vocab_size)配合from_logitsFalse使用计算上完全匹配。model.compile( optimizertf.keras.optimizers.AdamW(learning_rate1e-3, weight_decay1e-4), losstf.keras.losses.SparseCategoricalCrossentropy(), metrics[accuracy] )这里用 AdamW 而不是普通 Adam是最近做文本生成时的一个习惯给权重衰减一个固定系数可以有效抑制 LSTM 对训练集的死记硬背输出多样性比 Adam 略好。如果你的 TensorFlow 版本较老没有 AdamW直接换回 Adam 也完全可跑只是把learning_rate调到 1e-3 后训练步伐更大。3.3 训练回调按需保存模型而不是等训练结束再存长文本生成任务的训练周期通常在 20 到 40 个 epoch 才会收敛。手动盯中途结果很麻烦建议配三个回调。ModelCheckpoint只保存验证集 loss 最优的权重ReduceLROnPlateau在 loss 不降时把学习率减半避免后期震荡EarlyStopping设置耐心值 3连续三个 epoch 没有提升就停。这三个组合可以让训练过程基本不需要人工看管。callbacks [ tf.keras.callbacks.ModelCheckpoint(lyric_lstm_best.keras, monitorval_loss, save_best_onlyTrue), tf.keras.callbacks.ReduceLROnPlateau(monitorval_loss, factor0.5, patience2, min_lr1e-5), tf.keras.callbacks.EarlyStopping(monitorval_loss, patience3, restore_best_weightsTrue) ] model.fit(dataset, validation_dataval_dataset, epochs40, callbackscallbacks)save_best_onlyTrue会每轮写一次磁盘如果你的机器 I/O 较慢可以改成每 5 个 epoch 存一次写一个自定义回调控制频率即可。验证集最好按艺术家划分而不是随机划分否则同一首歌的相邻滑窗样本会被分到训练和验证两侧验证 loss 虚低。我在碰到验证 loss 和训练 loss 差距很小时反而会警惕那通常意味着模型已经把训练样本背下来了。4. 采样生成温度参数决定歌词像人话还是像乱码4.1 从模型输出到最终字符中间必须做温度缩放模型训练完成只是第一步。生成时如果每次都argmax(softmax(logits))模型会陷入两种常见病态循环输出同一个词或者反复念某句高频歌词。根因是 LSTM 对训练集中的高频片段形成了尖锐分布而语言本来需要一定随机性。解决方法是温度采样把 logits 除以一个温度系数 T。T 越大概率分布越平坦生成越多样T 越小越接近 argmax生成越保守。def sample_next_char(logits, temperature0.7, top_k40, top_p0.9): logits logits / max(temperature, 1e-8) if top_k 0: k min(top_k, logits.shape[-1]) kth tf.math.top_k(logits, k).values[..., -1:] mask logits kth logits tf.where(mask, tf.fill_like(logits, float(-inf)), logits) if top_p 1.0: sorted_logits tf.sort(logits, directionDESCENDING) cumprobs tf.cumsum(tf.nn.softmax(sorted_logits), axis-1) cutoff tf.cumsum(cumprobs top_p, axis-1) safe_mask tf.cast(cutoff 1, dtypetf.bool) mask tf.scatter_nd( indicestf.expand_dims(tf.argsort(logits, directionDESCENDING)[:, :safe_mask.shape[-1]], -1), updatestf.ones_like(tf.boolean_mask(sorted_logits, safe_mask)), shapetf.shape(logits) ) logits tf.where(tf.cast(mask, tf.bool), logits, tf.fill_like(logits, float(-inf))) probs tf.nn.softmax(logits) return tf.random.categorical(tf.math.log(probs)[None, :], num_samples1)[0, 0].numpy()逻辑说明先做温度缩放再做 top-k 截断最后做 top-p 过滤。top-k 把概率最低的若干候选直接排除top-p 在剩余候选中选出累计概率达到阈值的集合。两者合用的意义是既能防止低概率垃圾字符被随机选中又能给高概率候选内部保留多样性。tf.random.categorical接受的输入是 log 概率所以前面tf.math.log(probs)之后才采样。最终返回的是字符索引再去查idx2char就能得到字符。4.2 不同艺术家的生成参数不一样实践中我发现温度和艺术家风格存在一个规律用词偏向口语化、句子松散的歌手T 可以放到 0.9用词工整、押韵密集的歌手T 超过 0.75 就开始出现不通顺的跨词组合。所以别把温度当固定值每次换艺术家都试一组温度 T效果特征适用场景0.3 以下无重复但单调几乎复读高频模板调试模型验证是否记住训练集0.5 - 0.7有变化、大体通顺、偶有亮句大多数英文流行歌词的默认区间0.7 - 1.0创意强、易出现语法跳跃实验性 / 说唱类歌词1.0 以上随机性强、难以成句基本不推荐除非要生成无意义语料参数初始值我一般固定为top_k40、top_p0.9再根据结果微调 T。top_k 降到 20 会明显让歌词变“乖”同时会牺牲新颖表达top_p 升到 0.95 则相当于放宽候选范围。4.3 完整生成流程从一行前缀开始逐字回喂生成时不需要每次重新跑完整序列。给定一个前缀字符串先转成索引序列然后迭代执行当前序列 艺术家 ID 送入模型取最后一个时间步的 logits 做采样把采到的字符追加到序列尾部同时把序列头部弹出保持长度始终为seq_len。如果生成超过 500 字符还没有遇到换行符强制追加一个换行避免出现无限长单行。def generate_lyrics(model, prefix, artist_id, char2idx, idx2char, seq_len80, length800, temperature0.7): ints [char2idx[c] for c in prefix[-seq_len:]] generated list(prefix) for _ in range(length): padded_ints ints[-seq_len:] if len(padded_ints) seq_len: padded_ints [char2idx.get( , 0)] * (seq_len - len(padded_ints)) padded_ints seq_tensor tf.constant([padded_ints]) artist_tensor tf.constant([artist_id]) logits model.predict([seq_tensor, artist_tensor], verbose0)[0] next_idx sample_next_char(tf.constant(logits), temperature) next_char idx2char.get(next_idx, ) generated.append(next_char) ints.append(next_idx) return .join(generated)model.predict在循环里逐字符调用开销不小每 50 步打印一次进度即可。如果追求生成速度可以把输入改成 batch 形式一次生成多首歌同时推理吞吐率更高。这个函数返回的文本默认包含前缀本身方便对比不同艺术家对同一个开头的续写差异。5. 风格指纹与冷启动用最少数据让新艺术家进入模型5.1 把艺术家 Embedding 当作风格指纹来用训练完成后model.get_layer(embedding_1)里存的每一行就是一个艺术家的风格向量我习惯把它称为“风格指纹”。两个艺术家距离近说明他们的用词和断句习惯相近。这个距离可以量化验证自己的直觉。比如你随机挑两位歌手计算嵌入向量之间的余弦相似度artist_embeddings model.get_layer(embedding_1).get_weights()[0] def artist_similarity(a_id, b_id): va, vb artist_embeddings[a_id], artist_embeddings[b_id] return float(np.dot(va, vb) / (np.linalg.norm(va) * np.linalg.norm(vb) 1e-12))这个数值不只在分析层面有参考价值还能直接用于推断新艺术家。当你想加入一位训练集中不存在的歌手但没有足够数据重新训练全模型时找现有艺术家中与目标风格相似的前三名把它们的向量均值作为新艺术家的初始嵌入值能显著加快收敛。5.2 冷启动新风格的正确姿势是冻结主干只训嵌入只能拿到新艺术家几十首歌时标配做法不是重新训练整个 LSTM而是冻结字符 Embedding 和 LSTM 层只训练新加入的艺术家 Embedding。给模型扩展一位新艺术家需要把artist_count加一然后把模型复制一份新嵌入层初始化为风格相似艺术家的均值。其余层全部设为trainableFalse这样训练参数量从几十万骤降到几十几十首歌足够把风格向量训到位。base_model tf.keras.models.load_model(lyric_lstm_best.keras) for layer in base_model.layers: if embedding not in layer.name or layer.name embedding: # 只保留字符嵌入可训练 layer.trainable False new_artist_count artist_count 1 new_artist_emb layers.Embedding(new_artist_count, artist_emb_dim, namenew_artist_emb) # 把已有权重复制过来第 artist_count 行用最相似艺术家的嵌入初始化注意要冻结的是艺术家 ID 直接对应的那个 Embedding 反向不要更新得太快给它配单独的低学习率。一个直接的做法是新嵌入层设置trainableTrue其他层全部False然后用原始学习率的十分之一训练。这样迭代 2000 到 4000 步就能看到新风格有像模像样的输出。5.3 验证生成结果“像不像”用采样熵做客观指标主观听感之外可以用一个简单指标辅助判断字符级分布熵。对同一位艺术家按上述流程生成 20 段歌词统计每个字符位置上的分布熵值。如果熵值长期低于训练集整体熵值说明生成文本高度重复温度调低了或模型欠拟合如果高于正常范围说明生成太散、风格特征被稀释。把训练集本身的字符分布熵作为基线生成文本熵控制在基线的 1.1 到 1.3 倍之间歌词的可读性和多样性同时在线。这个方法对 LSTM 和之后替换成 Transformer 都能复用算是一个通用的风格评估兜底方案。本文还有配套的精品资源点击获取