小菜鸟

java菜鸟号正在起航

从一串乱码到崩溃的程序:Softmax计算概率的工程实战

当你调用一个大语言模型生成文本时,它在每一步其实并不是直接“说出”下一个词,而是先吐出一串原始浮点数。比如对于词表中的四个候选Token,模型可能输出 [12.5, -3.2, 8.7, 1000.0]。这些数字被称为Logits,它们本身没有概率含义,甚至可以是负数或远超1的值。要将它们转化为“下一个Token是某个词”的概率分布,我们必须使用Softmax函数。

然而,如果你试图用最直观的数学公式来实现这个转换,程序很可能会在特定输入下崩溃。下面这段完全可运行的Python脚本,复现了这个经典陷阱:

import numpy as np

# 模拟大模型输出层的原始Logits(float64),包含一个极端大值
# 注:若使用float32,exp(88)左右即会溢出,此处为演示清晰采用float64
logits = np.array([12.5, -3.2, 8.7, 1000.0])

def naive_softmax(x):
    """朴素实现:直接套用Softmax数学定义"""
    exp_x = np.exp(x)          # 对每个元素求指数
    return exp_x / np.sum(exp_x)  # 归一化

result = naive_softmax(logits)
print("计算结果:", result)

运行这段代码,你不会得到期望的概率分布,反而会看到类似 RuntimeWarning: overflow encountered in exp 的警告,输出结果全是 naninf。问题出在 np.exp(1000.0) 这一步:IEEE 754双精度浮点数能表示的最大有限值约为 1.8e308,而 exp(1000) 的理论值远超此限,导致上溢。更隐蔽的是,即使输入没有大到触发上溢,在FP16等低精度下,较小的负Logits经过exp后也可能下溢为零,使对应Token的概率丢失。

阅读全文 »

为什么你的 PyTorch 代码“能跑”却“不对”

你可能写过这样的代码:前向传播一切正常,loss 也在下降,但某个关键参数始终不更新;或者 loss.backward() 突然抛出 RuntimeError,提示你“trying to backward through the graph a second time”;又或者训练几个 epoch 后显存悄悄涨满,而你明明已经用了 no_grad

这些问题的根源,往往不在模型结构或数据加载器里,而在你对计算图的心智模型上。PyTorch 的动态图机制给了你极大的灵活性,但也把调试的责任完全交给了你。本文不讲拓扑排序的数学证明,也不带你从零实现自动微分引擎,我们只聊一件事:作为框架使用者,如何建立正确的计算图直觉,并在报错时快速定位到图结构层面的问题。

张量不只是数组:它携带了整张计算图的线索

在 PyTorch 中,张量从来不是孤立的数据块。当你执行 y = x * 2 + 1 时,y 不仅存储了数值结果,还悄悄记住了一件事:“我是由 MulBackward0 和 AddBackward0 两个操作生成的,我的父节点是 x*2 的结果和常量 1。”

下面这段代码展示了这种“记忆”是如何被编码的:

import torch

x = torch.tensor([2.0], requires_grad=True)
w = torch.tensor([3.0], requires_grad=True)
b = torch.tensor([1.0])  # 注意:b 没有 requires_grad

y = w * x + b
loss = y.sum()

print(f"loss.grad_fn: {loss.grad_fn}")        # SumBackward0
print(f"y.grad_fn: {y.grad_fn}")              # AddBackward0
print(f"x.is_leaf: {x.is_leaf}, x.grad_fn: {x.grad_fn}")  # True, None
print(f"b.requires_grad: {b.requires_grad}")  # False

这里有三个关键观察点。首先,grad_fn 是图的边,每个非叶子张量的 grad_fn 指向生成它的算子,该算子又持有对输入张量的引用,这就是反向传播的路径。其次,叶子节点是图的起点,只有 requires_grad=True 且由用户直接创建的张量才是叶子节点,它们的 grad_fn 为 None,梯度最终累积到 .grad 属性上。最后,不参与梯度的张量不保存激活值。b 虽参与前向计算,但因 requires_grad=False,PyTorch 不会为其创建 grad_fn,更重要的是,不会为计算它的梯度而保存上游中间激活值。例如一个冻结的 Linear 层,其输入激活值无需保存用于权重梯度计算;若偏置也被冻结,则输入激活值完全不被保留。这才是冻结参数显著省显存的根本原因,grad_fn 元数据本身的开销其实微不足道。

阅读全文 »

当梯度变成玄学

你大概率经历过这样的时刻:模型loss突然炸成NaN,或者训练了三天发现权重纹丝不动。你检查了数据、调小了学习率、换了初始化方法,最后在一篇论坛帖子里看到有人说“试试把某个算子换成数值稳定的版本”,问题莫名其妙地解决了。

你松了一口气,但心里清楚:自己并没有真正理解为什么。

这种无力感的根源,是我们把 .backward() 当成了一个黑盒。深度学习框架把微积分封装得太过优雅,以至于我们忘记了——计算机其实不懂极限,不懂链式法则,更不懂什么偏导数。它只会做加减乘除和内存读写。当我们写下 loss.backward() 时,机器执行的并不是数学意义上的“求导”,而是一套精心设计的工程流程。

这套流程不是凭空出现的。在它成为工业标准之前,人们尝试过人肉推导公式、用差分逼近斜率、用符号系统代数求导。每一种实践的局限,都精确地指向了下一个突破的方向。

