武辰作品索引

知乎作品库

文章 · 2

知乎原文

大模型里的Temperature的六个玩法

我结合自己的实战经验,跟大家讨论Temperature的几种用处。

首先需要搞清楚温度是啥:可以想象成是一个“想象力旋钮”。

  • 温度 = 0:模型变成超级保守的“学霸”,每一步都选分数最高的那个词。
  • 温度 = 1:这是标准模式,模型按概率正常发挥。
  • 温度 > 1:模型就嗨了,开始“脑洞大开”,可能会选一些概率不高但很有意思的词,输出变得天马行空。

理解了这点,咱们来看具体怎么用它。

玩法一:基础操作,按场景调参

这个最简单,但也是基本功。

  • 严肃场合,温度调低:比如法律咨询、医疗问答、生成合同条款。如果想让模型少一点随机发挥,可以把温度调低,比如设在 0 到 0.3。法律、医疗这类内容,事实和条款照样要核对。
  • 创意场合,温度调高:比如写小说、想广告slogan、构思短视频脚本。这时候把温度调到0.8~1.5,甚至更高,鼓励模型“放飞自我”。

玩法二:产品心法——给用户“后悔药”

这是我在做AI文案生成工具时的一个小心机。我们产品的交互是这样设计的:

  1. 用户输入需求,第一次生成:我们默认用低温(比如0.2)。目的是先给用户一个至少80分的、靠谱的基准答案。这符合用户体验,第一印象不能差。
  2. 用户点了“重新生成”:这说明他对首版不满意,想要点不一样的。这时候,我们悄悄把温度拉到1.5。这下,模型就会给出一个风格、措辞完全不同的新版本,往往能给用户带来惊喜。

这招的好处是,主动权交给了用户。先给一个保底的好答案,再提供探索更多可能性的按钮。

玩法三:开发测试的“定海神针”

搞算法开发和测试,排查问题时可以先把温度设为0。

  • 减少随机性:你测一个功能,今天输出“你好”,明天输出“您好”,这测试还怎么做?温度设为0,输出通常更稳定,同一个请求也可能跑出不一样的结果。
  • 定位Bug:当测试或用户报上来一个奇葩输出时,我第一件事就是用相同的提示词,把温度调到0去复现。
    • 如果还能复现,就接着查提示词或者模型本身。
    • 要是没复现,再用用户当时的参数多跑几次。有些问题平时不出现,偏偏在用户用的那套设置里才会冒出来。

能不能稳定复现,是排查线索,不是给问题定优先级的标准。

玩法四:“投票决策”机制,让模型自我纠错

这个方法特别适合解答那些有明确正确答案的问题。比如你问模型“1+1等于几”。

  • 如果你用高温直接问一次,它可能有极小概率抽风,说出个“3”来。
  • 但如果你用高温让它生成100次答案,你会发现,可能有95次是“2”,3次是“3”,2次是“窗户”。

这时候,你只要用一个简单的投票机制,选出现次数最多的那个答案(也就是“2”)。像这个例子,“2”本来就是最常出现的答案,多问几次再投票,就不容易被一次抽风带跑。要是模型每次都错在同一个地方,多投几票也没用。

玩法五:多问几次,看答案稳不稳

你想知道模型的回答稳不稳?那就把同一个问题多问几次。

  1. 先用温度=0生成一个答案A。这是它“不假思索”的第一反应。
  2. 再用温度=1生成10次,得到答案B、C、D…
  3. 观察对比:
  • 如果十次都跟A说的是同一件事,说明答案比较稳定。十次都这么说,也可能十次都说错。
  • 如果连结论都互相打架,就提醒用户核实。

这时候,你就可以在产品里给这个回答打个“答案不稳定,建议核实”的标签。

玩法六:安全对齐的“压力测试”

拿同一批容易诱导模型输出有害内容的问题,分别用低温和高温各跑几次,看看两组各有几次出了问题。

知乎原文

两个人一起打车,其中一个人在半路下车,应该怎么A钱?

前几天,我和朋友A一起从某地打车出发,A中途在一个途径点下车,而我继续坐到终点。从出发地到A下车的地方是20公里,到我下车的终点是30公里。总车费一共100块。虽然我不太在意几块钱,但从数学角度,怎么分摊才真的公平?我觉得这个问题还挺值得聊一聊的。

原文配图

第一种方法:按路程比例分摊。

从出发地到A下车的地方是20公里,到我下车的终点是30公里。那么A和我的路程比就是2:3。很直观对吧?所以A出40元,我出60元,好像没什么毛病。

但仔细一想,这样真的公平吗?

实际上,前20公里是我们俩一起坐的,是“共享路段”;而A下车之后那10公里,只有我一个人在坐,属于“我独享的路段”。如果简单按总路程2:3分,相当于A也在为那10公里付钱——可朋友A根本没享受这段服务啊!这样一看,A其实多付了。


第二种方法

那怎样更合理?来看第二种分法。

我们应该把共享的部分和单独的部分分开算。共享的路段,两人平分;单独的路段,谁坐了谁付。

总车费100元,总路程30公里,每公里单价大约是3.333元。

  • 共享的20公里段:费用约66.67元,两人平分,各付33.33元。
  • 单独的10公里段:费用约33.33元,由我一个人承担。

所以A应该付33.33元,我付66.67元。

这才叫真正公平:A没替我出钱,我也没占A便宜,各自承担自己那部分。合起来刚好100块,账也平了。


第三种方法

但——问题又来了,这样算就完全公平了吗?

其实这件事还有背景:本来我自己回家,正常路线只有25公里,预估车费大概60块。是因为A提出要搭车,我们才绕了一点,变成30公里、100块钱。不光路程远了,时间也多了。

多出来的5公里、40块钱,甚至额外的时间,该谁负责?

所以我又想了第三种方法:谁提出要搭车,谁就承担多出来的费用。

比方说,本来我回家只要60块,因为A要搭车,总价变成100,多出40块就由A来出。我还是付我原来的60,A付40。

反过来,如果是我提出要搭A的车,导致路线变长、费用变多,那么多出来的部分就应该我来承担。比如A回家只需要40块,我提出搭车,总价变成100,多的60就由我来出。

“谁提出、谁承担”,我觉得这样也合理。它不光是在算钱,也是在尊重原本的行程安排。提出需求的人,应该意识到自己带来了额外成本,而不是让原本的一方吃亏。


上面提到了三种方法。就公平性而言,第二种(按路段分开计算)和第三种(谁提出谁承担额外费用)在数学上更合理;而第一种(按总路程比例分摊)虽然最直观——我问过的几个朋友第一反应也都是它——但从实际受益来看,其实并不完全公平。

建议打车软件以后能内置一个“AA计算器”功能,把共享路段、独享路段、原路线费用这些因素都考虑进去,自动把账算清楚。这样一来,既避免了朋友之间不好意思细算的尴尬,也能让每个人都付得明明白白。

知乎原文

一道面试题:如何分配球的个数,使得摸到红球的概率最大?

前段时间面试一家大厂的大模型岗位,面试官出了一道概率题: 有50个红球和50个蓝球,两个空盒子。把这 100 个球全部分到两个盒子里(每个盒子至少有一个球),然后随机打乱盒子,再随机选一个盒子并从中抽一个球。问:如何分配球,才能使抽到红球的概率最大?

我当时靠直觉给出了一个答案:在第一个盒子中只放1个红球,其余99个球(49个红球+50个蓝球)全部放入第二个盒子。

我的思路是: 如果均匀分配(比如每个盒子各25红+25蓝),每个盒子红球比例都是1/2,最终抽到红球的概率也就是1/2,这显然不是最优解。

只要有一盒红球比蓝球多,另一盒就一定是蓝球比红球多。

此时,只需要让一个盒子中红球比例极高,另一个盒子中红球比例略低于1/2,可提升整体概率。

对于红球偏多的盒子,如果这个盒子有1个红球和0个蓝球,可以使得抽到红球的概率为1.

对于蓝球偏多的盒子,要尽可能减少红球与蓝球的差距。于是放置49个红球和50个蓝球。

这样总概率为:0.5×1+ 0.5×49/99 ≈ 0.5 + 0.2475 = 0.7475,接近75%。

如果两盒的红蓝比例都是一半,那抽到红球的概率就是一半。否则,一盒红多,一盒蓝多。红多的那盒,抽到红球最多是 100%。蓝多的那盒呢?把红球和蓝球一对一配起来,至少还多出一个蓝球。要让红球比例尽量高,就只多这一个蓝球,再尽量多放红蓝配对。另一盒至少得留一个红球,所以这里最多配 49 对,红球比例最高就是 49/99。我们上面的分法刚好让两盒都到各自的上限,所以它就是最优的,最大概率是 74/99。

这道题也收在我的《大白话概率论》里,书里还有一些类似的大厂算法岗面试题。

知乎原文

逆变换采样和拒绝采样(附python代码)

如果问你,如何获得一个均匀分布在 {1, 2, 3, 4, 5, 6} 中的随机数,答案很简单:抛骰子就可以了。那么,对于任意一个概率分布(给定一个概率分布律或者概率密度函数),我们该如何生成符合该分布的随机数?

今天介绍两种方法:逆变换采样(Inverse Transform Sampling)和拒绝采样(Rejection Sampling)。这两种抽样方法都是用于从复杂概率分布中抽样的方法。

逆变换采样

先说结论:如果一个随机变量 X 服从某个任意的连续分布(其分布函数FX​(x)F_X​(x) 是连续的,函数值在 0 到 1 之间),考虑随机变量 U=FX​(X)U=F_X​(X) ,则 U 将服从均匀分布 [0,1]。这被称为概率积分变换(Probability Integral Transform)。

这个结论在许多统计领域和随机模拟中有广泛的应用,尤其是在从给定的分布生成随机样本时。

生成服从均匀分布的随机数相对简单,但生成其他分布的随机数则较为困难。我们可以利用上述结论,首先生成均匀分布的随机数,然后通过变换来得到所需的分布。以参数为λ 的指数分布为例,下面将说明如何从该指数分布中生成随机样本。


首先给出指数分布的基本定义:

给定一个参数 λ>0\lambda>0 的指数分布,其概率密度函数(PDF)为:

f(x)=λe−λx,x≥0f(x)=\lambda e^{-\lambda x}, x\geq0

其累积分布函数(CDF)为:

F(x)=1−e−λx,x≥0F(x)=1-e^{-\lambda x}, x\geq0

然后计算出反CDF:

令 u=1−e−λxu=1-e^{-\lambda x}

可以得到 x=−1λln(1−u)x=-\frac{1}{\lambda}ln(1-u)

u服从均匀分布,x服从指数分布。换句话说,我们可以先从均匀分布中采样一个u,然后根据关系式 x=−1λln(1−u)x=-\frac{1}{\lambda}ln(1-u) ,得到样本x,此时x服从指数分布。这种采样方法叫做逆变换采样。

