從零開始用PyTorch構(gòu)建最小的語言模型(還能生成寶可夢名字!)
所以我开始琢磨给我的猫起一些像宝可梦那样的名字变体——试图让它听起来既独特又略带神秘。试过一些名字,比如“Flarefluff”和“Nimblepawchu”之后,我突然想到:为什么不干脆用一个字符级别的语言模型来搞定这件事呢?这似乎是一个完美的小项目,就这么定了。还有什么比用一个自定义的宝可梦名字生成器更酷的方式来研究字符级别模型呢?
在大型语言模型(LLM)和生成式人工智能的复杂性之下,其实有一个相当简单的核心理念:预测下一个字符或单词。 真的就这么简单!从对话机器人到创意写作,所有令人惊叹的模型,归根结底,都是看它们预测接下来内容的能力有多强。LLM的“魔力”在于它们如何不断提升和扩展这种预测能力。所以,让我们拨开层层迷雾,直达核心。
我们不会在这个指南中构建拥有数百万参数的庞大模型。相反,我们将创建一个字符级别的语言模型。这里有个有意思的地方:我们的数据集只有801个宝可梦名称,非常迷你!最终,你会理解语言模型的基础知识,并拥有了自己的迷你宝可梦名称生成器。
每个步骤的结构如下,帮助你跟着一起完成
- 目标:快速了解我们要达成的目标。
- 直观想法:核心概念——无需编写代码。
- 代码:逐步实现的 PyTorch 代码。
- 代码解释:详细解释代码,使其更易理解。
如果你只是想理解概念,可以直接跳过代码——你仍然可以理解整体思路。理解这些概念不需要任何编程经验。但如果你愿意,深入研究代码会很有帮助,所以我建议你试试看!
从字符到名字的探索想象一下,你一个字母一个字母地猜一个词,每个字母都给你一点线索,告诉你可能接下来会是什么。当你看到“Pi”时,你可能会想到“Pikachu”,因为在宝可梦的世界里,“ka”经常跟在“Pi”后面。这就是我们要教给模型的直觉方式,我们让它一个字符一个字符地接收宝可梦的名字。随着时间的推移,模型会学会这种命名风格的规律,帮助它生成听起来像宝可梦的新名字,让它们感觉真实。
准备好了吗?让我们从零开始用PyTorch建起来。
第一步:教模型它的第一个“基础”知识 目标是:定义模型可以使用的字符集,并为每个字符分配一个独一无二的编号,称之为“字母表”。
直觉是:目前,我们的模型对语言、名字甚至字母一无所知。对于它来说,文字只是未知符号的序列。关键是:这是不可改变的事实!因此,要让模型能够理解这些数据,我们需要给每个字符都分配一个独特的数字。
在这一步中,我们构建模型的“字母表”通过识别宝可梦名称数据集中每个唯一的字符。这将包括所有字母和一个表示名称结束的特殊标记。每个字符都将配对一个唯一的标识符,即一个数字,让模型能够以自己的方式理解每个符号。这为模型提供了创建宝可梦名称的基本“构建块”,并帮助它开始学习哪些字符通常会跟随其他字符。
有了这些数字ID后,我们正在为模型打下基础,让它从零开始理解宝可梦名字中的字符序列。
import pandas as pd
import torch
import string
import numpy as np
import re
import torch.nn.functional as F
import matplotlib.pyplot as plt
data = pd.read_csv('pokemon.csv')["name"]
words = data.to_list()
print(words[:8])
#['bulbasaur', 'ivysaur', 'venusaur', 'charmander', 'charmeleon', 'charizard', 'squirtle', 'wartortle']
# 现在我们构建字符集
chars = sorted(list(set(' '.join(words))))
stoi = {s:i+1 for i,s in enumerate(chars)}
stoi['.'] = 0 # 句点用作单词结束标志
itos = {i:s for s,i in stoi.items()}
print(stoi)
#{' ': 1, 'a': 2, 'b': 3, 'c': 4, 'd': 5, 'e': 6, 'f': 7, 'g': 8, 'h': 9, 'i': 10, 'j': 11, 'k': 12, 'l': 13, 'm': 14, 'n': 15, 'o': 16, 'p': 17, 'q': 18, 'r': 19, 's': 20, 't': 21, 'u': 22, 'v': 23, 'w': 24, 'x': 25, 'y': 26, 'z': 27, '.': 0}
print(itos)
#{1: ' ', 2: 'a', 3: 'b', 4: 'c', 5: 'd', 6: 'e', 7: 'f', 8: 'g', 9: 'h', 10: 'i', 11: 'j', 12: 'k', 13: 'l', 14: 'm', 15: 'n', 16: 'o', 17: 'p', 18: 'q', 19: 'r', 20: 's', 21: 't', 22: 'u', 23: 'v', 24: 'w', 25: 'x', 26: 'y', 27: 'z', 0: '.'}
代码解析:
- 我们创建了
stoi
,将每个字符映射到一个唯一的整数。 itos
字典反转了这种映射,使我们可以将数字转换回字符。- 我们还包括一个特殊的结束字符(
.
),用于标记每个宝可梦名字的结尾。
让模型根据前面字符的上下文来预测下一个字符。
直觉:在这里,我们通过一个游戏来教模型:猜下一个字母!模型将尝试预测每个名字中每个字符后面的字母。例如,当它看到“Pi”时,它可能会猜“k”作为接下来的字母,就像“Pikachu”这个名字一样。我们将每个名字转换为序列,其中每个字符都指向它后面的字符。随着时间推移,模型将开始识别出宝可梦名字中的常见模式。
我们还会在每个名字后面加上一个特殊的结束符,让模型知道这个名字结束了。
N元字符。来源:作者的图片
这个例子展示了我们如何用固定上下文长度为3来预测序列中的每个下一个字符。当模型读取每个字符时,它只记得最后三个字符作为上下文来做下一个预测。这种滑动窗口的方法有助于捕捉短期依赖性,但你可以试着用更短或更长的上下文长度,看看这会如何影响预测。
block_size = 3 # 上下文大小
def build_dataset(words):
X, Y = [], []
for w in words:
context = [0] * block_size # 初始化为空白上下文
for ch in w + '.':
ix = stoi[ch]
X.append(context)
Y.append(ix)
context = context[1:] + [ix] # 移位并将其与新字符拼接
return torch.tensor(X), torch.tensor(Y)
X, Y = build_dataset(words[:int(0.8 * len(words))]) # 取前80%的单词
print(X.shape, Y.shape) # 输出训练数据的形状
代码解释:
- 设置上下文长度为
block_size = 3
:定义了用于预测下一个字符的前导字符数量为3。 - 创建
build_dataset
函数:此函数从单词列表中准备X
(上下文序列)和Y
(下一个字符的索引)。 - 初始化并更新上下文:每个单词都从空白上下文
[0, 0, 0]
开始。随着字符被处理,上下文向前移动,以保持3个字符的长度。 - 存储输入-输出对:每个上下文(在
X
中)与下一个字符(在Y
中)配对,构建模型训练的数据集。 - 转换并检查数据:将
X
和Y
转换为张量,为训练做准备,并检查它们的维度。此时,数据集捕捉了字符序列中的模式,用于生成新的名字。
通过预测每个字符后面的那个字符,并根据预测的准确度来调整权重,来训练这个模型。
直觉 :这里就有趣了!我们将创建一个简单的三层结构,这些层次协同工作,每次根据前三个字母来预测下一个字母。就像在猜单词游戏中猜字母一样:每次模型猜错时,它都会从错误中学习并进行调整,每次尝试都会变得更好。
当它使用真实的宝可梦名称练习时,它逐渐学会了让这些名称独具特色的风格和模式。最终,经过多次练习后,它能够想出有同样宝可梦风格的新名字!
# 初始化参数,这些参数包括嵌入矩阵C、权重W1、偏置b1、权重W2和偏置b2
g = torch.Generator()
C = torch.randn((27, 10), generator=g)
W1 = torch.randn((30, 200), generator=g)
b1 = torch.randn(200, generator=g)
W2 = torch.randn((200, 27), generator=g)
b2 = torch.randn(27, generator=g)
parameters = [C, W1, b1, W2, b2]
for p in parameters:
p.requires_grad = True
# 进行100,000次迭代以更新参数
for i in range(100000):
# 随机选择32个样本索引
ix = torch.randint(0, X.shape[0], (32,))
# 获取输入样本的嵌入表示
emb = C[X[ix]]
# 计算隐藏层输出
h = torch.tanh(emb.view(-1, 30) @ W1 + b1)
# 计算最终的logits值
logits = h @ W2 + b2
# 计算当前批次的交叉熵损失
loss = F.cross_entropy(logits, Y[ix])
# 重置参数的梯度
for p in parameters:
p.grad = None
loss.backward()
# 根据梯度更新参数
for p in parameters:
p.data -= 0.1 * p.grad
解释代码。
- 我们为嵌入层(
C
)和两个线性层(W1
和W2
)随机初始化权重和偏置。 - 每个参数被设置为
requires_grad=True
,从而调整这些参数以最小化预测误差。 - 我们从训练数据(
Xtr
)中随机选取一个包含32个样本的小批量数据,这使模型能更高效地通过同时处理多个样本进行优化。 - 对于每个批量数据,我们将嵌入层传递到隐藏层(
W1
),使用tanh
激活函数,计算输出的对数几率。 - 使用交叉熵损失,模型在每一步中学习减少误差并提高预测准确性。
训练模型。来源:作者
第四步:计算下一个字符出现的概率 目的 :根据输入的序列和模型学习到的概率,逐个字符地预测,以生成新的宝可梦名字。
直觉是:在训练过程中,它调整了权重以捕捉每个字符跟随另一个字符的概率,特别是在宝可梦的名字中。现在,通过这些学到的权重(W1
,W2
,b1
,b2
),我们可以通过一个接一个地“预测”字符来生成全新的名字。接下来,让模型“猜测”在给定序列(如“pik”)之后可能出现的下一个字符。
模型并不能直接理解字母,因此输入的字符会被转换成代表每个字符的数字。这些数字会被填充以匹配所需的输入大小,输入到模型的“层”中。这些层就像是训练过的筛选器,能预测每个字符后面通常会跟着什么字符。经过这些层处理后,模型会提供一个概率列表,其中每个可能的下一个字符都有对应的可能性。这些概率是基于模型从宝可梦名称数据集中学习到的信息计算出来的,因此我们得到了一个加权的潜在字符列表,按照可能性排序。
来源:作者
在上面的例子中,你可以看到,‘a’和‘i’这两个字符更可能出现在‘pik’之后。
input_chars = "pik" # 输入示例,用于获取下一个字符的概率
# 将输入字符转换为索引,基于字符到索引的映射(stoi)
context = [stoi.get(char, 0) for char in input_chars][-block_size:] # 确保上下文符合块大小
context = [0] * (block_size - len(context)) + context # 如果长度不足,则在前面填充
# 将当前上下文嵌入
emb = C[torch.tensor([context])]
# 通过网络层进行处理
h = torch.tanh(emb.view(1, -1) @ W1 + b1)
logits = h @ W2 + b2
# 计算并输出概率
probs = F.softmax(logits, dim=1).squeeze() # 通过挤压移除多余的维度
# 输出每个字符对应的概率
next_char_probs = {itos[i]: probs[i].item() for i in range(len(probs))}
代码说明(请看下面):
- 我们将
context
索引转换成嵌入表示,这可以作为数值格式输入到模型层。 - 我们用模型的层来转换这个嵌入上下文。隐藏层 (
h
) 处理它,输出层 (logits
) 则计算每个可能字符的得分。 - 最后,我们对
logits
应用 softmax 函数处理,得出一个概率列表。这个概率分布存储在next_char_probs
中,每个字符的概率都与可能性相对应。
使用第4步中的概率,我们希望通过依次选择每个字符来生成一个完整的名字,直到遇到特殊的“结束标记”。
直觉:模型从宝可梦的名字中学习了典型的字符序列模式,现在通过根据概率来“猜测”每个后续字母来应用这一知识。它会这样不断选择字符,直到它感觉这个名字已经完成。其中一些名字完美贴合宝可梦的风格,而其他名字则可能更加随性——捕捉到了这种创意的不可预测性,这是生成模型所吸引的。这里是由我们的模型生成的一些名字:
- 德韦布尔
- 西米库
- 巴特里尔
- 普比
- 多恩姆
- 布尔
- 索拉
- 帕特兰
- 喵
- 奥马克
- 蠕蚁
- 布内
- 吉利萨
- 涡力克丝
- 海德罗
- 奥丁贾
- 迪格勒
- 斯克内昂
context = [0] * block_size # 初始化 context 为长度为 block_size 的列表,初始值为 0
for _ in range(20): # 循环20次
out = [] # 定义输出列表
while True: # 无限循环
emb = C[torch.tensor([context])] # 将 context 转换为张量并获取嵌入
h = torch.tanh(emb.view(1, -1) @ W1 + b1) # 计算中间层的激活值
logits = h @ W2 + b2 # 计算未标准化的概率
probs = F.softmax(logits, dim=1) # 计算概率分布
ix = torch.multinomial(probs, num_samples=1, generator=g).item() # 根据概率分布生成下一个token的索引
context = context[1:] + [ix] # 更新 context 列表
out.append(ix) # 将生成的 token 添加到输出列表
if ix == 0: # 如果生成的 token 为 0,表示结束
break # 退出循环
print(''.join(itos[i] for i in out)) # 打印生成的 token 序列
代码解释:
- 对 logit 使用 softmax,我们可以为每个字符得到概率。
torch.multinomial
根据这些概率选择一个字符,为生成的名字增添多样性和真实感。
就是这样!试试用你的名字开头,看看模型会把它变成什么宝可梦风格的名字。
一个未来的改进计划这个模型提供了一种生成类似宝可梦名字的字符级文本的基本方法,但它还远未达到可以投入生产的程度。为了专注于培养直觉,我故意简化了以下几个方面,计划在下一篇中进一步展开这些概念。
- 动态学习率:我们当前的训练设置使用固定的0.1学习率,这可能限制了收敛速度和效果。尝试使用动态学习率(例如,随着模型性能提升而降低学习率)可能会提高收敛速度并获得更好的最终准确率。
- 防止过拟合:由于数据集只有801个宝可梦名字,模型可能会开始记忆模式而不是泛化能力。我们可以通过引入dropout或L2正则化等技术来减少过拟合,使模型更好地泛化到未见过的序列。
- 扩展上下文长度:目前,固定的
block_size
可能限制了模型捕捉长序列依赖性的能力。增加上下文长度可以使模型更好地理解长序列中的模式,生成更复杂和细致的名字。 - 更大规模的数据集:由于数据集较小,模型的泛化能力和生成多样化名字的能力受到了限制。训练更大规模的数据集,可能包括来自不同来源的更多虚构名字,有助于它学习更广泛的命名规范并提高其创造范围。
- 调整温度:尝试调整温度设置,这可以控制模型预测的随机性程度。较低的温度会使模型更保守,选择最有可能的下一个字符,而较高的温度鼓励创造性,允许更多变化和意外的选择。微调这个参数可以帮助在生成可预测和独特的宝可梦名字之间取得平衡。
这是最简单的字符级语言模型之一,也是很好的起点。通过增加更多的层、使用更大的数据集,或增加上下文长度,你可以改进模型,生成更多创意的名字。但不要停下!试着输入一组不同的名字——比如龙、精灵或其他神话生物——看看它如何捕捉这些氛围。只需稍微调整,这个模型就能成为你生成奇幻世界名字的首选工具。祝训练顺利,愿你的创作既宏伟又迷人!
完整的源代码和Jupyter Notebook都可以在GitHub存储库中找到。如果您有关于改进的想法或任何其他意见,请随时联系我们。
参考:
宝可梦情侣 GIF,来自 giphy
共同學(xué)習(xí),寫下你的評論
評論加載中...
作者其他優(yōu)質(zhì)文章