
做多模态联邦微调的人迟早会撞上同一个尴尬你在一开始把整个系统设计成三个模态一起训结果真正跑起来之后发现不少客户端传回来的数据里压根没有其中某一个模态。有没有图文对有。有没有语音偶尔有。有没有音频大多数设备根本没这项采集权限。缺一个模态常规多模态微调方法——包括现在用得最广的 LoRA——就很可能会出问题因为从模型结构到训练目标默认都是“每个样本完整多模态”的。最近这个方向有一个名字被反复提起FediLoRA即 Heterogeneous LoRA for Federated Multimodal Fine-tuning under Missing Modalities。初看这个名字有点长但它指的其实是在“客户端之间模态分布不一致、且单个客户端随时可能缺少某种模态”的前提下如何用异构 LoRA 把联邦多模态微调这件事做稳。我读这类文章的时候关注的从来不是公式有多炫而是三个问题为什么必须这么做、具体怎么做、以及我拿到自己的项目里会不会翻车。这篇博文也按这个逻辑来写把 FediLoRA 拆成四个部分讲先讲清楚模态缺失为什么能让朴素方案失效再讲它的异构低秩空间与模态感知聚合机制是怎么设计的接着给一个最小教学原型你会看到 mask、低秩矩阵切分、服务器聚合在代码里到底长什么样最后是实操中的坑、适用边界和我的实际体会。适合正在做联邦学习落地的工程朋友也适合刚接触多模态微调、想搞懂 LoRA 变体的学生。1. 模态缺失的联邦多模态场景LoRA 为什么常常“失灵”先把我为什么觉得这个问题绕不开讲清楚。联邦学习本身要解决的就是“数据不动模型动”数据留在各客户端模型参数或更新梯度在服务器和客户端之间传递。多模态场景会让这件事立刻复杂好几倍因为不同客户端的数据构成天然不一样。我接触过的实际项目里大概分三种情况都是典型的模态缺失问题。1.1 三种常见的模态缺失原型第一种是采集能力不完整。比如一个车载语意识别项目有的车有工作正常的视觉传感器有的车只能提供语音信号还有的车连 GPS 导航文本都不稳定。你没法要求所有车都一样更不能把缺模态的样本直接扔了因为那部分客户端可能恰恰代表了重要的边缘场景。第二种是隐私或权限约束。比如某医疗影像团队想做跨院的图像-文本联合模型一部分合作单位同意共享 CT 图像特征但没有同步文本报告脱敏的权限另一部分只有结构化检验指标。你训练时拿不到完整模态对数据天然就是“各有一块拼图”的状态。第三种是动态掉线与退化。客户端本身运行环境不稳定这次同步还有三种模态下一次突然只剩两种再下一次又恢复。这种“随时间变化的缺失模式”比静态缺失更麻烦因为训练状态会在不同模态更新之间震荡模型很容易遗忘。这几种情况有个共同点每个客户端在每一轮能看到的模态集合都不一样而且没有一个统一的“完整训练样本”来支撑常规多模态学习。联邦场景又把问题放大因为服务器根本看不到模态缺失的原始数据只能从客户端上报的模型更新里间接推断。1.2 朴素方案为什么扛不住多模态微调现在最常见的做法是冻结预训练骨干加上 LoRA 低秩适配层。你把一个双塔结构——视觉塔和文本塔——各插几层 LoRA然后让客户端做本地微调服务器做参数平均。听起来很合理实际跑起来问题一堆。第一个问题是缺模态时梯度来源不平衡。如果一个客户端本地没有文本塔它只能更新视觉塔里的 LoRA 层。剩下的 LoRA 参数还在用上一轮服务器平均值等下一轮又变。结果就是不同客户端实际在更新不同子集相互之间产生强烈干扰我称之为“异步子空间漂移”。常规 FedAvg 平均出来的模型往往比单模态本地训练效果还差。第二个问题是朴素的单一 LoRA 共享空间不够用。你把所有模态的打散融到同一个低秩空间里看起来共享知识多了可实际上不同模态的更新方向很可能互相冲突。尤其是当客户端 A 用视觉信息更新某一层的 B 矩阵、客户端 B 用文本信息更新同一层的 B 矩阵时两个更新的方向在低秩空间里根本不是一个坐标系硬平均之后这个低秩矩阵会变成“四不像”。第三个问题是信息浪费。当一个客户端缺失某种模态时它仍然会收到服务器下发的完整模型、完整 LoRA 配置。你以为它没数据就不更新那部分其实它还在本地做前向计算把缺失模态的位置当成常量处理这些计算和通信开销完全是无谓的。所以与其在所有客户端上强行套同一个 LoRA 结构不如接受客户端的异质性并为它专门设计参数的表示方式与聚合方法。这就是 FediLoRA 要做的事。2. FediLoRA 核心机制异构低秩空间与模态感知聚合FediLoRA 从标题就能读出两个关键词Heterogeneous LoRA 和 Missing Modalities。这里有一个容易被忽略的思维翻转传统 LoRA 假设所有客户端共享同一个低秩空间但 FediLoRA 认为低秩空间在结构上本来就是可拆的我们可以把其中的一部分设计成跨模态共享低秩子空间剩下的部分留给特定模态私有再让服务器按照客户端实际的模态参与情况来做聚合。2.1 低秩分解不只是降维更是一种结构建模先快速回顾 LoRA 的原理。假设一个线性层权重为 W0形状为 d_out × d_in输入 x。常规微调会直接更新 W0但参数量太大。LoRA 的做法是冻结 W0额外学习两个小矩阵Ar × d_in和 Bd_out × r用公式表示就是h W0 x (B A) x其中 r 远小于 d_in 和 d_out所以新增参数量只有 r × (d_in d_out)。它的理论直觉是预训练模型已经有很强的表达能力微调只需要在少数低秩方向上进行少量修正不需要对全体权重做剧烈改动。FediLoRA 在这个基础上做了更细的结构拆解。它把 A 和 B 的秩分成两部分共享低秩子空间对所有模态都生效承载跨模态的通用表达模态私有低秩子空间只服务于某个特定模态比如视觉或文本。假设有 M 个模态那么某个层的更新的权重增量可以写成ΔW B_shared A_shared sum(B_m A_m for m)但不同的客户端不一定拥有全部的 m。所以 FediLoRA 引入了一个模态掩码 mask_m ∈ {0, 1}表示客户端是否参与模态 m 的更新ΔW B_shared A_shared sum(mask_m · B_m A_m for m)这里面的关键是你得提前把低秩矩阵设计成块状结构共享块维度在所有客户端保持一致模态私有块的阶梯则按模态排列。这样客户端无论如何组合模态参数空间的对齐规则不变。2.2 客户端侧只更新存在的模态不做无谓计算在实际训练中某个客户端拿到模型权重后会先检查本地数据到底有哪些模态可用。然后它做两件事对存在模态的私有低秩块和共享低秩块正常计算梯度并更新对缺失模态的私有低秩块不做任何更新甚至在本地前向时就跳过相应计算路径。这个“跳过”是有讲究的。常规多模态模型即使输入模态缺失也会用zeros或pad填充然后送进模型这保留了模型结构完整性但浪费了大量计算。FediLoRA 的思路是只要对应的低秩参数块没有数据流就干脆不计算只在代码上用 mask 记录它的存在性状态这样客户端不同模态的本地计算量差别很大但回到服务器之后这些“不均等”的更新可以被统一处理。这里顺便说一个我自己踩过的细节mask 的粒度太粗或太细都会出问题。如果只按“模态级”粗粒度 mask当同一客户端里有部分样本缺模态、另一部分样本完整时你没法表达这种混合状态如果按“样本级”细粒度 mask又会把本地更新变成一串碎片最终平均时噪声很大。比较稳妥的做法是“模态级 mask 样本权重的软开关”一个客户端大部分样本都有视觉但只有少数含文本那文本模态的私有低秩块更新幅度就按有效样本比例折算而不是完全跳过或全额更新。2.3 服务器端异构参数如何“加权平均”常规 FedAvg 的聚合公式是所有客户端更新的加权平均。但在 FediLoRA 里每个客户端上传的 LoRA 参数形状可能不同客户端甲只传 B_shared 和 B_visual客户端乙传 B_shared、B_visual 和 B_text。如何把这些不同形态的更新混在一起步骤是这样拆的服务器维护一个全局 LoRA 结构始终包含共享低秩矩阵和所有模态的私有低秩矩阵每个客户端上报的更新只覆盖它实际更新过的子块服务器做“按子块分别平均”共享子块对全体参与客户端做加权平均私有子块只对该模态存在且实际更新的客户端做加权平均。严格来说这不是简单的全局平均而是模态感知的局部聚合。它之所以有效是因为每个子块都被限制在通用语义空间里共享子块传播跨模态知识私有子块保留模态特异性。你可以把它理解成开会全局会议纪要由所有人共同修订但视觉小组的分工只由看得见视觉数据的人投票决定文本小组的人不参与投票。还有一个工程细节各个子块的聚合权重要不要额外处理。如果客户端数据量差距很大我会建议在服务器端预先把客户端权重归一化甚至加上一个“有效模态数”的惩罚项防止某些只缺模态但数据量大的客户端在共享子块上形成过强影响。这个细节原文未必会单独强调但实测下来对稳定收敛非常重要。3. 从零跑通 FediLoRA最小教学原型的关键代码与参数为了把这套思想落到能跑的状态我给一个最小原型。它不追求在多模态大型预训练模型上刷新指标核心是让你看清楚 mask、低秩拆分、聚合这三个环节怎么写。3.1 先定义带 mask 的 LoRA 层这里我按“共享低秩子空间 多模态私有低秩子空间”来定义。假设某个编码器层的输入特征为 d_in输出为 d_out模态数量为 2visual 和 text。import torch import torch.nn as nn class MaskedLoRALayer(nn.Module): 带模态掩码的异构LoRA层。 结构说明 - shared: 跨模态共享低秩子空间任何模态存在时都更新。 - private: 每个模态一个私有低秩子空间只为对该模态参与的客户端训练。 def __init__(self, d_in, d_out, r_shared4, r_private8, dropout0.1): super().__init__() # 为了简化这里不真正绑定预训练权重只演示LoRA部分 self.r_shared r_shared self.r_private r_private # low-rank 参数按 (rank, in_features) 和 (out_features, rank) 存放 self.A_shared nn.Parameter(torch.randn(r_shared, d_in) * 0.02) self.B_shared nn.Parameter(torch.zeros(d_out, r_shared)) self.A_visual nn.Parameter(torch.randn(r_private, d_in) * 0.02) self.B_visual nn.Parameter(torch.zeros(d_out, r_private)) self.A_text nn.Parameter(torch.randn(r_private, d_in) * 0.02) self.B_text nn.Parameter(torch.zeros(d_out, r_private)) self.dropout nn.Dropout(pdropout) def forward(self, x, mask_visual, mask_text): # 基础前向这里应当加上冻结的固定权重为了演示省略 delta (self.B_shared self.A_shared) x if mask_visual 0: delta delta (self.B_visual self.A_visual) x if mask_text 0: delta delta (self.B_text self.A_text) x # 注意LoRA标准实现是在原权重输出上加 delta return self.dropout(delta)这段代码里面两个初始化细节值得注意。一是B 0确保初始状态下低秩增量为零模型的输出与预训练骨干完全一致这样微调不会一开始就跑偏。二是A使用标准差 0.02 的小随机初始化这个经验值在轻微规模模型上效果尚可如果模型规模更大可以再调低到 0.01 或直接采用 Kaiming 初始化。3.2 模拟缺失模态的客户端训练循环模拟缺失模态时要考虑两种模式静态缺失和动态缺失。静态缺失是每个客户端的模态组成固定不变动态缺失是每轮有概率丢一个模态。前者检验模型的异质性适应能力后者检验稳健性。建议先做静态模拟再把动态缺失加进来对比。import copy import random def simulate_client_training(model, dataset, local_epochs2, lr1e-4): 模拟一个客户端的本地训练返回该客户端对共享/私有子块的增量。 local_model copy.deepcopy(model) optimizer torch.optim.Adam(local_model.parameters(), lrlr) loss_fn nn.CrossEntropyLoss() # 生成随机mask实际项目中应从客户端数据结构里推断 mask_visual 1 mask_text 1 if random.random() 0.5 else 0 for _ in range(local_epochs): for batch in dataset: x_visual, x_text, label batch # 如果mask_text 0就只用视觉分支的输入做前向 # 这里简化成缺失模态的数据直接不参与 optimizer.zero_grad() output local_model(x_visual, x_text, mask_visual, mask_text) loss loss_fn(output, label) loss.backward() optimizer.step() # 收集增量全局模型参数减去本地模型参数 delta {} for name, param in local_model.named_parameters(): delta[name] param.data - model.state_dict()[name].data return delta, mask_visual, mask_text实际项目里mask 的生成逻辑应当来自数据本身而不是随机数。比如视觉数据为空时自然置零文本数据为空时自然置零。这地方的隐性风险是客户端本地没有某个模态不代表该模态在全局模型里不重要所以 mask 只能决定“本地更新哪些低秩块”不能在服务器聚合时直接把该模态私有块整个丢弃。3.3 服务器端聚合只平均有效子块服务器端要维护全局模型并把各客户端返回的增量做加权平均。重点不是简单求和而是让每个子块只对“有效参与过该子块训练的客户端”进行平均def fedilora_aggregate(model, deltas, masks, weights): model: 全局模型 deltas: 客户端返回的参数增量字典列表 masks: 每个客户端的模态掩码 weights: 客户端样本占比权重 aggregated {} for name, param in model.named_parameters(): aggregated[name] torch.zeros_like(param.data) total_weight sum(weights) for delta, mask, weight in zip(deltas, masks, weights): for name, upd in delta.items(): # 提取该参数的名称判断属于共享块还是哪个私有块 # 这里演示简化逻辑私有块只有在mask为1时才加入聚合 if private_visual in name and mask[0] 0: continue if private_text in name and mask[1] 0: continue aggregated[name] weight * upd for name in aggregated: aggregated[name] / total_weight with torch.no_grad(): for name, param in model.named_parameters(): param.data aggregated[name] return model这里我刻意把权重设成全部客户端总权重没有对子块单独分母。更精细的版本应该分别记录每个子块的有效客户端权重和并用它做分母否则数据量大但缺模态的客户端会在共享块上占据过高话语权。实际使用中我把这两者都实现过单独分母的效果要稳定很多。3.4 建议的超参与观测指标FediLoRA 的超参大体和 LoRA 相同但多了“共享秩和私有秩配比”这个新维度。我经验上的默认值r_shared 初始为 4r_private 初始为 8。共享秩不追求大它只是跨模态迁移的高速公路alpha 保持与总秩r_shared r_private的比例在 1.5 到 2 之间避免更新过猛联邦微调本地学习率比普通微调低一个数量级建议从 1e-4 左右起试差分隐私如果要做一般加在增量上而不是加在 LoRA 低秩矩阵本身上这样噪声影响更可控。观测指标不能只看最终准确率。我一般同时看三样东西完整模态客户端上的测试性能、缺模态客户端上的测试性能、以及共享子块更新幅度的范数。后者能反映跨模态迁移是否在正常进行——如果共享子块范数一直几乎为零说明设计有问题模态之间的知识交换根本没发生。4. 实操中更容易翻车的地方与排查思路从论文思路到真实项目之间隔着很多琐碎的坑。我挑四个自己反复遇到、且花费时间最长的讲按排查顺序列出来希望对你有用。4.1 聚合噪声暴涨loss 反而升高了很多朋友刚把异构 LoRA 跑起来第一感觉是“参数量明明更小了为什么联邦 loss 比 FedAvg 还高”。这里最常见的根因是不同客户端样本量差距大而你在聚合时没有按子块分别归一化。某个客户端可能有 80% 的样本量但它的模态相对单一在“共享子块”上它和那些模态多样、样本量小的客户端用同一个权重。结果是少数客户端主导共享子空间损失上升。排查链路是这样的打印每个客户端上传增量的 L2 范数看是否存在参数过大的客户端对每个子块单独统计有效客户端数量把聚合分母从“总样本权重”改成“子块有效样本权重”。这一步改动很小但收敛稳定性往往立刻改善。另外还要检查客户端本地训练的迭代次数是否差异太大本地迭代多的客户端天然拥有更大的增量范数我见过不少本地迭代不均匀的案例也建议用增量归一化解决例如在平均前除以 sqrt(n_local_steps)。4.2 模态缺失久了共享子空间会“遗忘”模态缺失不是暂时现象而是某些客户端长期缺失。比如一个项目有 10 个客户端其中两个客户端连续 20 轮没有文本数据。你在聚合时发现全局模型的文本塔相关私有低秩块在慢慢退化——并不是断崖式掉点而是在测试集上文本性能一周比一周差一点。这是因为共享子块一直被视觉样本更新它在低秩空间里挤占了原来承载文本语义的方向。这问题没有一个公式能完美解决工程上常用的手段是给共享子块增加一个“模态均匀性正则”def modality_regularization(shared_B): # 希望共享子块的列方向不要偏向某个模态 # 简化写法鼓励B_shared的方差在不同维度上保持均匀 return torch.std(shared_B, dim1).mean()另一种更直接的做法是隔若干轮强制做一次“模态补全”从服务器侧选少量完整多模态样本来校准共享子块。这在严格联邦场景下可能不现实因为服务器不一定有自己的数据。如果实在没有可以考虑让少量“模态齐全”的参与方承担校准任务同时对其本地隐私预算做额外处理。这个问题的本质是长尾参与度不均FediLoRA 的结构已经缓解了一部分但还没有完全根治。4.3 异构 LoRA 的通信与存储开销被低估我把 LoRA 参数量算给团队看时大家的第一反应都是“这么少”。但算总账时会发现服务器要保存的不止是当前全局模型还要保存历史最优模型、验证快照、以及多个模态的低秩备份。若骨干模型有 1 亿参数LoRA 部分可能只有 0.5% 大小但整体训练轨迹的存储仍然很可观。所以实践上我会建议服务器端只保存全局模型和 LoRA 增量不保存历史原始权重如果聚类联邦场景需要多个全局模型则考虑只存低秩增量推理时动态合并回骨干客户端上下行通信时可以构造“仅传输非零子块”的稀疏版本用压缩格式导出增量。比如某客户端只更新了视觉私有块和共享块那文本私有块直接不发送服务器自动用上一轮值。如果你用 XGBoost、树模型等非深度模型做横向对比也要注意树模型的通信量随模型深度指数级增长LoRA 在通信上的优势在超大模型场景下会被明显放大这也是异构 LoRA 在真实多模态联邦项目里比较有吸引力的原因之一。4.4 评估时最容易犯的错不要把“所有模态齐全”的客户端作为唯一评测集。FediLoRA 的目标是让缺失模态的客户端也能正常工作所以测试至少要有三组全模态、缺失单一模态、动态缺失序列。我见过不少团队只报全模态指标结果一看缺失模态场景性能下降甚至超过十个点。这个现象在普通多模态模型上还好在联邦场景里会被客户端数据异质性进一步放大。如果缺失模态测试很差要分清是低秩空间结构的问题还是本地数据太少的问题。一种快速验证方法是把测试阶段某个模态输入简单置零看模型输出的预测分布是否发生剧烈变化。如果变化很小说明该模态的低秩子空间可能没有被真正激活如果变化非常大说明该模态过度依赖某种虚假相关性。这时候需要回到数据预处理和模态子空间秩的分配上找原因。5. 与常规联邦微调方案的对比以及适用边界最后给一个横向对比方便你判断自己的场景到底适不适合直接上 FediLoRA。我把同一套完整多模态训练任务用四种方式跑了一遍分别记录它们在“通信量”“保存存储”“模态缺失抗性”和“实现难度”四个维度的表现。这些数据和结论来自我自己的小规模模拟不宣称普适但趋势与公开案例基本一致。方案通信量存储开销模态缺失抗性实现难度适用场景朴素 FedAvg 完整模型高高差低客户端同构、数据完整每个模态单独一个 LoRA 再平均中中中中模态比较齐全、缺失比例低单一共享 LoRA 跨模态低低差低模态强相关、几乎没有缺失FediLoRA 异构 LoRA低中强中高客户端模态异构、长期缺失动态变化单条对比不说明什么结合起来看会更加明显FediLoRA 在模态缺失比例高且动态变化时收益最大在模态齐全且高度相关时反而收益有限。如果一个项目里所有客户端都能稳定提供完整多模态数据你直接用常规 FedAvg 或单一共享 LoRA 就好没必要上复杂的异构结构。适用边界还要画得更细一些。FedLoRA 并不是万能的以下场景我建议谨慎使用一是模态缺失非常极端比如某客户端连续几十轮只有一个模态数据而且该模态数据量还很少那再优秀的低秩设计也很难弥补信息匮乏二是骨干模型本身非常小小到低秩适配层的容量占比过大LoRA 微调可能不足以表达目标任务差异这时还不如全量微调三是需要处理多种语义粒度非常不同的模态时比如文本和 3D 点云模态私有的低秩子空间可能需要不同的秩每个模态单独调参的成本会急剧上升。另外必须承认异构 LoRA 让我在实现时多了不少额外工程负担。单模态 LoRA 你只需要管一套 mask 为零可能有用/没用的逻辑异构 LoRA 则要求你设计参数命名规范、子块对齐规则、服务器与客户端的契约这比代码本身更花时间。如果团队没有专门的联邦学习框架支持建议先把参数命名规范固定下来再用一个小型 Demo 跑通全链路再上真实项目。很多翻车都不是算法原理错了而是参数名字写错了导致聚合对不上。我在实际项目里的一些体会如果你也在做一个“看起来每个人手里的数据都不一样”的多模态联邦项目我最大的建议是先别急着研究聚合算法先把模态缺失的统计做细。统计谁缺什么、缺多久、是否周期性地恢复这些信息决定了你能否直接复用 FediLoRA 的结构也决定了共享子空间该占多大比例。缺失模态不是简单地把某个输入通道置零它背后往往是设备能力、用户隐私和数据收集周期的复杂组合。我个人对异构 LoRA 的看法是它把“异质性”从一个问题变成了一个设计起点。以前我们总想把客户端抹平成一个同构的世界实际上界是抹不平的不如直接给每个模态一个可解释的低秩空间位置再让联邦聚合在子块层面上精确发生。这个思路不止适用于多模态也适用于任何“客户端能力不均”的场景比如有的设备只有 CPU、有的设备有 GPU就可以用类似的方式划分计算相关的低秩结构。把 FedLoRA 理解成一种参数结构化的思路比只记住一个模型名更有用。