python示例:

import numpy as np
import matplotlib.pyplot as plt

# 设置参数
lambda_param = 1

# 1. 从均匀分布生成样本
u = np.random.uniform(0, 1, 1000)

# 2. 计算指数分布的样本
x_samples = -np.log(1 - u) / lambda_param

# 3. 绘制样本的直方图和指数分布的概率密度函数
plt.figure(figsize=(12, 6))
plt.hist(x_samples, bins=30, density=True, alpha=0.6, color='g', label='Generated Samples')
x = np.linspace(0, 5, 1000)
plt.plot(x, lambda_param * np.exp(-lambda_param * x), 'b-', label='Exponential PDF (λ=1)')
plt.title('Samples from Exponential Distribution (λ=1)')
plt.xlabel('Value')
plt.ylabel('Density')
plt.legend()
plt.grid()
plt.show()

代码结果:

原文配图

可以看到采样结果和真实的指数分布曲线比较拟合。均匀分布里一样宽的两小段,变换以后不一定一样宽。被挤窄的地方,点就密一点;被拉宽的地方,点就稀一点。

拒绝采样

分布函数有平的地方,或者是一阶一阶往上跳的,也能这么抽。先抽一个 0 到 1 之间的数 u,再从左往右找:累计概率第一次达到或超过 u 的位置,就是抽到的值。比如回到开头的骰子,u 落在 2/6 和 3/6 之间,就抽到 3。有些分布反着找不方便,这时候可以试试拒绝采样。

(1)假设我们想采样的分布,概率密度是 p(x)。

(2)选一个比较好抽的候选分布,概率密度是 q(x)。

(3)从q(x)中随机采样一个值,记为 xix_i

(4)计算接受概率: α=p(xi)Mq(xi)\alpha=\frac{p(x_i)}{Mq(x_i)}

(5)以概率 α\alpha 接受 xix_i 作为一个来自p(x)的值

重复(3)(4)(5),直到获得足够的采样点。

先选个 M,让 Mq(x) 在每个位置都盖住 p(x)。如果某个地方 q(x) 是 0,p(x) 却不是 0,那乘多少倍都没用,得换 q(x)。下面还是抽标准正态。正态的尾巴不会在 −3 或 3 的地方突然没了,所以这里用两头都能抽到的拉普拉斯分布。这个例子里取 M=2 就能盖住。

以下是python代码:

import numpy as np
import matplotlib.pyplot as plt

# 目标分布 p(x): 标准正态分布 N(0, 1)
def target_distribution(x):
    return np.exp(-x**2 / 2) / np.sqrt(2 * np.pi)

# 候选分布 q(x):拉普拉斯分布 Laplace(0, 1)
def proposal_distribution():
    return np.random.laplace(0, 1)

def proposal_density(x):
    return 0.5 * np.exp(-abs(x))

# M 要够大,使 M * q(x) 在每个位置都不小于 p(x)
M = 2
# 拒绝采样算法
def rejection_sampling(num_samples):
    samples = []
    while len(samples) < num_samples:
        # 从候选分布 q(x) 中采样
        x = proposal_distribution()
        # 生成一个均匀随机数 u ~ U(0, 1)
        u = np.random.uniform(0, 1)
        
        # 接受-拒绝规则
        if u <= target_distribution(x) / (M * proposal_density(x)):
            samples.append(x)
    
    return np.array(samples)

# 主程序
if __name__ == "__main__":
    # 设置随机种子以保证结果可复现
    np.random.seed(42)
    
    # 生成样本
    num_samples = 10000
    samples = rejection_sampling(num_samples)
    
    # 绘制直方图和目标分布曲线
    x_vals = np.linspace(-4, 4, 1000)
    target_vals = target_distribution(x_vals)
    
    plt.hist(samples, bins=50, density=True, alpha=0.6, label="Sampled Histogram")
    plt.plot(x_vals, target_vals, color="red", label="Target Distribution $N(0, 1)$")
    plt.title("Rejection Sampling Demonstration")
    plt.xlabel("x")
    plt.ylabel("Density")
    plt.legend()
    plt.show()
原文配图
柱子是样本的密度,红线是标准正态的密度。这次留下 10,000 个样本,其中 18 个落在 −3 到 3 之外。

知乎原文

价格翻倍,浮盈加仓能赚多少?

先说结论

在期货市场中,有一种玩法是滚仓。以做多为例,滚仓是越涨越买,买到不能买为止。

比如说初始资金有100美金,设定的杠杆数是3倍。那么在非滚仓的情况下,最多能开到300美金。

滚仓的玩法是:在3倍杠杆下,初始只能开300美金,随着股价上涨,账户出现浮盈,浮盈的钱用来继续开多,开到不能开为止。

如果股价翻倍,在非滚仓策略下,最终可以赚300美金。(300 美元的仓位涨了一倍,赚了 300 美元。加上本金 100 美元,平仓后手里一共是 400 美元。)

如果股价翻倍,在滚仓策略下,最终可以赚多少美金呢?

我先说结论:如果价格一路涨到两倍,每涨一点都能马上加仓,先不算手续费和资金费,这 100 美元最后会变成 800 美元,净赚 700 美元。

下面的表格显示了如果股价翻倍,在不同杠杆下,非滚仓获利(包括本金)与滚仓获利(包括本金)的区别。

原文配图

计算方式

对于非滚仓的期货合约,盈利的公式比较好计算。

设a为杠杆,x为价格的翻倍数,y为平仓后账户余额(包括本金)的翻倍数,则有

y=a*x−(a−1)y=a*x-(a-1)

比如在3倍杠杆下,如果价格是原来的2倍,那么a=3, x=2, y=4,相当于账户余额是原来的4倍。

对于滚仓的期货合约,盈利的公式是什么呢?

(注意,本文提到的滚仓,默认是做多滚仓,不是做空滚仓。且一旦设定好初始杠杆,不改变杠杆)

滚仓有一条很重要的性质:价格每创造一个高点,涨到可以加仓的时候,去执行加仓,此时相当于平仓所有仓位,并且立马以同样的杠杆开全仓仓位。

举个例子,初始100美金,3倍杠杆做多,股价初始价格为10美元,最小开单金额为3美元(每次最少开单3美元,但是在3倍杠杆下,本金只有1美元即可开单)。当股价上涨到10.001美元的时候,仓位值 300.3 美元,账户算上浮盈是 100.3 美元,此时没有足够的盈利来开多。当股价上涨到10.004美元时,仓位值 301.2 美元,账户算上浮盈是 101.2 美元,此时可以用盈利的1.2美元继续以3倍杠杆开多。这就是滚仓。

这相当于我在10.004美元时,先平掉所有的仓位,这时手里是 101.2 美元。再按 3 倍杠杆开仓,就是 303.6 美元的仓位。

这条性质是得到滚仓盈利公式的关键。我再深入举一个例子:

比如说,价格先涨 20%,再涨 20%,就到了原来的 1.44 倍。每次涨完都把杠杆加回原来的倍数,下一段就可以接着算。比如 3 倍杠杆,价格只涨 1%,账户就涨 3%。把整段涨幅拆成很多段,每段都涨同样的百分比,段末加一次仓。把每一小段的账户倍数乘起来,分得越细,算出来就越接近价格倍数的 3 次方。杠杆是几倍,这里就是几次方。

因此可以得出结论:滚仓的盈利公式是 y=xay=x^a

实操拟合

我写了滚仓代码,实际用在数字货币的合约交易中。

下图是wld三倍杠杆的拟合曲线,可以看到实际会略低于拟合曲线,因为开单是离散的,有最小开单精度。

原文配图

下图是xvg四倍杠杆的拟合曲线:

原文配图

理论收益曲线

我列举了余额在不同杠杆(3/4/5)下,跟随价格的变化(横坐标是价格相对于开仓时的倍数,纵坐标是账户的总额(算上浮盈和本金,换句话说,就是立马平仓后的账户余额)相对于初始资金的倍数):

原文配图

可以看到幂函数的威力远远大于线性函数。如果价格翻倍,按上面这个模型,5 倍杠杆滚仓后,账户总额是本金的 32 倍!

但是滚仓的风险也显而易见:如果在最高点刚把杠杆加回 3 倍,接下来价格跌三分之一,按这个模型,账户就亏完了。不滚仓的话,也是跌三分之一亏完,只不过是从开仓价往下算。实际交易还有维持保证金,可能还没亏完就被强平。

一些探索

我还探索了一个问题:当价格下跌后,如果马上平仓,然后以同样的杠杆立马开全仓,当价格回来的时候账户可以回本吗?

如果不滚仓的话,显然无法回本。比如初始资金是100美元,以3倍杠杆做多。开仓后价格直接下跌10%,则账户下跌30%,你的账户余额是70美元。此时平仓,然后以70美金3倍杠杆做多。当价格回来时(需要上涨1/9),你赚取了70美金的1/3,无法回到100美金。

如果滚仓的话,可以回本吗?

设a是杠杆,x是下跌幅度。

则当价格回来时,余额相比于原来的初始资金的百分比是:(1-a* x)*(1-x)**(-a)

下图画的是还没亏完的情况。对图里的几种杠杆来说,价格先跌再涨回原价,账户都回不到最初的 100 美元。

原文配图
跌后不补钱:涨回原价时的账户余额倍数是 (1−a×x)×(1−x)^(−a)。图题中的 −3 应为 −a。

如果当价格下跌后,我马上平仓,然后把手里剩下的钱补到 100 美元,再重新滚仓。当价格回来的时候,我可以回本吗?

如果价格一路涨回原价,按这个模型算,扣掉最初的 100 美元和中途补进去的钱,净赚的金额占最初 100 美元的比例是:(1-x)**(-a)-a*x-1。

数量关系如下图所示。比如用 100 美元、3 倍杠杆开仓,价格先跌 30%。按这个模型算,平仓时先亏 90 美元,手里剩 10 美元。再补 90 美元凑到 100 美元重新开仓,等价格一路涨回原价,账户约有 292 美元。前后共投入 190 美元,所以净赚约 102 美元。

如果先跌 20%,平仓后手里还有 40 美元,需要再补 60 美元。按同样的算法,价格涨回原价时,账户约有 195 美元,扣掉前后投入的 160 美元,净赚约 35 美元。

原文配图
跌后补到 100 美元:净赚金额占最初 100 美元的比例是 (1−x)^(−a)−a×x−1。图里沿用了上一张图的标题。

知乎原文

赌徒输光定理的初等证明

问题描述:甲有 aa 元,乙有 bb 元。甲获胜一局的概率是 pp 。若甲赢一局,则乙给甲1元;若甲输一局,则甲给乙1元。每局独立,谁先输光,游戏就结束。问:甲先输光的概率是多少?