这篇文章不会给你更多调试技巧。它会带你重走这条从“笨办法”到“自动微分”的演进之路。当你理解了机器求导的真实面貌,那些曾经神秘的梯度异常,将不再是玄学,而是可追溯、可解释的工程现象。

起点与困境:从人肉实践到机器算法

.backward() 成为默认选项之前,实践者面对三种选择。第一种是人肉推导,也就是手动微分;后两种是真正的计算机求导算法:数值微分和符号微分。人肉推导是自动微分出现之前的默认实践,后两者是算法尝试。它们各自的局限共同指向了自动微分的必要性。

手动微分:默认实践的不可扩展天花板

# 两层MLP的前向与手写梯度
h = torch.relu(x @ W1 + b1)
y = h @ W2 + b2
loss = ((y - target) ** 2).mean()

# 手动推导的梯度(仅展示W1部分)
dy = 2 * (y - target) / y.size(0)
dh = dy @ W2.T
dW1 = x.T @ (dh * (h > 0))  # ReLU导数硬编码,漏掉就静默错误

严格来说,这不是“让计算机求导”,而是人完成全部数学推导后,机器仅负责执行写好的梯度代码。它是没有算法时的默认状态——当参数只有几个时完全可行,但当模型膨胀到数十亿参数、网络结构频繁迭代时,其不可扩展的天花板便暴露无遗。任何结构调整都意味着重推整条链式法则,工程上已不可持续。

阅读全文 »

为什么说线性模型是深度学习的基石?从 Scikit-Learn 谈起

很多刚接触深度学习的人,容易陷入一个误区。大家往往一上来就去研究复杂的网络结构,觉得基础的线性模型太简单,甚至有点过时。但如果你去拆解那些庞大的网络,你会发现,它们最后输出结果的那一层,往往还是最基础的线性模型。不理解这些基础,就很难真正看懂深度学习。这篇文章我们就从大家最熟悉的 Scikit-Learn 库开始,把线性模型这条线彻底理顺。

预测连续值,从房价预测说起

假设我们要预测房价。影响房价的因素有很多,比如面积、房间数、到地铁的距离。线性模型的做法非常直白,给每个因素分配一个权重,然后相乘相加,最后加上一个基础底价。这就是线性回归的全部秘密。

理论上,只要特征和权重没有限制,线性回归的输出范围就可以是从负无穷到正无穷。Scikit-Learn 实现这个过程只需要几行代码。假设数据已经划分成训练集和测试集。

from sklearn.linear_model import LinearRegression

# 假设 X 是房屋特征,y 是房价
model = LinearRegression()
model.fit(X_train, y_train)
predictions = model.predict(X_test)

这看起来很简单,但在实际工程中,我们很容易遇到一个大坑,那就是过拟合。如果你的数据里不仅有面积,还有“前房主养的宠物数量”这种毫无关联的特征,模型为了在训练集上拿高分,可能会强行给宠物特征分配一个权重。这导致模型“死记硬背”了训练数据,在预测新房时一塌糊涂。

为了解决这个问题,我们需要给模型戴上紧箍咒,也就是正则化。常见的紧箍咒有两种。

阅读全文 »

下一个词最可能是什么:手写一个 Bigram 语言模型

你每天都在用语言模型

你在 IDE 里敲下 res 三个字母,补全列表已经弹出 response_body、response_time 两个候选。你按下 Tab,整行补完。这个动作背后没有魔法,编辑器只做了一件事:估算在当前上下文之后,下一个词最可能是什么。把这件事做成一个可以训练、可以查询的模型,就是语言模型的全部工作。

本文不从公式出发。我们从一份只有两句话、六个词的语料库开始,亲手把大模型的统计学祖先造一遍:数次数、做除法、填一张表。走完这一程,你会清楚三件事:语言模型算的到底是哪个概率;为什么数次数这种朴素操作是正经的统计估计而不是经验口诀;以及这张表天生带什么缺陷,逼出了后续所有的平滑技巧乃至神经网络。

阅读门槛只有会写循环和会查表。文中会出现公式,但每个公式都先落在代码或直觉上,再出现符号。

把预测写成一张表

把预测下一个词写成程序需求:输入已经出现的词,输出下一个词的概率分布。最诚实的实现是一张哈希表,键是完整前文,值是各后继词的计数。麻烦在于键的空间随前文长度指数膨胀,真实语料填不满它的零头,绝大多数查询永远 miss。一张几乎查不到的表,等于没有表。

统计语言模型的出路是主动丢信息:不看完整前文,只看最近一个词。我们把用来做依据的前一个词叫前驱词,把要预测的那个词叫后继词。只靠前驱词预测后继词的模型就是 Bigram,键缩短成一个词,表的大小降到词汇量级别,一次遍历语料就能填完。代价是模型变健忘,决定后继词的只有紧邻的前驱词,更远的上下文一律无视。本文全程使用 Bigram,因为它小到可以手算,又完整保留了这套方法的全部优点与全部缺陷。

截断历史之后,句子概率可以拆成连乘:整句概率等于逐词条件概率的乘积,每个条件概率只询问前一个词。下一节我们就把这套连乘套到一个六词语料上,亲手算一遍。

手算一遍:六词语料上的 Bigram

理论说完,直接上手。假设语料库只有两句话:

datawhale agent learns
datawhale agent works

现在要计算新句子 datawhale agent learns 出现的概率。按 Bigram 假设,它拆成三个因子相乘:

阅读全文 »
0%