
做LoRA微调的朋友十有八九都见过这段初始化代码一个低秩矩阵A用随机分布填一下另一个低秩矩阵B直接全零。说实话我最早看到这地方的时候心里是有点嘀咕的——既然两个矩阵都是要被训练的为什么非要把其中一个钉死在“零”这个起点上后来有一回我图省事在一个图像分类微调Demo里把B也改成了随机初始化结果训练一开始loss就比预训练模型的基线高出一大截还要震荡好几个epoch才缓过来。那次踩坑之后我才认真把这里面的数学和工程逻辑翻了个底朝天。这篇文章就想把“为什么LoRA必须把一个矩阵初始化为0”这件事讲透包括反向传播里那点容易忽略的细节以及实际代码里写错会踩到哪些坑。1. 先解释LoRA初始化问题为什么值得较真LoRA的核心思路说穿了其实很朴素预训练权重矩阵W0不动在旁边额外加一条低秩旁路用两个小矩阵B和A的乘积来模拟一个低秩增量ΔW。前向传播变成h W0 x B A x其中B的维度是d×rA的维度是r×d这里的r远小于d。训练时只更新B和A预训练权重保持冻结。因为参与更新的参数量大幅减少显存占用和训练耗时都降了下来同时还能把微调得到的增量合并回原权重推理时几乎不增加额外开销。但问题恰恰出在这条旁路的初始状态上。预训练模型经过海量数据训练之后已经落在了损失函数中一个相对不错的局部最优点附近。微调的目标不是从头学一个新模型而是在这个已有最优点的邻域内做小幅调整让它适应下游任务。因此LoRA在训练开始时的输出应当和冻结预训练权重时的输出完全一致。换句话说初始状态下的ΔW必须严格为零。如果B和A都随机初始化那么BA就是一个非零矩阵旁路会在一开始就往模型输出里注入一个随机扰动。这相当于把模型直接从“预训练最优点”硬拽到了一个偏离的位置后续所有层都会带着这个偏差去做前向传播和反向传播。最直接的表现就是初始loss远高于正常基线训练过程容易震荡学习率也得被迫调小。所以“一个矩阵初始化为0”这件事不是什么可有可无的工程习惯它决定了微调是不是真的从预训练模型原有的能力出发。只要这个起点有偏差后面的一切优化都会付出额外代价。2. 拆解数学推导为什么输出矩阵必须全零先把符号约定好。假设输入是x预训练权重是W0低秩旁路由两个矩阵B和A组成前向公式为y W0 x B A x如果B被初始化为全零矩阵那么无论A的值是什么B A这一项计算结果严格为零。于是初始输出退化为y0 W0 x模型在第一步训练时输出的就是纯预训练结果loss也应该等于加载预训练权重但不做任何微调时得到的loss。这是LoRA训练的天然基线也是我们判断初始化是否正确的最直观信号。接下来看反向传播。为了方便讨论记旁路输出对最终损失的梯度为g ∂L / ∂y。根据链式法则B和A的梯度分别是∂L / ∂B g (A x)^T∂L / ∂A B^T g x^T当B是全零矩阵时第二个式子里的B^T为零因此∂L / ∂A严格为零。但第一个式子中由于A是随机初始化的A x通常不为零g也不为零所以∂L / ∂B可以正常拿到非零梯度。2.1 三种初始化方案的梯度对比把几种初始化方案放在一起看你会对“零初始化”的意义有更直观的理解。第一种是B0、A随机初始化。这正是标准做法。初始前向输出等于W0 x反向传播时B有梯度A暂时没有梯度。第一次参数更新后B变成一个非零矩阵第二条通路B^T g x^T就不再为零A开始获得梯度整个旁路进入正常训练状态。第二种是A0、B随机初始化。初始前向输出同样等于W0 x因为A x为零。反向传播时情况刚好反过来∂L / ∂A非零∂L / ∂B为零。第一次更新后A变成非零B才开始获得梯度。从数学上看这也是可行的方案本质上和第一种方案是对称的。第三种是B0且A0。初始前向输出等于W0 x但两个矩阵的梯度全部为零。因为∂L / ∂B里有因子(A x)^TA为零时梯度为零∂L / ∂A里有因子B^TB为零时梯度也为零。这意味着旁路完全瘫痪模型永远学不动训练再久loss都不会变化。2.2 全零初始化的三重视角把这个结果放到更高维度看B0的初始化其实兼顾了三件事。第一前向视角下的无损接入。模型输出在初始时刻严格等于预训练结果不会因为微调结构上的改动而破坏原有能力。这就像在一座已经建好的建筑旁边搭一个临时脚手架脚手架本身不承重、不改变建筑受力只有当你主动加力时它才开始发挥作用。第二反向视角下的梯度激活。虽然A在初始时刻拿不到梯度但通过让B先获得梯度并跨出零值A的梯度通路会被迅速打开。零初始化并没有杀死学习过程而是设定了一个“输出门先打开、输入映射后跟上”的更新节奏。第三损失面视角下的稳定起步。由于初始输出和预训练输出完全一致初始loss就是保底基线。之后每一条路径的更新都是在保底基线上逐步改进训练曲线不会出现“先漂移再纠偏”的浪费阶段。3. 为什么是B置零而不是A置零既然A0、B随机也能满足初始前向输出不变那原版实现为什么偏偏选择了B0、A随机这个问题我在好几个项目群里都看到有人问过答案其实没有很多人想的那么高深。3.1 从数学对称性说起单看公式A和B的位置并不是完全等价的但初始化为零这件事在数学上是对称的。把A置零、B随机前向输出同样是W0x反向传播同样能通过先更新A来激活B。所以我见过的一些手写实现里确实存在这种反向约定而且实验效果和标准做法几乎一致。那为什么LoRA原论文以及后来的主流开源库都用B0这里有两个比较务实的理由。第一是约定俗成带来的兼容性。原论文公开的参考实现里就是A随机、B置零后人在这个基础上复现、对比、扩展自然沿用同一套约定。如果你在微调框架里加载一个官方发布的LoRA权重里面存的也是这种结构。第二是输出侧置零在直觉上更自然。B负责把低秩空间映射回输出空间把它置零相当于关掉旁路的输出阀门整个残差式旁路在初始状态下是“断”的。相比之下如果在输入侧置零随机初始化的B即使暂时被A挡住心里总觉得这个随机量会带来不必要的隐患。3.2 工程与实现层面的真实理由从实现角度看B0还有一个容易被忽略的好处如果错误地把LoRA权重和原权重合并了或者用错误的加载顺序恢复checkpointB0能保证合并后的权重至少和原权重一致把出错后的破坏范围降到最低。而A0、B随机的方案在“未正确处理旁路”时初始状态就可能引入随机扰动排查起来更让人头疼。另外从训练动态上看B0、A随机会让A在初始阶段固定住。换句话说前几步的参数更新可以被理解为在一组固定的随机投影方向上学习最优的输出映射。这本质上是一个随机特征建模过程。等B跨出零值之后A再开始调整两个矩阵协同优化。这个节奏在实践中非常稳定。3.3 一个容易忽略的梯度路径问题这里有一个前端开发者写LoRA时最容易踩的坑。很多人看到B是零矩阵为了省计算直接在forward里写了y base_model(x)跳过了B(A(x))这条支路。这种写法在功能上没错因为你确实算出了W0xB支路贡献为零。但在反向传播时由于计算图中根本没有B(A(x))的节点梯度就无法通过这个子图回流到A和B结果是两个矩阵都拿不到任何梯度训练一动不动。正确的做法是即使B是全零矩阵也要把它放进计算链路里。先算lora_A(x)再把这个结果传给lora_B即使lora_B的权重是零相乘结果为零反向传播的路径是通的。PEFT类的成熟实现里前向分支一定是被完整计算出来的原因就在这里。4. 手写LoRA时初始化怎么落代码如果你需要在代码里手写一个轻量LoRA层初始化部分其实只有几行但每行背后都有讲究。4.1 标准初始化流程一个典型的LoRA层伪代码如下import math import torch import torch.nn as nn class LoRALayer(nn.Module): def __init__(self, in_features, out_features, r, alpha): super().__init__() # 输入先降维到低秩空间输出再恢复到原维度 self.lora_A nn.Linear(in_features, r, biasFalse) self.lora_B nn.Linear(r, out_features, biasFalse) self.scaling alpha / r # A用Kaiming均匀分布随机初始化 nn.init.kaiming_uniform_(self.lora_A.weight, amath.sqrt(5)) # B直接全零 nn.init.zeros_(self.lora_B.weight) def forward(self, x): # 务必走完整分支保证梯度回流 delta self.lora_B(self.lora_A(x)) * self.scaling return x delta这里有几个细节要单独提醒。A的初始化使用Kaiming均匀分布是因为它考虑了ReLU这类激活函数对输入方差的影响能让随机投影的输出保持在合理量级。如果A初始化得过大虽然初始前向输出仍然是W0x但反向传播中A x的值会很大B的第一步更新幅度就会失控表现为训练初期loss剧烈波动。如果A初始化得过小B的初始梯度过小整个旁路要花很久才真正“启动”。B的初始化就是一行zeros_没什么花样。唯一要留意的是你不能省掉这个初始化因为有些框架的Linear层默认会按Kaiming均匀分布初始化权重。如果忘了把B置零所有关于零初始化的讨论都不成立了。4.2 scale、dropout与初始化的配合LoRA还有一个重要的缩放因子scaling alpha / r。这个缩放系数和B0的设计是紧密搭配的。因为初始状态下B A x严格为零所以无论alpha和r的比值是多少初始前向输出都不会受到影响。这给了我们一个安全的调节旋钮通过改变alpha的大小可以控制旁路的有效学习率而不用承担“改动超参导致起点漂移”的风险。如果LoRA层里加了dropout常规做法是把dropout放在A之前或者A的输出之后。放在A之前的含义是输入x经过随机置零后再进入A放在A之后的含义是低秩投影结果先被随机遮掉一部分再交给B。因为B初始为零dropout在初始状态下不影响前向输出但它依然会在计算图中生效反向传播时梯度也会按dropout的mask做对应处理。我自己更习惯把dropout放在A之前。原因很朴素LoRA的输入往往直接是hidden states维度很大先随机屏蔽一部分输入能更早地给低秩投影增加随机性。放在A之后当然也不是不行只是A输出的维数是r可以理解为一种对低秩表征的扰动效果类似看个人习惯。4.3 多卡与混合精度下的细节在多卡训练场景下初始化还有一个容易被忽视的要求所有进程里的LoRA参数尤其是B的零值应该在数据加载之前完全一致。如果某个数据并行进程里的B没有被正确置零而其他进程里的B是零梯度同步时会造成非对称更新最终模型权重在各个replica之间就不一致了。排查起来非常隐蔽因为loss曲线看上去只差了一点点。混合精度训练下零初始化的B在bf16或fp16下依然能精确表示零没有精度问题。但当你对B做第一次更新后它的数值会从零变成一个很小的非零值这时候你需要注意是否开启了weight decay。如果weight decay作用在LoRA参数上它会把B持续往零方向拉。一般这种拉力很小不会完全抵消梯度但如果你同时用了比较大的weight decay或学习率衰减策略B可能长期停留在接近零的低幅状态低秩旁路的有效容量就变小了。我一般会在实验记录里单独统计LoRA权重的均值绝对值半年下来发现这招对判断旁路是否真正激活非常有用。5. 常见初始化问题排查实录下面这些情况是我在项目里或者帮别人看代码时实际遇到过、并且都和“零初始化”有关的典型问题。整理成一张速查表放在这里。现象可能原因处理方式训练第一步loss远高于预训练基线B没有置零或加载权重时B被覆盖成了随机值检查初始化代码打印B的权重统计loss长时间保持预训练基线不变A和B同时为零或用简化的forward绕过了旁路分支确保A是随机初始化且forward里走了完整计算图初始loss正常但前几步loss剧烈震荡A初始化方差过大导致B第一步更新幅度过大改用更小的初始化尺度或降低Learning Rate恢复checkpoint后表现异常LoRA权重和base权重错误合并或B被当普通参数载入确认checkpoint只存LoRA参数加载后重新计算合并权重多卡训练后各卡模型权重不一致局部卡上B初始化失败在dataset加载前打印B的梯度/权重确认全进程一致微调完成后合并权重与原权重输出差异过大缩放因子设置错误或B/A的存储顺序搞混先冻结模型在小batch上对比合并前后的输出5.1 初始loss不正常的排查如果你训练的第一步loss明显高于预训练模型的推理loss第一个检查点就是B矩阵的权重。很多框架在保存模型时会把LoRA的B和A都存成一个字典加载的时候如果用了错误的key或者误把base model的权重当成了LoRA的B加载进来都会破坏B的零值。还有一种情况是前向代码里写了类似“如果B全零则跳过旁路”的裁剪逻辑这种优化在训练时会导致梯度断裂在推理时倒是没问题但训练时一定不要这么写。检查方法很简单加载完模型后打印一下lora_B.weight的sum或abs值。如果它明显不为零初始化大概率出了问题。另外还可以在第一个训练step之前用纯推理模式跑一遍验证集对比预训练模型的输出和LoRA模型的输出。二者应当完全相同任何差异都说明零初始化被破坏了。5.2 训练不更新与梯度为0训练loss完全不变的时候先看梯度的统计。如果lora_A.weight.grad和lora_B.weight.grad都是零那就说明forward里根本没有旁路路径。这个情况在PyTorch里尤其常见有人会把LoRA旁路写成if self.lora_B.weight.any(): out out self.lora_B(self.lora_A(x))这个判断在B是零矩阵时直接跳过了整个分支梯度自然归零。诚然跳过零矩阵的乘法能省一点点计算可是在PyTorch的动态图机制里少了节点就断了梯度流。要优化计算也应该在推理阶段去做比如把B A合并回主权重训练阶段必须保证路径完整。5.3 恢复训练与权重合并的坑LoRA训练完成后通常会把ΔW B A乘以缩放因子合并进W0得到一份可以直接推理的权重。如果你在中途恢复了训练一定要小心这个操作。有朋友踩过这样的坑训练到一半先合并了一次权重然后继续训练结果LoRA的两个矩阵还被更新但base权重已经被改写了前后状态彻底混乱。正确的做法是恢复训练时永远加载没有合并过的基础权重LoRA参数单独加载不要混在一起。我个人习惯是在每次保存checkpoint时同时保存一份“纯LoRA参数”和一份“合并后的推理权重”两个文件分开放。训练恢复用前者线上部署用后者。这样多花一点存储但能省掉大量调试时间。另外PEFT这类库在保存文件里对B和A的key有明确约定如果你自己写加载逻辑一定要照着约定来。顺序反了、维度错了、key对不上都会让B的零初始化状态被覆盖出现初始loss异常。最后补一点经验B0和A随机初始化这件事现在回头看是LoRA设计里最巧妙、也最不起眼的一环。它保证了微调从“无损接入”开始同时通过反向传播的梯度差创造了“一个矩阵先动、另一个后动”的启动顺序。我在实际使用中养成的习惯是每开一个新实验都会先对比一次“预训练直接推理的loss”和“LoRA模型训练第一步之前的loss”。这两个数值一旦对不上我根本不会往下跑一定是哪里有初始化问题。别嫌这一步麻烦它能帮你筛掉大半的配置错误。后面你还可以在这个基础上进一步研究rsLoRA的缩放因子重置、DoRA对幅值方向的拆解但无论怎么扩展那个“初始必须有一个零矩阵”的地基始终没变。