记 hkh_k 为甲有 kk 元时,破产的概率。记 q=1−pq=1-p , w=qpw=\frac{q}{p} 。

如果此时再继续一局,有p的概率赢1元,有q的概率输1元,根据全概率公式可推出 hk=phk+1+qhk−1h_k=ph_{k+1}+qh_{k-1}

稍加变换得: hk+1−hk=(hk−hk−1)wh_{k+1}-h_{k}=(h_k-h_{k-1})w

不断迭代下去,可得:

hk+1−hk=(h1−h0)wkh_{k+1}-h_{k}=(h_1-h_0)w^k (1)

在(1)中把 kk 从0到 a+b−1a+b-1 遍历,可以得到 a+ba+b 个不同的类似(1)的式子,然后对这些式子相加,可得:

ha+b−h0=(h1−h0)∑i=0a+b−1wih_{a+b}-h_0=(h_1-h_0)\sum_{i=0}^{a+b-1}{w^i}

甲手里是 0 元,就是甲已经输了;甲拿到了全部的钱,就是乙已经输了。即 h1−h0=−1∑i=0a+b−1wih_1-h_0=\frac{-1}{\sum_{i=0}^{a+b-1}{w^i}} (2)

在(1)中把 kk 从0到 k−1k-1 遍历,可以得到 kk 个不同的类似(1)的式子,然后对这些式子相加,可得:

hk−h0=(h1−h0)∑i=0k−1wih_{k}-h_0=(h_1-h_0)\sum_{i=0}^{k-1}{w^i} (3)

把(2)代入(3),可得:

hk=1−∑i=0k−1wi∑i=0a+b−1wih_{k}=1-\frac{\sum_{i=0}^{k-1}{w^i}}{\sum_{i=0}^{a+b-1}{w^i}}

当 w≠1w\ne1 时,有

hk=1−1−wk1−wa+bh_{k}=1-\frac{1-w^{k}}{1-w^{a+b}}

当 w=1w=1 时,有

hk=1−ka+bh_{k}=1-\frac{k}{a+b}

当 w≠1 时,将 k=ak=a 代入得:

ha=1−1−wa1−wa+bh_{a}=1-\frac{1-w^{a}}{1-w^{a+b}}

特别地,如果 w=1w=1 ,甲乙赢的概率都是0.5。此时甲破产的概率是 ba+b\frac{b}{a+b} ,乙破产的概率是 aa+b\frac{a}{a+b} ,这样的结果还挺符合直觉。

知乎原文

通过代码来理解文本相似度评估指标BLEU和ROUGE

我喜欢通过debug代码的方式来深入理解一个概念,因为我认为实践出真知。如果只是浏览论文或者别人的博客,对概念的认识可能仅停留在表面。即使用笔在纸上计算一遍,可能还是无法完全掌握细节。

引言:判断语言生成模型的性能

之前我分享过语言模型的“困惑度”的概念(武辰:深入理解语言模型的困惑度(perplexity))。通过计算一段语料的生成概率,困惑度可以衡量一个base模型的性能优劣。

对于chat模型,如何衡量模型的好坏呢?在传统的分类问题中,我们可以轻松地通过精确度(accuracy)、召回率(recall)等指标来测定分类器的好坏。对于目标检测、定位、分割等问题,也有一套客观的评估方案。

对于语言生成模型来说,我们希望生成的句子与目标句子尽可能接近。交叉熵可以看模型给参考答案多大的概率。我这里想问的是另一件事:两句话都已经写出来了,它们到底有多像?

语言作为一个充满主观性的领域,往往即使对句子稍作修改,如增删几个字或微调词序,也不会改变句子的基本含义。那么,除了交叉熵,还有哪些指标可以衡量生成句子与目标句子之间的匹配程度呢?

抛砖引玉:一些简单的衡量句子相似度的方法

文本转向量模型

我的初步想法是利用文本向量化模型,诸如BERT模型、BCEmbedding模型、BGE模型等到,把句子转换为向量表示。然后通过向量的内积或余弦相似性来衡量两段语料的接近程度,其结果越大,则两段语料越为接近。然而,这一方案并不是太理想,因为它需要进行额外的推理运算,这会消耗大量计算资源。此外,结果的准确性还受到文本向量化模型质量的影响。如果模型的质量不高,计算出的相似度可能就不够准确。在一些特殊情况下,文本向量化模型可能无法处理词汇表以外的单词,这也会影响到计算的准确性。

余弦相似度

假设我们有两段文本(Text A 和 Text B):

Text A: The quick brown fox jumps over the lazy dog.

Text B: A fast brown fox leaps over a sluggish dog.

第一步:分词

将两段文本分别拆分成单词列表:

Text A: ["The", "quick", "brown", "fox", "jumps", "over", "the", "lazy", "dog"]

Text B: ["A", "fast", "brown", "fox", "leaps", "over", "a", "sluggish", "dog"]

(还可以进一步进行文本标准化处理,如转换为小写、去除停用词、词干化/词形还原等,以提高相似度计算的准确性。这个例子里只统一成小写,其他处理先不做。)

第二步:构建词频向量:

计算每段文本中每个单词的词频,形成词频向量。

假设我们有一个包含两段文本所有唯一单词的词汇表:

Vocabulary: ["a", "brown", "dog", "fast", "fox", "jumps", "lazy", "leaps", "over", "quick", "sluggish", "the"]

根据词汇表,构建词频向量:

Text A Vector: [0, 1, 1, 0, 1, 1, 1, 0, 1, 1, 0, 2]

Text B Vector: [2, 1, 1, 1, 1, 0, 0, 1, 1, 0, 1, 0]

第三步:计算余弦相似度。
两个向量的点积是 4,长度都是 √11,所以余弦相似度是 4/11,约等于 0.364。

这种方法比较naive,有如下缺陷:

1.对词汇顺序不敏感: 余弦相似度仅基于词频向量的点积,忽略了词汇在文本中的具体顺序。对于一些依赖于语序表达语义的情况(如“我喜欢狗”与“狗喜欢我”),这种方法可能无法准确反映其语义差异。

2.对词频过于敏感: 余弦相似度直接基于词频计算,可能导致某些高频但非关键的词语(如“的”、“和”、“是”等停用词)对相似度得分产生过大影响,掩盖了真正重要的词汇和概念。虽然可以通过预处理阶段去除停用词来缓解这个问题,但对于某些特殊情况(如词义丰富的同义词、多义词)仍可能不够精准。

3.忽视词汇的语义关联: 余弦相似度仅基于词汇表中的单词出现与否及其频率,无法捕捉词汇之间的语义关联或概念层次关系。例如,“汽车”与“轿车”、“跑车”在语义上高度相关,但在词频向量模型中被视为不同的词汇,可能导致相似度得分偏低。

4.对短文本效果不佳: 对于非常短的文本,由于词汇数量有限,构建的词频向量可能过于稀疏,导致余弦相似度计算结果不稳定,不能准确反映文本间的实际相似度。

5.需要预定义词汇表: 余弦相似度计算通常需要预先定义一个词汇表,这可能导致某些特定领域或上下文相关的词汇被忽略,进而影响相似度计算的准确性。此外,随着文本集规模增大,词汇表可能会变得庞大,增加计算复杂性和资源消耗。

TF-IDF

TF-IDF可以看作是上一个方法的升级版。

TF-IDF原本是用来评估一个词对一篇文档的重要程度

以下内容参考于:https://blog.csdn.net/szn1043862535/article/details/120922191

词频(Term Frequency,TF)表示词语在当前文章中的频率;逆文本频率指数(Inverse Document Frequency,IDF)则表示某个词语在包含众多文档的文库中的常见程度,即IDF值越大,该词越不常见。
(1) 假设有篇文章包含1000个词,且其中"中国"这个词出现了20次,"梦想"出现了10次,"我"出现了50次。那么:
TF(“中国”)=20/1000=0.02
TF(“梦想”)=10/1000=0.01
TF(“我”)=50/1000=0.05
(2) 假设有个包含5000篇文档的文库,用于计算IDF。在这5000篇文档中,包含"中国"的文档数为1000,包含“梦想”的文档数为500,包含“我”的文档数为2000。计算IDF的公式为:
IDF = log(文库中的文档总数 / (包含该词的文档数+1) )
其中,加1是为了防止分母为0。
IDF(“中国”)=log(5000/1001)=0.6985
IDF(“梦想”)=log(5000/501)=0.9991
IDF(“我”)=log(5000/2001)=0.3977
(3) 计算TF-IDF:
TF-IDF(“中国”)= TF(“中国”) * IDF(“中国”)=0.01397
TF-IDF(“梦想”)= 0.00999
TF-IDF(“我”)= 0.019885
若某词语较少见(IDF值较高),同时在文章中多次出现(TF值较高),则它很可能就反映了该文章的特性,成为我们所需的关键词。这组数里,「我」的 TF-IDF 最高,约为 0.01989。这里的词权重是按这批文档算出来的,还得结合原文看它能不能代表主题。

TF-IDF原本是用来评估一个词对一篇文档的重要程度,但是为什么可以用来衡量两个文本的相似度度量?接下来通过一个简单的例子来了解如何使用TF-IDF评估两个文本的相似度。

假设文档集合只有下面两句话:

文档1:“我喜欢吃苹果 和 橙子”

文档2:“我喜欢看电影 和 吃橙子”

首先使用TF-IDF为每个文档生成一个词汇的权重向量。

计算TF (词频):文档中某词出现的次数 / 文档的总词数

文档1切成「我/喜欢/吃/苹果/和/橙子」,一共 6 个词,每个词出现 1 次,TF 都是 1/6。

文档2切成「我/喜欢/看/电影/和/吃/橙子」,一共 7 个词,每个词出现 1 次,TF 都是 1/7。

计算 IDF(逆文档频率):log₁₀(总文档数 / 包含某词的文档数)。这个例子的分母不加 1。

对于词"我",总文档数是2,包含"我"的文档数也是2,所以"我"的IDF = log(2/2) = 0。

对于词"橙子",总文档数是2,包含"橙子"的文档数也是2,所以"橙子"的IDF = log(2/2) = 0。

对于词"苹果",总文档数是2,包含"苹果"的文档数是1,所以"苹果"的IDF = log(2/1) ≈ 0.3010。

对于词"电影",总文档数是2,包含"电影"的文档数是1,所以"电影"的IDF = log(2/1) ≈ 0.3010,等等。

通过将TF和IDF相乘,我们得到每个词在每个文档中的TF-IDF值。

于是,我们可以得到以下的TF-IDF向量表示:

文档1的向量表示:[我, 喜欢, 吃, 苹果, 和, 橙子, 看, 电影] ≈ [0, 0, 0, 0.05017, 0, 0, 0, 0]

文档2的向量表示:[我, 喜欢, 吃, 苹果, 和, 橙子, 看, 电影] ≈ [0, 0, 0, 0, 0, 0, 0.04300, 0.04300]

