做多模态联邦微调的人,迟早会撞上同一个尴尬:你在一开始把整个系统设计成三个模态一起训,结果真正跑起来之后,发现不少客户端传回来的数据里压根没有其中某一个模态。有没有图文对?有。有没有语音?偶尔有。有没有音频?大多数设备根本没这项采集权限。缺一个模态,常规多模态微调方法——包括现在用得最广的 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,额外学习两个小矩阵:A(r × d_in)和 B(d_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,模态数量为 2(visual 和 text)。
import torch import torch.nn as nn class MaskedLoRALayer(nn.Module): """带模态掩码的异构LoRA层。 结构说明: - shared: 跨模态共享低秩子空间,任何模态存在时都更新。 - private: 每个模态一个私有低秩子空间,只为对该模态参与的客户端训练。 """ def __init__(self, d_in, d_out, r_shared=4, r_private=8, dropout=0.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(p=dropout) 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_epochs=2, lr=1e-4): """模拟一个客户端的本地训练,返回该客户端对共享/私有子块的增量。""" local_model = copy.deepcopy(model) optimizer = torch.optim.Adam(local_model.parameters(), lr=lr) 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 初始为 4,r_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, dim=1).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 理解成一种参数结构化的思路,比只记住一个模型名更有用。