小菜鸟

java菜鸟号正在起航

当大模型“忘记”中间内容时,我们在失去什么

你一定遇到过这样的场景:把一份几十页的技术文档喂给大模型,开头和结尾它都能准确复述,但中间某个关键接口的参数定义却开始胡言乱语。即便上下文窗口扩到百万Token,“Lost in the Middle”依然顽固存在。

这并非单一机制所致。自回归架构的因果掩码偏置、训练中形成的U型位置注意力偏差、Attention Sink对首Token的过度依赖——三者叠加,导致中间区域的语义依赖在推断中被系统性弱化。概率图模型的独特价值,不在于解释这些偏置的成因,而在于它提供了一套结构化损失函数的设计语言,将分散的架构缺陷转化为统一的可优化目标。没有这套语言,修复手段只是孤立trick;有了它,我们才能系统推导、验证并组合这些手段。

本文用PGM的工程透镜重新审视大模型:长上下文依赖为何瓦解?推理路径如何诊断?更重要的是,这套设计语言能带来哪些有明确实现路径但需权衡代价的启示,以及哪些尚未成熟的研究方向。如果你也曾对着Attention Map皱眉,怀疑模型“看到了所有Token却没理解它们之间的关系”,这篇文章就是为你写的。

词嵌入:让动态图成为可能的工程使能器

传统PGM在语言任务前撞上了一堵墙:高维离散空间的相似度计算不可行。“汽车”和“轿车”在图中是两个完全独立的节点,要让模型知道它们语义相近,只能手动加边或依赖共现统计。词汇表扩大到十万级时,这种方式立刻崩溃——稀疏性让绝大多数节点对无连接,未见过的词组合概率归零。这正是关联规则时代的遗留病灶。

词嵌入的真正价值,在于它重构了图节点的语义空间。当每个词被映射到低维连续向量后,相似度通过向量距离隐式表达,不再依赖显式边或共现计数。即使“汽车”和“轿车”从未同现,只要上下文分布相似,向量就会自动靠近。图结构中的“潜在依赖”得以被数据驱动地推断出来。

阅读全文 »

为什么你的Loss函数不叫“KL散度”?PyTorch分类损失实战指南

训练分类模型时,我们几乎总是用nn.CrossEntropyLoss()。但翻开信息论教材,衡量分布差异的正统指标是KL散度。既然KL散度才是分布距离的度量,为什么框架的分类损失都叫交叉熵?

真正用到KL散度时(比如知识蒸馏或标签平滑),nn.KLDivLoss的参数设计又容易让人踩坑:input要不要取log?target是什么格式?reduction怎么选?

本文解决三个工程问题:为什么优化交叉熵等于优化KL散度;PyTorch分类Loss如何按任务选型;KL散度在蒸馏和标签平滑中的正确用法。

一、交叉熵与KL散度的优化等价性

测量误差类比

用一把有固定偏差的尺子测零件长度,读数包含零件真实长度(固定)和系统误差(待消除)。交叉熵是带基准的读数,KL散度是扣除基准后的净误差。真实分布P由数据集决定,其熵H(P)是常数,最小化交叉熵即最小化KL散度。

公式表达为 H(P,Q) = H(P) + D_KL(P||Q),H(P)为常数项,优化方向一致。

PyTorch参数映射:F.kl_div(input=log_Q, target=P) 计算 D_KL(P||Q)。input为模型预测的对数概率,target为真实分布的概率值。若target本身已是log-prob(如教师返回log_softmax),应改用 F.kl_div(log_Q, log_P.exp()) 或手写 (P * (log_P - log_Q)).sum(),避免对log-prob重复取exp引入数值误差。

定义式验证

阅读全文 »

从一串乱码到崩溃的程序: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 的警告,输出结果全是 nan 或 inf。问题出在 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导数硬编码,漏掉就静默错误

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

阅读全文 »
0%