需要注意的是,当我们计算文档的TF-IDF向量时,词汇表应当包含整个文档集中的所有唯一单词。因此,每个文档的TF-IDF向量将具有相同的长度,即使某些单词在特定文档中没有出现(在这种情况下,该单词的TF-IDF值将为零)。

再算这两个向量的余弦相似度,结果是 0。两句话明明都提到了吃橙子,可它们共有的词在这两篇里都出现了,IDF 全是 0。第一句只剩「苹果」,第二句只剩「看」和「电影」,点积自然就是 0。

这种方法来衡量文本相似度仍然有一些缺陷:

1.语义层面的问题:TF-IDF关注的是词频和逆文档频率,而不是实际的语义。这意味着,即使两段文本在语义上相似但用词不同,TF-IDF也可能给出较低的相似度分数。

2.无法捕捉词序信息:TF-IDF是基于词袋模型的,即它只在乎文本中的词汇,而不关心词汇出现的顺序。这可能导致在判断某些情况下的相似度时效果不佳。

3.影响权重的因素较少:TF-IDF只关注词频和逆文档频率,缺乏其他有助于判断相似度的特征。例如,单词的重要性、长度、位置等特征都无法从TF-IDF中获取。

4.对高频词和罕见词可能处理不平衡:罕见词的影响往往高于常见词,这可能导致结果不准确。例如,两篇关于同一主题的文章中使用了不同的罕见词,可能被认为区别较大。

5.对同义词和词形变化敏感:即使两个词在语义上相同,但在形式上有差异(例如时态、复数等),也可能导致TF-IDF相似度低。

BLEU

除了前文提到的这两种方法外,学术界还提出了一些更具针对性的评估指标,其中BLEU(Bilingual Evaluation Understudy)是一个较为经典的衡量文本相似度的指标。

BLEU 是一种用于评估自然语言处理领域机器翻译质量的指标,最早由 IBM 的研究团队于 2002 年提出。其工作原理主要是通过比较机器翻译生成的文本与一个或多个人类翻译的参考文本,进而计算翻译质量的准确性。

当然,BLEU 也可以应用于衡量两段文本的相似度,不仅仅局限于机器翻译。

代码

from nltk.translate.bleu_score import sentence_bleu


candidates = 'The quick brown fox jumps over the big dog'
references = 'The quick brown beautiful fox jumps over the lazy dog'


candidate = candidates.split(' ')    # 这是生成文本的例子
reference = [references.split(' ')]  # 这是一个参考文本的例子,用列表的列表表示,因为可以有多个参考文本


bleu_score = sentence_bleu(reference, candidate)


print("BLEU score:", bleu_score)

通过debug代码分析计算BLEU的步骤

第一步:分词

在这里,为简化问题,我直接基于空格进行分词,而无需使用分词器。对于英文来说,这种方法是可行的。

分词后的结果:

reference: [['The', 'quick', 'brown', 'beautiful', 'fox', 'jumps', 'over', 'the', 'lazy', 'dog']]

candidate: ['The', 'quick', 'brown', 'fox', 'jumps', 'over', 'the', 'big', 'dog']

第二步:计算1-gram

计算参考文本(reference)中每个词出现的频数:

{('The',): 1, ('quick',): 1, ('brown',): 1, ('beautiful',): 1, ('fox',): 1, ('jumps',): 1, ('over',): 1, ('the',): 1, ('lazy',): 1, ('dog',): 1}

计算候选文本(candidate)中每个词在reference中出现的频数:

{('The',): 1, ('quick',): 1, ('brown',): 1, ('fox',): 1, ('jumps',): 1, ('over',): 1, ('the',): 1, ('big',): 0, ('dog',): 1}

candidate一共有9个词,其中有8个词在reference出现过,因此precision是8/9。注意,BLEU是计算precision,因此分母是candidate的长度。如果计算recall,则分母是reference的长度。

为了更深入理解计算1-gram的逻辑,我换一个例子:

predictions = 'the quick brown big dog jumps over the big dog'

references = 'the quick brown beautiful fox jumps over the lazy dog'

计算参考文本中每个词出现的频数:

{('the',): 2, ('quick',): 1, ('brown',): 1, ('beautiful',): 1, ('fox',): 1, ('jumps',): 1, ('over',): 1, ('lazy',): 1, ('dog',): 1}

计算候选文本中每个词在参考文本中出现的频数:

{('the',): 2, ('quick',): 1, ('brown',): 1, ('big',): 0, ('dog',): 1, ('jumps',): 1, ('over',): 1}

虽然在candidate中dog出现了两次,但是在reference中,dog只出现了一次,因此还是计为一次。

需要注意的是,BLEU 这里算的是 precision。像 beautiful、fox 这种只在参考句里出现的词,不单独扣分;候选句太短,下面还有一项惩罚。

有人可能会问,那我忽略recall,只关注prerecision,行不行?比如我认为the这个词语一定会出现在reference中,那么只输出一个the作为candidate。答案是不行。因为针对短文本,BLEU会有一个 惩罚因子,下文会说到。而且这一招只能应付1-gram,对于2-gram、3-gram则无法奏效。

第三步:计算2-gram、3-gram、4-gram

计算2-gram、3-gram、4-gram和计算1-gram的思路是类似的,这里只列举结果。

参考句中各个 2-gram 的出现次数:

{('The', 'quick'): 1, ('quick', 'brown'): 1, ('brown', 'beautiful'): 1, ('beautiful', 'fox'): 1, ('fox', 'jumps'): 1, ('jumps', 'over'): 1, ('over', 'the'): 1, ('the', 'lazy'): 1, ('lazy', 'dog'): 1}

候选句中各个 2-gram 在参考句里匹配了几次:

{('The', 'quick'): 1, ('quick', 'brown'): 1, ('brown', 'fox'): 0, ('fox', 'jumps'): 1, ('jumps', 'over'): 1, ('over', 'the'): 1, ('the', 'big'): 0, ('big', 'dog'): 0}

2-gram的precision是5/8

参考句中各个 3-gram 的出现次数:

{('The', 'quick', 'brown'): 1, ('quick', 'brown', 'beautiful'): 1, ('brown', 'beautiful', 'fox'): 1, ('beautiful', 'fox', 'jumps'): 1, ('fox', 'jumps', 'over'): 1, ('jumps', 'over', 'the'): 1, ('over', 'the', 'lazy'): 1, ('the', 'lazy', 'dog'): 1}

候选句中各个 3-gram 在参考句里匹配了几次:

{('The', 'quick', 'brown'): 1, ('quick', 'brown', 'fox'): 0, ('brown', 'fox', 'jumps'): 0, ('fox', 'jumps', 'over'): 1, ('jumps', 'over', 'the'): 1, ('over', 'the', 'big'): 0, ('the', 'big', 'dog'): 0}

3-gram的precision是3/7

4-gram的candidate中每个gram出现的频数:

{('The', 'quick', 'brown', 'fox'): 1, ('quick', 'brown', 'fox', 'jumps'): 1, ('brown', 'fox', 'jumps', 'over'): 1, ('fox', 'jumps', 'over', 'the'): 1, ('jumps', 'over', 'the', 'big'): 1, ('over', 'the', 'big', 'dog'): 1}

候选句中各个 4-gram 在参考句里匹配了几次:

{('The', 'quick', 'brown', 'fox'): 0, ('quick', 'brown', 'fox', 'jumps'): 0, ('brown', 'fox', 'jumps', 'over'): 0, ('fox', 'jumps', 'over', 'the'): 1, ('jumps', 'over', 'the', 'big'): 0, ('over', 'the', 'big', 'dog'): 0}

4-gram的precision是1/6

第四步:加入惩罚项

上文说到,BLEU计算的是precision,precision更偏向于短文本。短文本惩罚可以防止短翻译在BLEU中得分过高。

候选句不比参考句短,惩罚系数 bp 就是 1;候选句更短,才用下面这个式子:

bp=math.exp(1 - reference_len / candidate_len)

在本例中,bp = exp(1 − 10/9) ≈ 0.895。

第五步:计算BLEU

在这里我就不列举枯燥的公式了,而是直接将数值往里面代入。

注意式子中有一个值是0.25,这代表取平均值。其实也可以自定义加权平均值,比如可以给2-gram的precision的权重更高一些。

bleu=bp*e0.25(log89+log58+log37+log16)=0.4bleu=bp*e^{0.25(log\frac{8}{9}+log\frac{5}{8}+log\frac{3}{7}+log\frac{1}{6})}=0.4

BLEU的局限性

1.语义问题:BLEU主要依赖于n-gram精确度进行计算,它无法捕捉到文本的语义信息。如果两句话在语义上几乎一样,但是词序或者用词稍有不同,BLEU可能会给出较低得分。

2.无法处理句子长度:BLEU容易受到句子长度的影响。对于短句子,即使所有的词都正确,但由于句子长度过短,BLEU的得分可能依然很低。

ROUGE

ROUGE(Recall-Oriented Understudy for Gisting Evaluation)是一种自动评估文本摘要质量的指标,尤其适用于评估抽取式摘要任务。ROUGE 主要通过比较自动生成的摘要与人工编写的参考摘要进行评估,侧重于衡量召回率。ROUGE 的设计初衷是弥补 BLEU 在评估文本摘要任务时的不足。

当然,ROUGE也可以用来衡量两段语料的相似度。

代码

这里直接用 rouge_score 库算,能同时看到 precision、recall 和 F1。安装命令是 pip install rouge-score。

from rouge_score import rouge_scorer

predictions = ['The quick brown fox jumps over the big dog']
references = ['The quick brown beautiful fox jumps over the lazy dog']

scorer = rouge_scorer.RougeScorer(['rouge1', 'rouge2', 'rougeL'])
score = scorer.score(references[0], predictions[0])
print(score)

通过debug代码分析计算ROUGE的步骤

第一步:分词

这里用的是 rouge_score 自带的默认分词器,它会先把英文字母转成小写。

predictions = ['The quick brown fox jumps over the big dog']
references = ['The quick brown beautiful fox jumps over the lazy dog']

references经过分词后是

['the', 'quick', 'brown', 'beautiful', 'fox', 'jumps', 'over', 'the', 'lazy', 'dog']

predictions经过分词后是

['the', 'quick', 'brown', 'fox', 'jumps', 'over', 'the', 'big', 'dog']

注意,这个分词器不能对中文分词,因此这个代码无法适用于有中文的情况。解决方法是修改源代码,将self.tokenizer替换成支持中文的分词器,或者使用其他库(比如rouge_chinese)。

第二步:计算1-gram

计算参考文本(reference)中每个词出现的频数:

{('the',): 2, ('quick',): 1, ('brown',): 1, ('beautiful',): 1, ('fox',): 1, ('jumps',): 1, ('over',): 1, ('lazy',): 1, ('dog',): 1}

