Zero → \to → Hero : 1
实现了一个字符级中文语言模型,数据采用的是开源中文姓名数据集中的一部分,主要内容如下:
- 字符的预处理
- 统计频次
- 计算字符对频次矩阵
- 实现一个简单的先验概率模型
- 从训练数据中计算字符的先验概率
- 根据先验概率通过采样生成字符串
- 根据先验概率计算字符串的平均负对数似然度
- 优化语言模型,简单神经网络模型
- 模拟一个简单语言模型的预测过程
- 计算模型预测结果的负对数似然度
- 通过梯度优化模型的参数,最小化模型的负对数似然度
import torch
import matplotlib.pyplot as plt
import torch.nn.functional as F
from matplotlib.font_manager import FontProperties
font = FontProperties(fname='../chinese_pop.ttf', size=10)
数据集
数据是一个中文名数据集
- 名字最小长度为 2:
- 名字最大长度为 3:
words = open('../Chinese_Names_Corpus.txt', 'r').read().splitlines()
# 数据包含100多万个姓名,过滤出一个姓氏用来测试
names = [name for name in words if name[0] == '王' and len(name) == 3]
len(names)
52127
names[:10]
['王阿宝', '王阿炳', '王阿成', '王阿达', '王阿大', '王阿迪', '王阿多', '王阿芳', '王阿凤', '王阿福']
字符对统计:
把数据拆分为简单的上下文对,并统计在数据集中出现的频次:
b = {}
for w in names:
chs = ['.'] + list(w) + ['.']
for ch1, ch2 in zip(chs, chs[1:]):
bigram = (ch1, ch2)
b[bigram] = b.get(bigram, 0) + 1
# top 100
sorted(b.items(), key = lambda kv: -kv[1])[:10]
[(('.', '王'), 52127),
(('王', '文'), 599),
(('华', '.'), 555),
(('王', '晓'), 538),
(('王', '子'), 500),
(('平', '.'), 481),
(('明', '.'), 472),
(('文', '.'), 450),
(('林', '.'), 432),
(('王', '建'), 431)]
从统计结果可以看到,以(‘平’, ‘明’, ‘文’,‘林’)结尾的名字最多,因为数据只包含王姓,所以('.', '王')
频次最高。
构建词汇表到索引,索引到词汇表的映射,词汇表大小为:1561(加上开始和结束填充字符):
chars = sorted(list(set(''.join(names))))
char2i = {s:i+1 for i,s in enumerate(chars)}
char2i['.'] = 0 # 填充字符
i2char = {i:s for s,i in char2i.items()}
len(chars)
1650
计算字符频次矩阵:
N = torch.zeros((1651, 1651), dtype=torch.int32)
for w in names:
chs = ['.'] + list(w) + ['.']
for ch1, ch2 in zip(chs, chs[1:]):
ix1 = char2i[ch1]
ix2 = char2i[ch2]
N[ix1, ix2] += 1
部分字符组合和可视化:
- 行和列是全部的字符共1650+1(‘.’)
- 每行统计数据中,所有两两字符组合的频次
plt.figure(figsize=(20, 20))
plt.imshow(N[:27,:54], cmap='Blues')
for i in range(27):
for j in range(54):
chstr = i2char[i] + i2char[j]
plt.text(j, i, chstr, ha="center", va="bottom", color='gray', fontproperties=font)
plt.text(j, i, N[i, j].item(), ha="center", va="top", color='gray')
plt.axis('off')
构建简单的语言模型
根据词频矩阵对频次进行行方向的归一化,得到先验概率矩阵,根据概率矩阵预测下一个词,过程如下:
- 根据矩阵的第一行,选择概率最高的词组(
.x
),(.王)
被选中的概率最高。 - 然后根据
王
对应的索引,查询矩阵的对应行,选择(王x)
概率最高的组合。 - 如此这般,…直到遇到
(x.)
或者字符达到最大长度结束。
第一行是.
和所有字符的组合的概率分布,由于数据是过滤了全部的王姓,所以王
的概率是最高的,这个结果是正常的。
# 计算每个字符组合的先验概率
p = N[0].float()
p = p / p.sum()
# 根据先验概率采样
g = torch.Generator().manual_seed(2147483647)
ix = torch.multinomial(p, num_samples=1, replacement=True, generator=g).item()
i2char[ix]
'王'
下面计算先验概率矩阵,根据先验概率生成10个名字:
# 字符对儿的先验概率矩阵
P = (N+1).float()
P /= P.sum(1, keepdims=True)
P.shape
torch.Size([1651, 1651])
g = torch.Generator().manual_seed(2147483647)
for i in range(10):
out = []
ix = 0
while True:
p = P[ix]
ix = torch.multinomial(p, num_samples=1, replacement=True, generator=g).item()
out.append(i2char[ix])
if ix == 0 or len(out) == 3:
break
print(''.join(out))
王静修
王映凤
王定罗
戈佶次
王安.
王永仰
王娆拥
王牡右
王友缨
王梓集
以上就是最简单的概率模型,通过查表的方式生成名字字符串,生成过程都是基于可观察数据中的真实概率。
概率模型的量化评估
如何量化概率模型的生成结果?
答案是:likelihood,也叫做可能性或似然度,计算方式简单就是把所有词的概率相乘即可。例如,王阿宝:
l i k e l i h o o d = p ( . 王 ) × p ( 王阿 ) × p ( 阿宝 ) × p ( 宝 . ) likelihood = p(.王)\times p(王阿)\times p(阿宝) \times p(宝.) likelihood=p(.王)×p(王阿)×p(阿宝)×p(宝.)
但是这会出现一个问题,就是概率都是小于1的值,只会越乘越小,所以可以计算log_likelihood,更利于观察:
l o g l i k e l i h o o d = l o g ( p ( . 王 ) ) + l o g ( p ( 王阿 ) ) + l o g ( p ( 阿宝 ) ) + l o g ( p ( 宝 . ) ) {log}_{likelihood} = log(p(.王)) + log(p(王阿)) + log(p(阿宝)) + log(p(宝.)) loglikelihood=log(p(.王))+log(p(王阿))+log(p(阿宝))+log(p(宝.))
对数变换后,在增加一些常用的变换,取负值和计算平均似然度:
M e a n N e g a t i v e L o g L i k e l i h o o d = − l o g l i k e l i h o o d n = ( l o g ( p ( . 王 ) ) + l o g ( p ( 王阿 ) ) + l o g ( p ( 阿宝 ) ) + l o g ( p ( 宝 . ) ) b i g ) ∗ − 1 n MeanNegativeLogLikelihood = \frac{-{log}_{likelihood}}{n} = \frac{\big(log(p(.王)) + log(p(王阿)) + log(p(阿宝)) + log(p(宝.))\\big)*-1}{n} MeanNegativeLogLikelihood=n−loglikelihood=n(log(p(.王))+log(p(王阿))+log(p(阿宝))+log(p(宝.))big)∗−1
这意味着,如果生成的结果越好,平均负对数似然度就应该越小,反之越大。
下面计算部分模型生成结果的MNLL=MeanNegativeLogLikelihood:
# 计算字符串的对数似然度
log_likelihood = 0.0
n = 0
for w in ["王静修","王映凤","戈佶次"]:
chs = ['.'] + list(w) + ['.']
for ch1, ch2 in zip(chs, chs[1:]):
ix1 = char2i[ch1]
ix2 = char2i[ch2]
prob = P[ix1, ix2]
logprob = torch.log(prob)
log_likelihood += logprob
n += 1
print(f'{ch1}{ch2}: {prob:.4f} {logprob:.4f}')
print(f'{log_likelihood=}')
print('negative log likelihood =',-log_likelihood)
mean_nll = -log_likelihood/n
print(f'{mean_nll=}')
.王: 0.9693 -0.0312
王静: 0.0028 -5.8954
静修: 0.0005 -7.5648
修.: 0.0143 -4.2474
.王: 0.9693 -0.0312
王映: 0.0005 -7.5968
映凤: 0.0006 -7.4260
凤.: 0.1019 -2.2840
.戈: 0.0000 -10.8926
戈佶: 0.0006 -7.4146
佶次: 0.0006 -7.4097
次.: 0.0006 -7.4103
log_likelihood=tensor(-68.2039)
negative log likelihood = tensor(68.2039)
mean_nll=tensor(5.6837)
下面根据先验概率计算出整个数据的MNLL:
# 计算全部样本的平均负对数似然度
log_likelihood = 0.0
n = 0
for w in names:
chs = ['.'] + list(w) + ['.']
for ch1, ch2 in zip(chs, chs[1:]):
ix1 = char2i[ch1]
ix2 = char2i[ch2]
prob = P[ix1, ix2]
logprob = torch.log(prob)
log_likelihood += logprob
n += 1
nll = -log_likelihood
mean_nll = nll/n
print(f'{mean_nll=}')
mean_nll=tensor(3.9716)
下面随机测试一些名字:
# 计算字符串的对数似然度
log_likelihood = 0.0
n = 0
for w in ["一乃乃","乃乃乃","丁丫丫"]:
chs = ['.'] + list(w) + ['.']
for ch1, ch2 in zip(chs, chs[1:]):
ix1 = char2i[ch1]
ix2 = char2i[ch2]
prob = P[ix1, ix2]
logprob = torch.log(prob)
log_likelihood += logprob
n += 1
print(f'{ch1}{ch2}: {prob:.4f} {logprob:.4f}')
print(f'{log_likelihood=}')
print('negative log likelihood =',-log_likelihood)
mean_nll = -log_likelihood/n
print(f'{mean_nll=}')
.一: 0.0000 -10.8926
一乃: 0.0005 -7.6406
乃乃: 0.0006 -7.4559
乃.: 0.0006 -7.4559
.乃: 0.0000 -10.8926
乃乃: 0.0006 -7.4559
乃乃: 0.0006 -7.4559
乃.: 0.0006 -7.4559
.丁: 0.0000 -10.8926
丁丫: 0.0006 -7.4236
丫丫: 0.0006 -7.4103
丫.: 0.0012 -6.7172
log_likelihood=tensor(-99.1490)
negative log likelihood = tensor(99.1490)
mean_nll=tensor(8.2624)
通过上面的测试结果,可以发现数据中真实正常的名字MNLL = 3.97,根据先验概率生成的结果MNLL = 5.68,随机编的奇葩名字MNLL = 8.26。这也验证生成效果越差,MNLL值越大。
优化模型:
接下就是通过神经网络模型来替代之前的简单模型,通过找到一组参数可以实现以下目标:
- 最大化生成结果的对数似然度 = = = 最小化负对数似然度 = = = 最小化平均负对数似然度,生成更加符合数据分布的结果。
x n ∗ 1651 × W 1651 ∗ 1651 = l o g i t s n ∗ 1651 x_{n*1651} \times W_{1651*1651} = {logits}_{n*1651} xn∗1651×W1651∗1651=logitsn∗1651
M N L L = − ∑ i = 1 n l o g ( f p ( l o g i t s ) [ i ] ) n MNLL = \frac{-\sum_{i=1}^{n} log(f_p(logits)[i])}{n} MNLL=n−∑i=1nlog(fp(logits)[i])
- W W W : 模型的参数矩阵
- 1651 : 词汇表的大小,也是输入向量化后的长度
- n :输入输出序列的长度
- f p f_p fp : 转换函数把logits缩放为0~1之间概率值
- MNLL : 平均负对数似然度
创建训练数据:(x,y)
xs, ys = [], []
for w in names[:1]:
chs = ['.'] + list(w) + ['.']
for ch1, ch2 in zip(chs, chs[1:]):
ix1 = char2i[ch1]
ix2 = char2i[ch2]
print(ch1, ch2)
xs.append(ix1)
ys.append(ix2)
xs = torch.tensor(xs)
ys = torch.tensor(ys)
. 王
王 阿
阿 宝
宝 .
xs(char) | ys(char) | xs(index) | ys(index) |
---|---|---|---|
. | 王 | 0 | 993 |
王 | 阿 | 993 | 1539 |
阿 | 宝 | 1539 | 409 |
宝 | . | 409 | 0 |
下面我们模拟神经网络模型的推理过程,简单起见,模型只有一个权重矩阵(输入 → \to →输出),计算模型输出:
- 对输入进行向量化,one-hot编吗:
xenc = F.one_hot(xs, num_classes=1651).float()
xenc
tensor([[1., 0., 0., ..., 0., 0., 0.],
[0., 0., 0., ..., 0., 0., 0.],
[0., 0., 0., ..., 0., 0., 0.],
[0., 0., 0., ..., 0., 0., 0.]])
模型输入向量的可视化:
- 随机初始化的输入到输出之间的权重矩阵 W W W,计算模型的输出:预测ys的概率分布,即xs每个词对应下个词的概率分布。
W = torch.randn((1651, 1651))
W.shape
torch.Size([1651, 1651])
权重矩阵的可视化:
- 计算模型的输出:矩阵乘法
logits = xenc @ W # log-counts
counts = logits.exp() # equivalent N
probs = counts / counts.sum(1, keepdims=True) # scale to 0~1
probs
tensor([[2.6660e-04, 3.3742e-03, 1.3357e-03, ..., 3.4182e-04, 2.3816e-04,
3.4352e-04],
[2.1228e-04, 1.5516e-03, 5.3819e-05, ..., 1.5116e-03, 1.8045e-04,
1.7484e-04],
[7.3022e-04, 8.9569e-04, 1.9453e-04, ..., 1.0783e-04, 2.8304e-03,
9.5034e-05],
[2.9773e-04, 6.6036e-04, 1.1865e-03, ..., 1.6132e-04, 1.7862e-04,
4.8288e-04]])
模型输出的可视化:
- 下面根据最终的输出,每一行概率最大元素对应的索引即模型的预测值:
for i in torch.argmax(probs, axis=1):
print(i.item(),i2char[i.item()])
1316 萱
30 乐
869 济
573 惜
- 计算随机模型预测结果的MNLL:
xs,ys
(tensor([ 0, 993, 1539, 409]), tensor([ 993, 1539, 409, 0]))
nlls = torch.zeros(4)
for i in range(4):
# i-th bigram:
x = xs[i].item() # input character index
y = ys[i].item() # label character index
print('--------')
print(f'example {i+1}: {i2char[x]}{i2char[y]} (indexes {x},{y})')
print('input to the neural net:', x)
print('label (next character):', y)
p = probs[i, y]
print('probability assigned by net:', p.item())
logp = torch.log(p)
print('log likelihood:', logp.item())
nll = -logp
print('negative log likelihood:', nll.item())
nlls[i] = nll
print('=========')
print('average negative log likelihood, i.e. loss =', nlls.mean().item())
--------
example 1: .王 (indexes 0,993)
input to the neural net: 0
label (next character): 993
probability assigned by net: 0.00024021849094424397
log likelihood: -8.333961486816406
negative log likelihood: 8.333961486816406
--------
example 2: 王阿 (indexes 993,1539)
input to the neural net: 993
label (next character): 1539
probability assigned by net: 0.0004108154389541596
log likelihood: -7.797366619110107
negative log likelihood: 7.797366619110107
--------
example 3: 阿宝 (indexes 1539,409)
input to the neural net: 1539
label (next character): 409
probability assigned by net: 0.002926822518929839
log likelihood: -5.833837985992432
negative log likelihood: 5.833837985992432
--------
example 4: 宝. (indexes 409,0)
input to the neural net: 409
label (next character): 0
probability assigned by net: 0.0002977268013637513
log likelihood: -8.11933422088623
negative log likelihood: 8.11933422088623
=========
average negative log likelihood, i.e. loss = 7.521124839782715
对比之前的测试结果,随机初始化的模型预测结果和随机生成的名字差不多。
梯度下降
通过MNLL(loss)对参数 W W W求导计算梯度,根据梯度更新模型。
# 使用全量数据
xs, ys = [], []
for w in names:
chs = ['.'] + list(w) + ['.']
for ch1, ch2 in zip(chs, chs[1:]):
ix1 = char2i[ch1]
ix2 = char2i[ch2]
xs.append(ix1)
ys.append(ix2)
xs = torch.tensor(xs)
ys = torch.tensor(ys)
num = xs.nelement()
print('number of examples: ', num)
number of examples: 208508
# 随机初始化
g = torch.Generator().manual_seed(202304191424)
W = torch.randn((1651, 1651), generator=g, requires_grad=True)
# gradient descent
history = {'loss':[]}
for k in range(100):
# forward pass
xenc = F.one_hot(xs, num_classes=1651).float() # one-hot encoding
logits = xenc @ W # predict log-counts
counts = logits.exp() # counts, equivalent to N
probs = counts / counts.sum(1, keepdims=True) # probabilities for next character
loss = -probs[torch.arange(num), ys].log().mean() + 0.01*(W**2).mean() # Add a regular
#print(loss.item())
history['loss'].append(loss.item())
# backward pass
W.grad = None # set to zero the gradient
loss.backward()
# update
W.data += -300 * W.grad
plt.figure(figsize=(8,3))
plt.plot(history['loss'], label='NN model')
plt.axhline(y=3.9716, c='red')
plt.grid()
经过100多轮的训练,模型的预测结果已经达到了真实数据的水平,下面是测试结果:
# finally, sample from the 'neural net' model
g = torch.Generator().manual_seed(202304191533)
for i in range(10):
out = []
ix = 0
while True:
xenc = F.one_hot(torch.tensor([ix]), num_classes=1651).float()
logits = xenc @ W # predict log-counts
counts = logits.exp() # counts, equivalent to N
p = counts / counts.sum(1, keepdims=True) # probabilities for next character
ix = torch.multinomial(p, num_samples=1, replacement=True, generator=g).item()
out.append(i2char[ix])
if ix == 0 or len(out) == 3:
break
print(''.join(out))
王骏杭
王桂普
王晓薪
王亦麒
王英.
王长萍
王丙能
王中.
王占郢
王光皓