再看候选句里每个词在参考句里匹配了几次:

{('the',): 2, ('quick',): 1, ('brown',): 1, ('fox',): 1, ('jumps',): 1, ('over',): 1, ('big',): 0, ('dog',): 1}

predictions一共有9个词,其中有8个词在reference出现过。intersection_ngrams_count=8.

然后分别计算precision/recall/fmeasure

precision = intersection_ngrams_count / prediction_ngrams_count
recall = intersection_ngrams_count / target_ngrams_count
fmeasure = scoring.fmeasure(precision, recall)

这里的prediction_ngrams_count是predictions的长度,为9;target_ngrams_count是references的长度,为10.

结果是precision=8/9, recall=8/10, fmeasure=0.84

BLEU只计算了precision,ROUGE计算了precision/recall/fmeasure

第三步:计算2-gram

计算2-gram计算1-gram的思路是类似的,这里只列举结果。这里没有计算3-gram、4-gram

参考句中各个 2-gram 的出现次数:

{('the', 'quick'): 1, ('quick', 'brown'): 1, ('brown', 'beautiful'): 1, ('beautiful', 'fox'): 1, ('fox', 'jumps'): 1, ('jumps', 'over'): 1, ('over', 'the'): 1, ('the', 'lazy'): 1, ('lazy', 'dog'): 1}

候选句中各个 2-gram 在参考句里匹配了几次:

{('the', 'quick'): 1, ('quick', 'brown'): 1, ('brown', 'fox'): 0, ('fox', 'jumps'): 1, ('jumps', 'over'): 1, ('over', 'the'): 1, ('the', 'big'): 0, ('big', 'dog'): 0}

intersection_ngrams_count=5.

precision=5/8, recall=5/9, fmeasure=0.59

第三步:计算Rouge-L

ROUGE-L基于最长公共子序列(Longest Common Subsequence)计算两个摘要之间的相似度。它不仅考虑了重叠的单词和短语,还考虑了它们的顺序关系。ROUGE-L适用于评估生成摘要中保持参考摘要结构和重要信息的能力。

对于references:

['the', 'quick', 'brown', 'beautiful', 'fox', 'jumps', 'over', 'the', 'lazy', 'dog']

和predictions:

['the', 'quick', 'brown', 'fox', 'jumps', 'over', 'the', 'big', 'dog']

代码使用动态规划的方法求得它们的最长公共子序列是:

['the', 'quick', 'brown', 'fox', 'jumps', 'over', 'the', 'dog']

长度为8,然后结果是:

precision=8/9, recall=8/10, fmeasure=0.84

最后,每种 ROUGE 都有三个分数。下面保留三位小数:

{'rouge1': Score(precision=0.889, recall=0.8, fmeasure=0.842),

'rouge2': Score(precision=0.625, recall=0.556, fmeasure=0.588),

'rougeL': Score(precision=0.889, recall=0.8, fmeasure=0.842)}

其他计算ROUGE的库

rouge库:

from rouge import Rouge 


def calculate_rouge_score(hypothesis, reference):
    rouge = Rouge()
    scores = rouge.get_scores(hypothesis, reference)
    return scores


hypothesis = 'The quick brown fox jumps over the lazy dog.'
reference = 'A swift brown fox leaps over a sluggish canine.'


scores = calculate_rouge_score(hypothesis, reference)


print(scores)

这个库比较简陋,它甚至都没有集成分词器(tokenizer),而是仅仅依赖于空格来实现分词功能。

rouge_chinese库:

from rouge_chinese import Rouge

rouge = Rouge()
hypothesis = 'The quick brown fox jumps over the lazy dog.'
reference = 'A swift brown fox leaps over a sluggish canine.'
scores = rouge.get_scores(hypothesis, reference)
print(scores)

对于中文,仍然要配合jieba分词器使用,因为它没有内置分词器。

from rouge_chinese import Rouge
import jieba # you can use any other word cutting library

hypothesis = "今天我们相聚在一起"
hypothesis = ' '.join(jieba.cut(hypothesis))  #'今天 我们 相聚 在 一起'

reference = "我们今天相聚在这里"
reference = ' '.join(jieba.cut(reference)) #'我们 今天 相聚 在 这里'

rouge = Rouge()
scores = rouge.get_scores(hypothesis, reference)
print(scores)

Bert-score

在我们前文中提到,BLEU和ROUGE这两个评价指标无法准确捕获到文本的语义含义。即使两句话的要表达的内容几乎一致,只因词序或词汇选择稍有不同,这两个评估工具可能也无法给出高分。

面对这样的问题,BERT-Score就显得相当有用。此指标借助于BERT模型,将所需评估的文本转换为向量模式,然后评估模型预测输出(Hypothesis)相对于标签(Reference)的精确度和查全率。

前置准备

安装bert-score库

pip install bert-score

另外,我们需要下载bert-base-chinese模型,该模型的下载地址是https://huggingface.co/google-bert/bert-base-chinese/tree/main。

代码

from bert_score import score


cands = ['你举止优雅,吸引了众人的目光。']
refs = ['你风度翩翩,令人无法转移视线。']


P, R, F1 = score(cands, refs,model_type="/home/notebook/data/group/huggingface/bert-base-chinese",num_layers=8, lang="zh", verbose=True)
print(F1)
print(f"System level F1 score: {F1.mean():.3f}")

如果模型已经下载到本地,model_type 就填模型目录。这里再传 num_layers=8,选第 8 层来评分,不用改库的源码。

通过debug代码分析计算步骤

第一步:分词。

加载好tokenizer后,发现tokenizer是BertTokenizer,具体的配置是:

BertTokenizer(name_or_path='/home/notebook/data/group/huggingface/bert-base-chinese', vocab_size=21128, model_max_length=512, is_fast=False, padding_side='right', truncation_side='right', special_tokens={'unk_token': '[UNK]', 'sep_token': '[SEP]', 'pad_token': '[PAD]', 'cls_token': '[CLS]', 'mask_token': '[MASK]'}, clean_up_tokenization_spaces=True), added_tokens_decoder={
0: AddedToken("[PAD]", rstrip=False, lstrip=False, single_word=False, normalized=False, special=True),
100: AddedToken("[UNK]", rstrip=False, lstrip=False, single_word=False, normalized=False, special=True),
101: AddedToken("[CLS]", rstrip=False, lstrip=False, single_word=False, normalized=False, special=True),
102: AddedToken("[SEP]", rstrip=False, lstrip=False, single_word=False, normalized=False, special=True),
103: AddedToken("[MASK]", rstrip=False, lstrip=False, single_word=False, normalized=False, special=True),
}

分词的结果是:

'[CLS] 你 举 止 优 雅 , 吸 引 了 众 人 的 目 光 。 [SEP]'

'[CLS] 你 风 度 翩 翩 , 令 人 无 法 转 移 视 线 。 [SEP]'

可见分词器将一个汉字分为一个词,每个句子都包括17个token。

对应的input_ids.shape=2*17。

第二步,加载model。

bert-base-chinese 本身有 12 层;这里评分用的是第 8 层的输出,每个 token 的向量还是 768 维。

第三步,模型推理。

输入的input_ids的shape是torch.Size([2, 17]),经过bert模型后,得到两个输出,分别是last_hidden_state和pooler_output。last_hidden_state 是这里截取的第 8 层的输出。pooler_output则是最后一层的[CLS]符号对应位置再经过几个线性层的结果。out.last_hidden_state.shape是torch.Size([2, 17, 768]),out.pooler_output.shape是torch.Size([2, 768])。

第四步:矩阵相乘(类似于做向量内积)。

out.last_hidden_state的第一个数据是hyp_embedding,shape是torch.Size([1, 17, 768])

第二个数据是ref_embedding,shape也是torch.Size([1, 17, 768])

先把每个 token 的向量除以自己的长度,再做点积,得到形状为 [1, 17, 17] 的矩阵 sim。这样,每个格子里算出来的就是候选句一个 token 和参考句一个 token 的余弦相似度。

第五步:计算precision和recall。

在源代码中,这两行代码可能比较难以理解:

word_precision = sim.max(dim=2)[0]
word_recall = sim.max(dim=1)[0]

sim矩阵的第一行,表示hyp的第一个token,和ref的所有token都算一遍余弦相似度后的结果。在这些结果中,挑出最大的出来。然后对每一行都是如此。默认设置下,[CLS] 和 [SEP] 那两行不计入平均。其余每行的最大值取个平均,就是 precision。这种计算precision的方式感觉有点牵强,并且结果不一定在0到1之间,但是它的基本逻辑符合直觉。比如说,hyp中出现了一个与参考文本无关的token,比如“克拉霉素”这个token,明显它和ref的任何token都不相关,那么克拉霉素这个token这一行的所有值都很小,这会拉低precision的分数,这是符合直觉的。

刚刚是从行的角度解释precision。现在从列的角度解释recall。

sim矩阵的第一列,表示在ref 的第一个token,和hyp的所有token都算一遍余弦相似度后的结果。在这些结果中,挑出最大的出来。然后对每一列都是如此。同样,[CLS] 和 [SEP] 那两列不计入平均,其余每列的最大值取个平均,就是 recall。假如ref中出现了“克拉霉素”这个token,但是hyp中没有出现任何医学相关的token,那么“克拉霉素”这个token所在的那一列的所有值都会很小,这会拉低recall的分数,是符合直觉的。

一些具体的例子

hyp = '美的人'

ref = '漂亮的笔'

得到的sim矩阵是:

原文配图

从precision的角度来剖析这个结果。

「美」和「漂」、「亮」的相似度都超过了 0.7,这是符合实际的。

“的”和“的”的余弦相似度最大,这是符合实际的。

“人”和“笔”的余弦相似度达到了0.76,超过了“美”和“漂”的余弦相似度,这似乎有些不太符合实际。

最终得到的precision是(0.7239+0.8304+0.7674)/3=0.7739

从recall的角度来剖析这个结果。

「漂」和「亮」都和「美」最相似,分数都超过了 0.7,这是符合实际的。

“的”和“的”的余弦相似度最大,这是符合实际的。

“笔”和“人”的余弦相似度达到了0.76,超过了“美”和“漂”的余弦相似度,这似乎有些不太符合实际。

最终得到的recall是(0.7103+0.7239+0.8304+0.7674)/4=0.7580

知乎原文

[LLM入门]从零开始预训练一个迷你的大语言模型

引言

在谈及大语言模型的训练时,大家普遍实践的是模型的微调(SFT)阶段。以一个规模为70亿参数(7b)的语言模型为例,采用QLoRA策略,仅需单张RTX 3090显卡即能实现有效的微调工作。即便是个人研究者或小型团队,也能在有限资源下对大规模预训练模型进行微调。

要从头训练一个很大的模型,个人很难有那个算力。不过,如果只是想弄清楚预训练是怎么做的,拿一个小模型亲手跑一遍就行。

本文以仓库“baby-llama2-chinese”(https://github.com/DLLXW/baby-llama2-chinese)为例,通过实战来了解大语言模型预训练的大致步骤。

数据下载

高质量的数据对模型性能至关重要。该项目选用五个数据集:中文版Wikipedia、中文版BaiduBaiKe、C4、中文悟道以及shibing624医疗数据。前面四个数据集对于大家来说应该比较熟悉。其中,C4数据集基于Common Crawl项目,经过额外的清理、过滤和处理。

数据预处理

下载好原始数据集后,按道理来说需要经过复杂的预处理,比如处理缺失值、去重、处理异常值、文本规范化等操作。该项目为了简单起见,略过了这些操作。

预训练的数据格式和SFT的数据格式不同。对于SFT的数据,一条数据一般是一个字典,这个字典包括prompt和output两个字段,分别对应着提问和回答。这个项目不把预训练数据做成问答对。它先把分好词的文档用结束标记隔开,拼成一串 token,再分段训练。

以中文Wikipedia数据为例,该数据集由254,547个词条组成。在该项目的预处理方案中,所有词条经过分词,然后连接成一个统一的文本串。在连接时,在相邻词条间插入特定分隔符“[EOS]”。

数据预处理的命令是:

python data_process.py

这个文件里的不同函数可以分别处理五个数据集,目的是把一条一条的数据拼成一串 token。在将数据连接之前,需要先进行分词操作。分词后的 token 编号用 np.uint16 存储,uint16是指无符号16位整数,它可以表示的范围从0到(2^16)-1,即最大到65535。

本项目所使用的分词器是来源于chatglm2-6b模型。分词器可以通过以下两种方式进行加载:

tokenizer = AutoTokenizer.from_pretrained('./tokenizer',trust_remote_code=True)

或者

tokenizer = ChatGLMTokenizer(vocab_file='./tokenizer/tokenizer.model')

tokenizer文件夹下包含三个文件:tokenization_chatglm.py,tokenizer.model和tokenizer_config.json。这些文件是直接从chatglm2-6b模型的文件夹中提取出的。用 uint16 就能存下这些 token 编号。

Dataset和Dataloder

该项目提供了一个较为基础的预训练实现,没有使用高级的封装(如 Transformers 库中的 Trainer),而是手动定义了 Dataset 和 DataLoader。

我使用的是百度百科数据,整体数据规模较小。分词后的 token 编号存在二进制文件里,Dataset 用 np.memmap 读取,不用先把整个文件搬进内存。整个数据一共有 136319488 个 token,排成 266249 行,每行 512 个,相邻两段不重叠。

具体怎么切呢?第一段是第 0 到 511 个 token,第二段是第 512 到 1023 个 token,后面照这个办法往下切。

在__getitem__方法中,定义了每条数据的X和Y:

def __getitem__(self, index: int):
    sample = self.data[index]
    X=np.array(sample[:-1]).astype(np.int64) #shape=(511,)
    Y=np.array(sample[1:]).astype(np.int64) #shape=(511,)

每段 512 个 token,前 511 个当 X,后 511 个当 Y,刚好错开一格。这样每个位置要猜的,就是后面那个 token。
这里为什么要从 uint16 转成 int64?Y 存的是正确 token 的编号,PyTorch 的交叉熵函数(F.cross_entropy)要求这种标签用 int64。

预训练

预训练的命令是:

torchrun --standalone --nproc_per_node=1 pretrain.py

根据GPU卡的数量来调整nproc_per_node参数的值。

该项目作为入门学习LLM的演示示例,使用到的模型规模不大:hidden_dim=512,layers=8,总参数量是58479616,约等于0.06B。

在训练过程中,每次将shape=32*511的input(即代码中的X)输入到模型中,输出logits,形状为32*511*64793。每个位置给 64793 个候选 token 各打一个分。接着,将输出的logits与shape=32*511的label(即代码中的Y)做交叉熵计算,这样就得到了loss,用来指导梯度下降。

我使用 A100的显卡,在15分钟就完成了一轮的训练。

训练效果

以“感冒的症状是”为例,来看看模型是否能生成我想要的结果。

对于完全没有经过任何训练的模型,参数是随机初始化的,产生的输出如同随机生成:

bagsomer printf歎酎颓 FacilityⒺ体****擀 shortage穹穹溟 possessionsnis等功能 Campaign beatingitarian已达Sm

经过了一轮百科数据的训练,产生的输出如下:

感染可能会造成病因。
21日,北京大禹的传染病药物检查与临床试验开始,严重急性,慢性肝、心核、心痛、肺膜炎和肝素水平皆没有异常作用。但以54型冠状病毒剧疫苗为辅助,使大禹能够安全有效的治疗,因而长期服用疫苗,如服用或治疗多种相关药物。该治疗可能影响中国境内癌症,但相关患者的癌症。

虽然也是胡说八道,但至少能看出回答属于医学话题。

知乎原文

深入探究Float32数据格式的精度问题

float32 通常能保住六七位有效数字。这里是从第一个非零数字开始数,不是小数点后固定六七位。那在不同大小的数附近,它能把多近的两个数分开?

float32的定义

首先回顾float32的定义:float32(单精度浮点数)是一种在计算机中表示小数的数据类型,其中32位(即4字节)用来表示一个浮点数。这种表示方法是基于IEEE 754标准来定义的,其包括1位符号位、8位指数位和23位尾数位。这种表示方法的主要目的是在计算机中实现有限精度、动态范围和浮点数的编码,以便在各种计算任务中使用。float32 类型分为以下三个部分:

  1. 符号位(Sign bit):用于表示浮点数的正负性,占1位,0表示正数,1表示负数。
  2. 指数位(Exponent):用于表示浮点数的指数部分,占8位。这8位存下来的数可以是0到255。对规格化数,把它减去127,就得到了实际指数。
  3. 尾数位(Mantissa):用于表示浮点数的尾数部分,占23位。这部分表示的是一个二进制小数,将按照二进制小数解析;对规格化数,还要在前面加一个隐含的1。

float32的计算原理

从float32转成32位表示

任意一个float32类型的数值都可以转换为一个32位的二进制数串。以数值6.75为例,下面来详细解析这一转换过程。

在二进制中,6.75 可以表示为 110.11,因为:

4 (2²) 的二进制表示是 100
2 (2¹) 的二进制表示是 010
0.5 (2⁻¹) 的二进制表示是 .1
0.25 (2⁻²) 的二进制表示是 .01

接着,按照IEEE 754的32位浮点数格式,我们需要把这个数表示为1.xxxxx的形式(科学计数法): 1.1011 * 2²。

(注意一个细节:在二进制科学计数法中,所有非零数都将表现为1.xxxxx的形式。这意味着整数位一定只有一位,且该位的值一定是1。这是二进制科学计数法的一个独特特性,有利于我们规范和简化浮点数的表示。但是在非规范化的浮点数中,会出现http://0.xxx的形式)

现在,参照IEEE 754的32位单精度浮点标准:

  1. 符号位:因为 6.75 是正数,所以符号位是 0。
  2. 指数:指数为2,在其基础上加上127(因为IEEE 754的偏移量是127),得到129。129的二进制是 10000001。
  3. 尾数:尾数是 1.1011 中除了隐含的那个1之外的部分,即小数 1011。由于尾数部分在IEEE 754格式中达23位,我们需要在这个小数后面添加0,直至长度为23位。所以 1011 后面跟了19个0:10110000000000000000000。

组合这些部分,我们得到了 6.75 的32位IEEE 754浮点数表示:

0 10000001 10110000000000000000000

每一部分分开解释是:

  • 符号位:0 (因为它是正数)
  • 指数:10000001 (等于2加上偏移量127,在二进制中等于129)
  • 尾数:10110000000000000000000 (表示 .1011 并在后面填充0直至总长为23位)

如果将上述二进制串转换成32位IEEE 754格式的二进制串,它将会是:

01000000110110000000000000000000

用代码验证:

import numpy as np
import struct

def float_to_bin(num):
    return format(struct.unpack('!I', struct.pack('!f', num))[0], '032b')

def bin_to_float(bin_str):
    return struct.unpack('!f', struct.pack('!I', int(bin_str, 2)))[0]

# 使用方式
num = np.float32(6.75)
print(float_to_bin(num))

bin_str = "01000000110110000000000000000000"
print(bin_to_float(bin_str))

从32位表示转成float32

可以使用相同的方法将32位表示转换为float32。这个方法适用于规范化数和非规范化数(将在后文解释非规范化数的定义)。以下是转换方法的概述:

  1. 从给定的32位表示中获取符号位、指数位和尾数位。
  2. 对规格化数,把存下来的指数减去127。指数位全为0时,指数用−126;全为1的情况另有用途,这里先不展开。
  3. 对于规范化数,假设尾数左边隐含有一个 1(即计算1.xxx),并将尾数转换为十进制。对于非规范化数,小数部分将是0.xxx,不再隐含一个1。
  4. 使用以下公式计算十进制浮点数:value = (-1)^(符号位) × 2^(实际指数) × (尾数),对于规范化数尾数为1.xxx,而非规范化数尾数为0.xxx。

例如,给定32位表示 00111110001000000000000000000000。在这种情况下:

  1. 符号位 = 0,指数位 = 01111100,尾数位 = 01000000000000000000000
  2. 指数为01111100(十进制124),实际指数为124 - 127 = -3。
  3. 因为这是一个规范化数(指数位既不全为0,也不全为1),我们将尾数视为 1.010… (二进制)。转换为十进制,我们得到尾数为 1.25。
  4. 应用公式:value = (-1)^0 × 2^(-3) × 1.25 = 1 × 0.125 × 1.25 = 0.15625

float32格式的表示精度为什么一般是六七位?

先用代码试一下:1.12345678 和 1.12345679,存成 float32 以后还是两个不同的数吗?

import numpy as np

# 将数字转换为float32类型
f1 = np.float32(1.12345678)
f2 = np.float32(1.12345679)

# 打印转换为float32之后的数值
print(f1)  # 输出: 1.1234568
print(f2)  # 输出: 1.1234568

# 检查它们是否相等
print(f1 == f2)  # 输出: True

这两个数存下来变成了同一个数。为什么会这样?

现在重新审视对数值6.75的转换过程。6.75的二进制形式是1.1011 * 2²,其中2是指数,1011是尾数。保持指数不变,对尾数进行微调。考虑这样一个问题:在float32格式中,距离6.75最近且大于6.75的数值是什么?这个数肯定存在,因为float32表示的数值是有限且离散的。

回忆一下6.75在32位字符串中的表示:01000000110110000000000000000000。

我们只需要将这个二进制串加一,得到:01000000110110000000000000000001。

该串表示的数值是6.750000476837158。
它与6.75之间的差值是0.000000476837158。
这意味着在float32的数字世界中,紧接在6.75之后的数字是6.750000476837158。而数值6.75到6.750000476837158之间的任何数在float32中都不存在。这有点类似于量子力学的概念。

再次将二进制串加一:01000000110110000000000000000010。

得到的数值是6.750000953674316。
在6.750000476837158到6.750000953674316之间的数值,在float32中也是不存在的。

知道了间距,再看看几个具体的数会被舍到哪里。

经过上面的计算,我们知道6.75的下一个数是6.750000476837158。在NumPy的取舍机制下,对于位于6.75和6.750000476837158之间的数值,若更接近6.75,则会被舍入为6.75;若更接近6.750000476837158,则会被舍入为6.750000476837158。

拿 6.7500004、6.7500005 和 6.7500008 来试。前两个数舍入成了同一个数,第三个落到了下一个格点。6.75 附近,相邻两个格点隔着大约 0.00000048。

import numpy as np

# 将数字转换为float32类型
f1 = np.float32(6.7500004)
f2 = np.float32(6.7500005)
f3 = np.float32(6.7500008)


# 打印转换为float32之后的数值
print(f1)
print(f2)
print(f3)

# 检查它们是否相等
print(f1 == f2)
print(f2 == f3)

一千万附近,还能存下小数吗?

上面说到,任意一个float32类型的数值都可以转换为一个32位的二进制数串。在这个转换过程中,我们首先需要将这个数表示为二进制的科学计数法。当数字变大,整数部分占的位数也可能变多。

以数值10000000.1234567为例。我们首先将整数部分和小数部分分别转换为二进制形式:
10000000(十进制)转为二进制是100110001001011010000000,
0.1234567(十进制)转为二进制的结果是0.0001111110011010…。
结合这两部分,我们得到100110001001011010000000.0001111110011010…。

可以看到,一千万的整数部分刚好用满 24 个二进制有效位(算上开头那个不存的 1)。float32 能保留的有效位也只有这么多,所以 10000000.1234567 会舍入到 10000000。

把舍入后的数写成二进制科学计数法,得到 1.00110001001011010000000 × 2^23。

接下来,我们需要将符号位、指数位和尾数位相结合形成最终的32位二进制表示。对于10000000.1234567这个正数,符号位是0,因此组合在一起的形式是:0 10010110 00110001001011010000000。这个数实际上表示的是10000000。

如果我们将这个二进制串增加1,得到0 10010110 00110001001011010000001,表示的数实质上是10000001。

所以一千万的下一个邻居是 10000001,中间没有别的数能存下来。

如果这个数增长到九位数,间距会更大。123456789 存成 float32 会舍入到 123456792,32 位表示是 01001100111010110111100110100011。再把这个位串加一,得到 01001100111010110111100110100100,对应的是 123456800。

这两个数隔了 8。也就是说,在这个大小附近,已经不是每个整数都能精确存下来了。

下面的代码段能够进一步验证这一点:

import numpy as np

# 将数字转换为float32类型
f1 = np.float32(123456789)
f2 = np.float32(123456791)

# 打印转换为float32之后的数值
print(f1)
print(f2)

# 检查它们是否相等
print(f1 == f2)

执行后,你会看到,尽管f1和f2在输入时是不同的数,但在转换为float32后,它们实际上是相同的数。这就是由于float32的精度限制。

扩展:间距什么时候恰好是 1?

上面看到,数越大,相邻的两个格点就可能隔得越远。那间距从什么时候开始变成 1 呢?

注意到尾数位有23位,当尾数位增加1的时候,换算成十进制,十进制增加2**(-23)。回到公式:value = (-1)^(符号位) × 2^(实际指数) × (尾数)

当尾数增加1的时候,要让value增加1,此时这个乘法的第三个因子已经增加2**(-23)了,因此第二个因子应该是2**(23)。也就是说,实际指数应该是23.

换句话说,从 2^23 往上找下一个能表示的数,刚好隔 1。

很小的数附近,两个格点隔多远?

先从去规格化数说起

刚才把数字越换越大,格点隔得也越来越远。那反过来,靠近零的时候呢?

考虑能够被float32表示的最小正数:

在IEEE 754单精度浮点数表示中,0 00000000 00000000000000000000001是一个非规范化浮点数。我们可以使用下面的步骤将此二进制表示转换为十进制浮点数:

  1. 获取符号位、指数位和尾数位:
    符号位:0 (代表正数)
    指数位:00000000
    尾数位:00000000000000000000001
  2. 转换指数和尾数:
    指数:指数位全是 0 时,这里用的指数是 −126。
    尾数:由于这是一个非规范化浮点数,所以小数部分将是 0.xxx。只需将尾数的二进制数转换为小数即可。在这个例子中,我们只需将二进制 1 转换为小数:1 * 2^(-23)。
  3. 通过下面的公式计算十进制浮点数:
    value = (-1)^(符号位) × 2^(实际指数) × (尾数)
    = (-1)^(0) × 2^(-126) × (1 * 2^(-23))
    = 1 × 2^(-126) × 2^(-23)

通过计算可得 value ≈ 1.401298464324817 * 10^(-45),这就是0 00000000 00000000000000000000001对应的十进制数。

在IEEE 754标准的浮点数表示法中,指数为全零表示指数部分所有位都是0。在float32这样的单精度浮点数中,这意味着指数部分由8个连续的零组成,即:00000000。
当指数为全零时,浮点数表示将进入"去规格化"(denormalized)模式。在这个模式下,尾数部分不再隐含一个前导1,相反,它成为了显式表示,必须单独考虑。这种表示方法主要用于非常接近于零的非常小的浮点数,从而确保了浮点数表示可以覆盖更广泛的范围。
所以,当指数为全零时,实际上,这代表了一个特殊的浮点数表示模式,即用于处理非常小的数值的去规格化模式。

32 位字符串 0 00000000 00000000000000000000001 对应的数约是 1.4E-45,下一个 0 00000000 00000000000000000000010 对应的数约是 2.8E-45。它们之间只隔了大约 1.4E-45。这是间距小,不是有效数字多了几十位。

接下来再看看,最小的规格化正数和它的下一个邻居隔多远。

规格化数

在 IEEE 754 单精度浮点数(float32)里,最小的正规格化数是 1.1754943508222875e-38。对应的 32 位二进制字符串是 0 00000001 00000000000000000000000。

解析如下:

  • 符号位:0,表示这是一个正数。
  • 指数位:00000001,表示实际的指数值是 1 - 127 = -126,减去127是因为指数部分以偏置形式存储,存储的值等于实际指数值加上偏置值127。
  • 尾数位:00000000000000000000000,这表示小数部分值为0。

因此,该值的真实大小为:(-1)^(符号位) × 2^(实际指数) × (1 + 尾数) = 1 × 2^-126 × 1 = 1.1754943508222875e-38。

这个数的下一个数是 0 00000001 00000000000000000000001,对应的数是1.175494490952134e-38

我们用代码验证下:

import numpy as np

# 将数字转换为float32类型
f1 = np.float32(1.1754943508222875e-38)
f2 = np.float32(1.175494490952134e-38)

# 打印转换为float32之后的数值
print(f1)
print(f2)

# 检查它们是否相等
print(f1 == f2)

这两个数转成 float32 以后不相等。它们之间的间距也是大约 1.4E-45,和前面那两个最小的正数一样。

扩展:为什么 FP16 更容易上溢?

梯度爆炸说的是梯度在反向传播里越变越大。FP16 能存的最大数比 FP32 小很多。同一个很大的梯度,FP32 还存得下,换成 FP16 可能就变成无穷大了。

知乎原文

详解GPT生成机制中的kvcache

一句话介绍

kvcache是一种以空间换时间的策略,能够加快语言模型的生成速度。

要明白kvcache,需要弄明清楚两个细节——GPT 的生成机制以及注意力掩码 (attention mask)。

GPT的生成机制

在介绍 kvcache之前,我们需要先了解生成式预训练模型(GPT)的生成机制。GPT 是泛指所有生成式语言模型,而非特指 OpenAI 的 GPT 产品。在生成句子时,GPT 采用逐个 token 的方式进行生成,而非一次性生成整个句子。如下图所示,例如模型的初始输入是 “Lionel Messi is a player”,将这句话送入模型后,模型预测下一个词为 “who”。接着,在第二阶段,将 “Lionel Messi is a player who” 输入模型,并再次进行推理,模型此时预测下一个词为 “has”。如此循环,逐步生成新词。整个过程将在预测结果中出现 “<eos>” 这个 token 时结束。

原文配图

以下是自回归语言模型生成机制的简化版本代码:

    while out_token != token_eos:
        logits, _ = model(in_tokens)
        out_token = torch.argmax(logits[-1, :], dim=0, keepdim=True)
        in_tokens = torch.cat((in_tokens, out_token), 0)
        text = tokenizer.decode(in_tokens)

具体的原始代码可以在 Transformers 库源码中找到,路径为:transformers/src/transformers/generation/utils.py。这里提供的代码是简化版本,并且采用的是贪婪搜索方式来生成结果。在这个策略下,模型生成的结果没有随机性,它总会选择概率最高的 token,从而方便重现模型。

在上述代码中,“in_tokens” 是模型的输入,原始文本经过分词后,根据词汇表的映射关系,将转化为一串整数序列,即模型的输入。

“_logits, _ = model(in_tokens)” 这行代码代表模型对输入进行推理。模型输入经过处理后,通常会生成两部分内容,分别为 logits 和 kvcache。

我们假设模型的词汇表大小是46145,模型输入的token数量是10,batchsize为1,那么logits的shape是1*10*46145。(注意,上面的代码只是简化版本的代码,仅用于示例。在有些模型的代码中,模型输入经过一次推理后,得到的结果是hidden_state,假如模型的hidden_size是4096,那么模型的输出hidden_state的shape是1*10*4096,而非1*10*46145。hidden_state再经过一个线性层之后,变成了1*10*46145的矩阵,这便是logits)。

这个1*10*46145的logits应该怎么理解?除非你正在计算困惑度,否则前9行都是无意义的。第10行包含了 46145 个值,代表模型对下一个词的概率分布预测。在贪婪搜索策略下,我们选择概率最高的作为下一个词的预测。例如,在这 46145 个分量中,第 168 个分量的值最大,然后在词汇表中查找,如词汇表中第168个分量的 token 是 “big”,那么模型预测下一个词即为 “big”。

注意力掩码 (attention mask)

首先,我们来了解一下注意力矩阵。假设 hidden_size = 4096,batch_size = 1,输入长度为 10,注意力头数为 32,则q/k/v 矩阵的形状均为 1x32x10x128。在计算过程中,q 和 k 的转置(1x32x128x10)在后两个维度进行矩阵乘法,得到一个 1x32x10x10 的矩阵,这就是注意力矩阵。其中 32 代表注意力头数,10x10 描述了这句话中 10 个 token 之间,每两两个token的注意力情况。比如第三行第四列描述的是第三个token对于第四个token的注意力的情况。

注意力掩码在 Transformer 结构中会出现两次。首先是对输入的mask(这部分与本文主题关系不大,但不妨顺便介绍)。由于每个序列的长度不同,我们通常需要对较短的序列进行填充,使其长度与一个批次中最长的序列相同,以便进行并行计算。例如,设定某批次的语料长度为 10,但“团结就是力量”仅有 6 个 token,因此需要在后面进行填充操作,变为“团结就是力量<<pad>><<pad>><<pad>><<pad>>”。但是 <<pad>> 仅仅用于占位,没有实际意义,其他词不应对其产生注意力。因此,在计算注意力时,需要将其进行掩码处理。掩码把右边 4 列遮住。每行做完 softmax,被遮住的位置权重是 0,左边 6 列分掉这一行的全部权重。下面两张图里的 0 只是标出哪里被遮住;左边保留的是原来的分数,不是 softmax 之后的权重。

原文配图
未经过mask的注意力矩阵
原文配图
经过mask的注意力矩阵

(严格来说,在大多数语言模型的实际应用场景中,一段语料的开头通常是由一个特殊的token:<s>符号来表示。这个符号与<pad>符号具有相同的性质,都属于特殊的token。其中,<s>符号代表语料的起始部分。)

第二次用到掩码的是在计算自注意力阶段。由于在生成一个新单词时,仅允许访问之前的上下文,不能查看该单词之后的信息。正如之前所提到的,GPT 的生成机制采用逐词生成方式。在生成阶段,模型不能看到后面的词语,因此需要通过设置注意力掩码来确保模型只关注当前位置之前的单词。

在 1x32x10x10 的注意力矩阵中,第一个 token 只能关注到自己,第二个 token 只能关注到第一个和第二个 token,以此类推。画在矩阵上,就是把对角线右上方的位置都遮掉,对角线本身留着。

下面展示了 attention_mask 的一个左上角小段示例,即方阵中前 3 行与前 3 列:

print(attention_mask[0,0,:3,:3])
tensor([[ 0.0000e+00, -3.4028e+38, -3.4028e+38],
        [ 0.0000e+00,  0.0000e+00, -3.4028e+38],
        [ 0.0000e+00,  0.0000e+00,  0.0000e+00]])

在上述输出中,-3.4028e+38代表了float32数据类型中可能的最小数值,通常被视作负无穷大。在注意力计算中,这个值用于有效地屏蔽(或阻断)不相关的token,故而注意力模型只会专注于合理的、先行出现的token序列。

下图还是用 0 标出被遮住的位置:

原文配图

没有kvcache的情况下,推理过程的细节

上面已经详细讨论了GPT的生成机制以及注意力掩码(attention mask)。接下来,我们将探讨kvcache的作用和含义。

首先,我们来看看在缺少kvcache的情况下,推理过程的具体细节是怎样的。本节使用到的例子和图像来自于知乎用户 @看图学 (文章链接:看图学:大模型推理加速:看图学KV Cache)。我推荐读者阅读这篇文章,我个人也是从这篇文章中,理解了kvcache的执行过程和作用。

在第一步,把<s>传递给模。在语言模型中,<s>一般代表一段语料的起始。

让我们再次回顾一下transformer的一次自注意力的运行过程。假设一个模型的hidden_size是4096,为了简化讨论,我们假设该模型没有使用多头注意力,即只有一个注意力头,并且我们在此过程中不考虑batchsize的维度。<s>经过嵌入(embedding)层后将转换为一个1x4096的tensor。接着,我们将这个tensor分别经过三个线性层,得到q/k/v,它们的形状都是1x4096。

在计算注意力的时候,先拿 q 和 k 的转置相乘,得到一个 1×1 的分数矩阵。分数按维度缩放,再按行做 softmax,才是注意力权重。这里一行只有一个位置,也就是 <s> 自己,所以权重是 1。用权重乘上 v,就是下面的 att,形状还是 1×4096。

下面这张图细致地展示了该流程:

原文配图

经过这一次自注意力的操作,得到了新的注意力表示:
Att1=a11V→1=V→1{\color{orange}{Att_1}} = a_{11}\vec{V}_1 = \vec{V}_1

需要注意的是,以上所述仅为自注意力计算的一次过程。实际上,在基于transformer结构的模型中,通常会经历多次自注意力计算。

假设在单次推理过程中(这指的是模型通过所有层的过程,而非仅通过一个自注意力层),我们模型预测<s>的下一词为“遥”。根据GPT的生成机制,在第二个步骤,我们将"<s>遥"送入模型。此时,模型输入经过embedding层后,就会变成一个形状为2x4096的tensor。生成的q/k/v的形状也都是2x4096。q和k的转置进行矩阵乘法,就得到了一个形状为2x2的注意力矩阵。第一行右上角的分数会被掩码遮住,做完 softmax 以后,这个位置的注意力权重才是 0。以下图像清晰地展示了这个过程:

原文配图

下面用 aᵢⱼ 表示第 i 行做完 softmax 后第 j 列的权重。同一行的分数要放在一起算:
Att1=a11V→1=V→1Att2=a21V→1+a22V→2\begin{aligned}{\color{orange}{Att_1}} &= a_{11}\vec{V}_1 = \vec{V}_1 \\ {\color{red}{Att_2}} &= a_{21}\vec{V}_1 + a_{22}\vec{V}_2\end{aligned}

这里的 Q1K2TQ_1 K_2^T 不一定是 0。被遮住的是这个位置,变成 0 的是 softmax 之后的权重。第一个 token 只看自己,所以第一行的输出就是 V₁。

假设在此次推理过程中,模型最终预测出“遥”这个token。接下来,在第三个阶段,我们将"<s>遥遥"输入模型。具体细节在此不再赘述,以下图像展示了该过程:

原文配图

其计算公式为:
Att1=a11V→1=V→1Att2=a21V→1+a22V→2Att3=a31V→1+a32V→2+a33V→3\begin{aligned}{\color{orange}{Att_1}} &= a_{11}\vec{V}_1 = \vec{V}_1 \\ {\color{red}{Att_2}} &= a_{21}\vec{V}_1 + a_{22}\vec{V}_2 \\ {\color{purple}{Att_3}} &= a_{31}\vec{V}_1 + a_{32}\vec{V}_2 + a_{33}\vec{V}_3\end{aligned}

模型的输入为"<s>遥遥",包含3个token,shape是3x4096。att矩阵同样是3x4096,其每个位置都对应了预测输入位置下一词的信息。例如,att矩阵的第一行代表 "<s>"后面的token的预测信息。att矩阵的第二行,则代表 "<s>遥"后面的token的预测信息。

我们可以从公式中看出,计算第 k 行的 att,在所有的 Q 里只用第 k 个 Q,不用前面的 Q。这就引出了一个问题:在推理的过程中是否能只将第k个token送进模型内?答案是否定的。因为尽管第k个att只与对应位置的q有关,但它却依赖于前k-1个token生成的k和v。

换种说法,每个token都会生成一个q向量,一个k向量和一个v向量。当我们计算第k个token对应的att时,尽管只会使用到第k个token的q向量,但它要用到前面所有 token 以及它自己的 K 和 V。

因此,有研究人员想到,每次只需要将第k个token,以及之前的k和v送给模型,而不是将全部的k个token送进模型,不就更加高效吗?这就是kvcache的诞生原因。

换言之,在transformer中,k个token将产生k个q向量,k个k向量,和k个v向量。而由于我们只关心第k个token的下一个词,我们可以选择只将第k个token,以及前k-1个token的k向量和v向量送入模型,而无需将全部k个token输入模型。这样做将极大提高计算效率。

也就是说,在模型的第三阶段,我们原本需要将"<s>遥遥"送进模型,但现在我们可以仅仅只将"遥"以及之前"<s>遥"生成的k和v的值送入模型。

一些关于kvcache的疑问

还是看第三步:如果把前缀从「<s>遥」换成「<s>先」,却把「<s>遥」的旧 K、V 照样传进去,会发生什么?模型用的还是旧上下文。真要把前面的「遥」换成「先」,就得从改动的位置开始重算。那模型不止一层,又怎么办?每层都有自己的 K、V 缓存:第二层新收到的是最后一个 token 的向量,前面两个位置的 K、V 则从第二层自己的缓存里拿。

有人可能还会有一个疑问:对于transformer模型来说,我们仍然有线性层。这些线性层是否会将各行信息融合在一起?我的回答是,不会。尽管我在前文中强调了只需要计算att的第三行,而前两行并无实际意义,但经过mlp层后,信息并不会互相融合。我曾经有过类似的疑惑,但后来我发现我误解了transformer中的线性层。transformer中的线性层是针对hidden_size这个维度的变换,这并不会影响length维度,因此不同的token之间的信息并不会交互。在transformer模型中,token之间信息的交互只在自注意力层发生。

相同地,在Layernorm层也不会产生token之间的信息交互。LayerNorm 是每个 token 自己算自己的。比如第一个 token 有 4096 个数,就在这 4096 个数里算均值和标准差,再逐个减去均值、除以标准差。整个过程不用看别的 token。

有kvcache的情况下,推理过程的细节

在前文中,我们介绍了kvcache的原理:在每个推理阶段(假设当前为第k阶段)中,将第k个token以及前k-1个token生成的k和v送入模型。接下来,我们来讨论推理过程的细节。

让我们再从头开始梳理。在第一个阶段,把"<s>"送进模型,这一点和没有kvcache的情况是一致的。由于hidden_size是4096,因此q/k/v矩阵的shape都是1x4096。然后,我们提取出每一层的k向量和v向量,并作为模型的输出。对于kvcache,模型的输出格式是batchsize * 2 * layers * length * hidden_size。比如,在此例中,假设模型共有32层,那么模型输出的kvcache的形状就是1x2x32x1x4096。

进入第二阶段,在没有使用kvcache的情况下,我们会将"<s>遥"送入模型。但现在,我们使用kvcache的方法,将"遥"和形状为1x2x32x1x4096的kvcache一起送入模型。

在此阶段,"遥"这个token生成的k和v的形状依然是1x2x32x1x4096。和第一阶段生成的kvcache结合,我们得到形状为1x2x32x2x4096的kvcache,这就是此阶段的kvcache输出。

kvcache比较占用内存。如果缓存里已经有 100 个 token,此时kvcache将会占用1x2x32x100x4096=26214400个数值。如果每个数值采用float32储存,一个float32类型占用了4个byte(字节),那么26,214,400 × 4 ÷ 1024² = 100 MiB 的存储空间。