MMoE 多任务学习实战:从人口统计预测到金融信用风险与授信额度联合建模

举报
yd_276081655 发表于 2026/11/02 10:20:23 2026/11/02
【摘要】 在传统机器学习中,一个模型通常负责预测一个目标。但在实际业务中,我们往往需要同时预测多个相互关联的结果,例如用户的收入水平与婚姻状态,或者金融客户的违约概率与授信额度。

MMoE(Multi-gate Mixture-of-Experts,多门控混合专家网络) 通过引入共享专家网络和任务专属门控,让不同任务动态选择所需要的特征表示,在共享信息的同时保留差异。

本文以 Google 提出的经典 MMoE 架构为基础,结合两个典型场景,介绍模型原理和 PyTorch 实践:一是利用 Census Income 数据同时预测收入水平和婚姻状态;二是参考消费金融领域的真实研究,将违约风险分类与授信额度回归整合到一个多任务模型中。最后通过 Shared-Bottom 对照实验,分析 MMoE 的实际价值及适用条件。


一、为什么需要 MMoE?

1. 从单任务学习说起

假设一家消费金融机构希望同时完成两个预测任务。

任务一:预测客户未来是否违约。

输入客户的征信评分、历史逾期、负债水平和收入等信息,输出未来违约概率。

任务二:预测客户的授信额度。

输入相同或相近的客户信息,预测一个额度数值。

传统方法通常为两个任务分别训练模型:

                 客户数据
                    |
          +---------+---------+
          |                   |
      违约预测模型          额度预测模型
          |                   |
       违约概率             授信额度

这种方案容易实现,但两个模型分别学习特征。

例如,收入、负债率、历史信用表现等变量可能同时影响两个目标。分别训练模型时,这些共同规律需要重复学习。

一种自然的改进方式是共享部分神经网络参数。

2. Shared-Bottom:最简单的多任务学习

Shared-Bottom 是经典的多任务神经网络结构。

              客户特征 X
                   |
                   v
             Shared Bottom
             共享特征提取层
                   |
          +--------+--------+
          |                 |
       Tower A           Tower B
          |                 |
       违约概率           授信额度

两个任务共用底层网络,再使用各自的任务塔输出预测结果。

问题在于:不同任务对特征的需求可能并不一致。

例如:

  • 历史逾期次数对违约风险预测非常重要。
  • 收入和负债负担对额度预测可能更加重要。
  • 某些特征对一个任务具有正向影响,对另一个任务的影响可能较弱,甚至呈现不同方向。

如果两个任务的梯度更新存在冲突,Shared-Bottom 的共享参数可能同时受到不同目标的拉扯。

这种现象称为负迁移(Negative Transfer)。

3. MMoE 的解决方法

Google 的研究人员在 KDD 2018 论文《Modeling Task Relationships in Multi-task Learning with Multi-gate Mixture-of-Experts》中提出了 MMoE。

其核心思想是:

让多个任务共享一组专家网络,再通过各自的 Gate 动态决定使用哪些专家,以及使用多大权重。

架构如下:

                    输入特征 X
                        |
           +------------+------------+
           |            |            |
        Expert 1     Expert 2     Expert 3
           |            |            |
           +------------+------------+
                        |
           +------------+------------+
           |                         |
         Gate A                    Gate B
       违约风险门控               授信额度门控
           |                         |
       专家加权组合               专家加权组合
           |                         |
         Tower A                   Tower B
           |                         |
       违约概率 PD               授信额度

MMoE 由三个部分组成。

组件 主要功能 是否共享
Expert 提取输入特征的潜在表示 所有任务共享
Gate 根据输入决定各专家的组合权重 每个任务独立
Tower 学习任务特有的映射关系并输出预测 每个任务独立

例如,一个模型拥有三个专家。

对于某个客户,两个任务可能学习到不同的权重:

专家 违约任务 额度任务
Expert 1 0.65 0.15
Expert 2 0.25 0.30
Expert 3 0.10 0.55
合计 1.00 1.00

这是示意数据。

可以看到,两个任务共用相同的三个专家,但违约任务更加依赖 Expert 1,而额度任务更加依赖 Expert 3。

实际训练时,专家通常没有预先指定的业务含义,其特征提取能力由模型训练自动学习。

而且,Gate 权重还会随客户输入变化,因此不同客户可能拥有不同的专家组合。


二、MMoE 的数学原理

假设模型有 (N) 个专家,输入特征为 (x)。

第 (i) 个专家网络输出:

[
E_i(x)
]

对于第 (k) 个任务,门控网络产生:

[
g_k(x)=\operatorname{Softmax}(W_kx+b_k)
]

其中:

[
g_k(x)=[g_{k,1}(x),\ldots,g_{k,N}(x)]
]

满足:

[
\sum_{i=1}^{N}g_{k,i}(x)=1
]

随后将专家输出加权组合:

[
h_k(x)=\sum_{i=1}^{N}g_{k,i}(x)E_i(x)
]

最后由任务塔输出预测:

[
\hat y_k=T_k(h_k(x))
]

如果有两个任务,整体损失函数通常写作:

[
\boxed{
L=\lambda_1L_1+\lambda_2L_2
}
]

其中:

  • (L_1):任务一的损失。
  • (L_2):任务二的损失。
  • (\lambda_1,\lambda_2):不同任务的损失权重。

例如:

  • 违约预测使用 Binary Cross-Entropy。
  • 授信额度预测使用 Mean Squared Error。

两个任务可以拥有不同类型的输出。

需要注意,标准 MMoE 通常会计算全部专家的输出,再通过 Softmax 权重进行组合。它的主要作用是改善多任务特征共享,不应直接套用大语言模型稀疏 MoE 的计算节省结论。


三、先实现一个通用 MMoE 模型

为了更容易理解后面的两个案例,我们先用 PyTorch 实现一个能够处理两个任务的通用 MMoE。

同时实现一个 Shared-Bottom 基线,以便进行对照。

安装依赖:

pip install torch numpy pandas scikit-learn

创建文件 mmoe_core_demo.py。

import torch
from torch import nn
from torch.utils.data import DataLoader, TensorDataset


class MultiTaskNet(nn.Module):

    def __init__(
        self,
        n_features,
        mode="mmoe",
        n_experts=4,
        hidden=32
    ):
        super().__init__()

        self.mode = mode

        if mode == "mmoe":

            # 多个共享专家
            self.experts = nn.ModuleList([
                nn.Sequential(
                    nn.Linear(n_features, hidden),
                    nn.ReLU(),
                    nn.Linear(hidden, hidden),
                    nn.ReLU()
                )
                for _ in range(n_experts)
            ])

            # 每个任务拥有自己的门控
            self.gates = nn.ModuleList([
                nn.Linear(n_features, n_experts)
                for _ in range(2)
            ])

        elif mode == "shared":

            # Shared-Bottom 对照模型
            self.bottom = nn.Sequential(
                nn.Linear(n_features, hidden),
                nn.ReLU(),
                nn.Linear(hidden, hidden),
                nn.ReLU()
            )

        else:
            raise ValueError("Invalid mode")

        # 两个独立的任务塔
        self.towers = nn.ModuleList([
            nn.Sequential(
                nn.Linear(hidden, 16),
                nn.ReLU(),
                nn.Linear(16, 1)
            )
            for _ in range(2)
        ])

    def forward(self, x, return_gates=False):

        if self.mode == "shared":

            h = self.bottom(x)

            output = torch.cat([
                tower(h)
                for tower in self.towers
            ], dim=1)

            if return_gates:
                return output, None

            return output

        # [Batch, Experts, Hidden]
        expert_output = torch.stack([
            expert(x)
            for expert in self.experts
        ], dim=1)

        logits = []
        gate_weights = []

        for gate, tower in zip(
            self.gates,
            self.towers
        ):

            # [Batch, Experts]
            weights = torch.softmax(
                gate(x), dim=1
            )

            # 对专家输出进行加权
            h = (
                expert_output *
                weights.unsqueeze(-1)
            ).sum(dim=1)

            logits.append(tower(h))
            gate_weights.append(weights)

        output = torch.cat(logits, dim=1)

        if return_gates:
            return output, gate_weights

        return output


def train_model(
    model,
    x_train,
    y_train,
    x_valid,
    y_valid,
    kinds=("binary", "binary"),
    epochs=12,
    batch_size=512,
    lr=0.002
):

    torch.manual_seed(42)

    optimizer = torch.optim.Adam(
        model.parameters(),
        lr=lr,
        weight_decay=1e-4
    )

    loader = DataLoader(
        TensorDataset(
            torch.as_tensor(
                x_train, dtype=torch.float32
            ),
            torch.as_tensor(
                y_train, dtype=torch.float32
            )
        ),
        batch_size=batch_size,
        shuffle=True
    )

    xv = torch.as_tensor(
        x_valid, dtype=torch.float32
    )

    yv = torch.as_tensor(
        y_valid, dtype=torch.float32
    )

    for epoch in range(epochs):

        model.train()

        for xb, yb in loader:

            out = model(xb)

            losses = [
                (
                    nn.functional.binary_cross_entropy_with_logits(
                        out[:, k], yb[:, k]
                    )
                    if kind == "binary"
                    else nn.functional.mse_loss(
                        out[:, k], yb[:, k]
                    )
                )
                for k, kind in enumerate(kinds)
            ]

            loss = losses[0] + losses[1]

            optimizer.zero_grad()
            loss.backward()
            optimizer.step()

        # 输出验证集损失
        if epoch in (0, epochs - 1):

            model.eval()

            with torch.no_grad():

                out = model(xv)

                val_losses = [
                    (
                        nn.functional.binary_cross_entropy_with_logits(
                            out[:, k], yv[:, k]
                        )
                        if kind == "binary"
                        else nn.functional.mse_loss(
                            out[:, k], yv[:, k]
                        )
                    )
                    for k, kind in enumerate(kinds)
                ]

            print(
                model.mode,
                "epoch:", epoch + 1,
                "valid_loss:",
                [round(v.item(), 4) for v in val_losses]
            )

    return model


def predict(model, x):

    model.eval()

    with torch.no_grad():

        output = model(
            torch.as_tensor(
                x, dtype=torch.float32
            )
        )

    return output.numpy()

这个实现包含两种模式:

  • mode="shared":Shared-Bottom。
  • mode="mmoe":MMoE。

两种模式都使用两个独立的任务塔,因此可以比较多门控专家机制带来的影响。

train_model() 还支持两种任务类型:

kinds=("binary", "binary")

表示两个二分类任务。

或者:

kinds=("binary", "regression")

表示一个分类任务和一个回归任务。

这样,我们就可以在两个不同的业务场景中复用同一套模型。


四、案例一:Census Income——同时预测收入与婚姻状态

1. 真实研究背景

MMoE 原始论文使用过 Census-Income 数据进行多任务分类实验,以分析任务相关程度对模型训练的影响。

公开的 MMoE 实现也经常采用两个预测任务:

  • 预测个人收入是否超过每年 50,000 美元。
  • 预测个人是否从未结婚。

这两个任务并非完全独立。

年龄、工作类型、教育程度、职业等变量可能同时与收入和婚姻状态存在统计关联。

但两个任务关注的变量关系又有所不同,因此适合演示多任务学习。

原始研究使用的 Census-Income 数据版本包含约 29.9 万条记录。

为了让实践更容易运行,我们改用 UCI 的 Adult(Census Income) 数据集。该版本共有 48,842 条记录,下面使用其中的 adult.data 文件,包含 32,561 条训练来源记录,并重新划分训练、验证和测试集。

这与原论文的数据版本和实验设置有所区别,因此属于独立的教学实验。

2. 获取数据

前往 UCI Machine Learning Repository,搜索 Adult。

下载:

adult.data

将文件放在程序目录中。

需要注意数据泄漏问题。

由于我们需要同时预测收入和婚姻状态,因此:

  • income 是任务一的标签。
  • marital-status 是任务二的标签。

这两个字段都应当从输入特征中删除。

此外,relationship 字段中包含 Husband、Wife 等信息,与婚姻状态高度重合,也应当从输入中排除,以免模型通过直接代理变量完成预测。

本实验用于研究多任务学习。现实中若要将婚姻状态预测用于个人决策,需要另外考虑用途合理性、隐私保护和公平性。

3. 完整实践代码

创建 mmoe_census_demo.py,与前面的 mmoe_core_demo.py 放在同一目录。

import numpy as np
import pandas as pd
import torch

from sklearn.compose import ColumnTransformer
from sklearn.impute import SimpleImputer
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler
from sklearn.model_selection import train_test_split
from sklearn.metrics import roc_auc_score

from mmoe_core_demo import (
    MultiTaskNet,
    train_model,
    predict
)

# 1. 数据字段
cols = [
    "age", "workclass", "fnlwgt",
    "education", "education-num",
    "marital-status", "occupation",
    "relationship", "race", "sex",
    "capital-gain", "capital-loss",
    "hours-per-week", "native-country",
    "income"
]

# 2. 读取 Adult 数据
df = pd.read_csv(
    "adult.data",
    header=None,
    names=cols,
    na_values="?",
    skipinitialspace=True
)

# 3. 构造两个任务标签
y_income = (
    df["income"].str.strip() == ">50K"
).astype("float32")

y_marital = (
    df["marital-status"] == "Never-married"
).astype("float32")

y = np.stack(
    [y_income, y_marital],
    axis=1
)

# 4. 定义输入变量
num_cols = [
    "age",
    "education-num",
    "capital-gain",
    "capital-loss",
    "hours-per-week"
]

cat_cols = [
    "workclass",
    "occupation",
    "education"
]

X = df[num_cols + cat_cols].copy()

# 5. 划分训练、验证和测试集
train_idx, rem_idx = train_test_split(
    np.arange(len(df)),
    test_size=0.4,
    stratify=y[:, 0],
    random_state=42
)

valid_idx, test_idx = train_test_split(
    rem_idx,
    test_size=0.5,
    stratify=y[rem_idx, 0],
    random_state=42
)

# 6. 数值和类别特征预处理
preprocess = ColumnTransformer([
    (
        "num",
        Pipeline([
            ("imputer", SimpleImputer(strategy="median")),
            ("scaler", StandardScaler())
        ]),
        num_cols
    ),
    (
        "cat",
        Pipeline([
            (
                "imputer",
                SimpleImputer(strategy="most_frequent")
            ),
            (
                "onehot",
                OneHotEncoder(
                    handle_unknown="ignore",
                    sparse_output=False
                )
            )
        ]),
        cat_cols
    )
])

# 只使用训练集拟合预处理器
x_train = preprocess.fit_transform(
    X.iloc[train_idx]
).astype("float32")

x_valid = preprocess.transform(
    X.iloc[valid_idx]
).astype("float32")

x_test = preprocess.transform(
    X.iloc[test_idx]
).astype("float32")

# 7. 分别训练 Shared-Bottom 和 MMoE
for mode in ["shared", "mmoe"]:

    torch.manual_seed(42)

    model = MultiTaskNet(
        n_features=x_train.shape[1],
        mode=mode
    )

    train_model(
        model,
        x_train,
        y[train_idx],
        x_valid,
        y[valid_idx],
        epochs=8
    )

    # 8. 计算两个任务的预测概率
    logits = predict(model, x_test)

    probs = 1 / (1 + np.exp(-logits))

    # 9. 分别计算 AUC
    auc_income = roc_auc_score(
        y[test_idx, 0],
        probs[:, 0]
    )

    auc_marital = roc_auc_score(
        y[test_idx, 1],
        probs[:, 1]
    )

    print(
        mode,
        "Income AUC:", round(auc_income, 4),
        "Marital AUC:", round(auc_marital, 4)
    )

4. 结果应该如何分析?

程序会输出两组结果:

Shared-Bottom
Income AUC: ...
Marital AUC: ...

MMoE
Income AUC: ...
Marital AUC: ...

真实数值需要在下载的数据集上训练后得到。

我们重点关注:

对比内容 需要回答的问题
收入预测 AUC MMoE 是否改善收入预测?
婚姻状态 AUC MMoE 是否改善第二个任务?
两个任务的变化 是否存在一个任务改善、另一个任务退化?
多次随机种子实验 性能变化是否稳定?
训练与推理时间 性能收益是否值得额外计算成本?

例如,假设实验结果如下:

模型 收入 AUC 婚姻状态 AUC
Shared-Bottom 0.87 0.84
MMoE 0.88 0.86

这些数字是示意值。

如果两个任务都获得改善,说明在当前实验设置下,任务专属门控可能帮助模型更有效地组合共享特征。

但如果出现:

模型 收入 AUC 婚姻状态 AUC
Shared-Bottom 0.87 0.84
MMoE 0.89 0.82

则说明收入预测有所改善,婚姻状态预测反而退化。

这就是多任务学习中值得重点观察的 跷跷板现象(Seesaw Phenomenon)。

MMoE 能够缓解部分任务冲突,但不能保证每个任务都获得提升。


五、案例二:消费金融——同时预测违约风险与授信额度

1. 真实业务研究背景

第二个案例来自消费金融领域。

2021 年,阿里巴巴等机构的研究人员在 WSDM 会议发表论文:

《Credit Risk and Limits Forecasting in E-Commerce Consumer Lending Service via Multi-view-aware Mixture-of-experts Nets》

论文研究了两个关联任务:

  • Credit Risk Forecasting: 预测客户信用风险。
  • Credit Limits Forecasting: 预测授信额度。

研究提出了 MvMoE(Multi-view-aware Mixture-of-Experts),在多任务专家网络基础上结合多视图特征提取和更复杂的网络结构。

论文使用约 544 万名用户的真实业务数据进行实验,并报告了相对于其比较方法的风险预测 AP 和额度预测 MAE 的改善。

这说明,MMoE 的专家共享思想除了推荐系统,还可以用于金融风险与授信业务。

但是,这项研究使用的是 MvMoE 扩展架构,原始业务数据也没有作为公开实验数据提供。

因此,下面采用简化 MMoE 和合成客户数据,演示分类任务与回归任务如何在同一个网络中联合训练。

2. 为什么违约风险和授信额度适合联合建模?

这两个任务拥有一部分共同信息。

例如:

输入特征 对违约风险的潜在影响 对额度预测的潜在影响
信用评分 识别历史信用风险 反映风险承受能力
月收入 影响偿债能力 影响可承担额度
负债收入比 衡量债务压力 约束潜在授信额度
额度使用率 反映用信压力 反映客户用信状态
历史逾期次数 识别信用恶化 反映风险约束
信用查询次数 反映近期融资活动 辅助判断借贷需求

两个任务涉及相近的客户特征,但学习目标不同。

违约风险需要输出概率:

[
\hat y_1=P(\text{Default}=1\mid X)
]

额度预测需要输出一个连续数值:

[
\hat y_2=\text{Credit Limit}
]

因此,可以设计:

[
L=L_{\text{BCE}}+\lambda L_{\text{MSE}}
]

其中分类任务使用二元交叉熵,回归任务使用均方误差。

这里有一个重要细节:额度通常以千元或万元计量,如果直接计算 MSE,其损失值可能远高于分类任务的 BCE。

因此,训练前通常需要对额度标签进行标准化,或者合理调整任务损失权重。

3. 构造模拟金融数据

我们模拟 12,000 名客户,每位客户包含以下特征:

  • 月收入。
  • 信用评分。
  • 额度使用率。
  • 负债收入比。
  • 历史逾期次数。
  • 信用历史长度。
  • 征信查询次数。
  • 现有负债余额。

同时生成两个标签:

  • label_default:是否发生违约。
  • limit:模拟额度标签。

这里的额度标签由预设的生成规则产生,只用于训练技术演示。真实业务中,历史批准额度受到既有审批政策和风险偏好的影响,不能直接视为客户的最优授信额度。

4. Python 完整实践

创建 mmoe_credit_demo.py。

import numpy as np
import torch

from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
from sklearn.metrics import (
    roc_auc_score,
    average_precision_score,
    mean_absolute_error
)

from mmoe_core_demo import (
    MultiTaskNet,
    train_model,
    predict
)

torch.manual_seed(42)

rng = np.random.default_rng(42)

# 1. 模拟客户数据
n = 12000

income = rng.lognormal(
    np.log(12000), 0.45, n
)

score = np.clip(
    rng.normal(665, 55, n),
    350, 850
)

util = rng.beta(2.3, 2.2, n)

dti = rng.uniform(0.05, 0.9, n)

late = rng.poisson(0.4, n)

tenure = rng.uniform(0, 15, n)

queries = rng.poisson(1.2, n)

balance = (
    income * util *
    rng.uniform(0.3, 1.5, n)
)

# 2. 构造输入特征
raw_features = np.column_stack([
    np.log1p(income),
    score,
    util,
    dti,
    late,
    tenure,
    queries,
    np.log1p(balance)
]).astype("float32")

# 3. 模拟违约概率
log_odds = (
    -2.7
    - 0.017 * (score - 650)
    + 1.6 * util
    + 1.1 * dti
    + 0.3 * late
    + 0.16 * queries
    - 0.03 * tenure
)

p_default = 1 / (1 + np.exp(-log_odds))

# 根据模拟概率生成违约标签
label_default = rng.binomial(
    1, p_default
)

# 4. 模拟额度标签
limit = (
    income
    * (0.55 + 0.6 * (score - 450) / 400)
    * (1 - 0.35 * util)
    * (1 - 0.2 * dti)
    + rng.normal(0, 2000, n)
)

limit = np.clip(
    limit, 1000, 40000
)

# 5. 划分训练、验证、测试集
idx_train, idx_rem = train_test_split(
    np.arange(n),
    test_size=0.4,
    random_state=42,
    stratify=label_default
)

idx_val, idx_test = train_test_split(
    idx_rem,
    test_size=0.5,
    random_state=42,
    stratify=label_default[idx_rem]
)

# 6. 特征标准化
scaler_x = StandardScaler().fit(
    raw_features[idx_train]
)

x = scaler_x.transform(
    raw_features
).astype("float32")

# 7. 额度标签标准化
limit_mean = limit[idx_train].mean()
limit_std = limit[idx_train].std()

y = np.stack([
    label_default,
    (limit - limit_mean) / limit_std
], axis=1).astype("float32")

# 8. 对比 Shared-Bottom 与 MMoE
for mode in ["shared", "mmoe"]:

    torch.manual_seed(42)

    model = MultiTaskNet(
        n_features=x.shape[1],
        mode=mode
    )

    train_model(
        model,
        x[idx_train],
        y[idx_train],
        x[idx_val],
        y[idx_val],
        kinds=("binary", "regression"),
        epochs=8
    )

    # 9. 预测
    pred = predict(
        model,
        x[idx_test]
    )

    # 分类输出转换为概率
    prob = 1 / (1 + np.exp(-pred[:, 0]))

    # 额度还原到原始单位
    predicted_limit = (
        pred[:, 1] * limit_std
        + limit_mean
    )

    # 10. 分别评价两个任务
    auc = roc_auc_score(
        label_default[idx_test],
        prob
    )

    ap = average_precision_score(
        label_default[idx_test],
        prob
    )

    mae = mean_absolute_error(
        limit[idx_test],
        predicted_limit
    )

    print(
        mode,
        "AUC:", round(auc, 4),
        "AP:", round(ap, 4),
        "Limit MAE:", round(mae, 2)
    )

    # 11. 查看两个任务的 Gate 权重
    if mode == "mmoe":

        with torch.no_grad():

            _, gate_w = model(
                torch.as_tensor(
                    x[idx_test][:200],
                    dtype=torch.float32
                ),
                return_gates=True
            )

        print(
            "Default Gate:",
            gate_w[0].mean(0).numpy().round(3)
        )

        print(
            "Limit Gate:",
            gate_w[1].mean(0).numpy().round(3)
        )

5. 如何评价两个任务?

这里的两个任务采用不同的指标。

任务 输出 评价指标
违约风险预测 0~1 概率 AUC、AP
额度预测 连续数值 MAE
Gate 分析 专家权重 权重分布

AUC 和 AP 越高通常越好,MAE 越低通常越好。

在本次合成数据测试中,使用 CPU、固定随机种子及上述 8 轮训练设置,得到以下结果:

模型 违约 AUC 违约 AP 额度 MAE
Shared-Bottom 0.7509 0.4721 1614.77
MMoE 0.7484 0.4692 1614.78

这是上述合成数据程序的实际测试结果,并非 WSDM 论文的实验成绩。

可以看到,在这组较简单的数据生成机制下,MMoE 并没有优于 Shared-Bottom。

这恰恰说明:MMoE 的优势取决于任务关系、数据结构、模型容量和训练方式。增加专家网络与门控机制,并不会自动带来更高的预测准确率。

在这组数据中,两个任务的规律都相对简单,共享底层网络已经可以学习大部分有效特征。

因此,采用更复杂的 MMoE 不一定有明显收益。

如果真实业务中的任务相关程度更复杂,或不同任务存在更加明显的特征偏好与梯度冲突,MMoE 才更有机会体现价值。

6. Gate 权重能告诉我们什么?

代码最后还会输出两个任务的平均专家权重。

例如,本次实验的部分测试结果为:

Default Gate:
[0.155, 0.328, 0.330, 0.186]

Limit Gate:
[0.302, 0.277, 0.245, 0.175]

四个数字分别对应四个共享专家。

这说明两个任务学习到了不同的专家组合。

不过,Gate 权重只能直接说明模型内部的特征表示分配方式。

例如,Expert 1 在授信额度任务中权重更高,并不意味着它一定专门学习收入或偿债能力。

如果希望解释专家网络究竟学到了什么,还需要结合特征扰动、梯度分析、消融实验以及具体业务数据进行判断。


六、MMoE 的关键参数怎么调?

MMoE 的核心超参数主要集中在专家数量、专家网络结构、任务塔和损失权重。

参数 作用 实践建议
n_experts 专家网络数量 可以从 2、4、8 个进行比较
hidden 专家网络隐藏层维度 小规模表格数据可从 16~64 开始
learning_rate 学习率 Adam 可以从 0.001~0.003 尝试
batch_size 每批样本数量 根据数据规模与显存调整
loss_weights 各任务损失权重 重点控制任务之间的梯度影响
tower 任务塔深度与宽度 可根据任务难度分别调整
weight_decay 权重正则化 用于控制模型复杂度

这里有三个比较容易被忽略的细节。

1. 专家数量越多越好吗?

假设模型只有两个任务,却使用 64 个专家。

如果数据量不大,部分专家可能学不到稳定的规律。

此外,标准 MMoE 通常会计算全部专家,因此专家数量增加会直接增加参数量和计算成本。

对于一般的结构化表格任务,可以先从 4 个专家开始。

然后比较 2、4、8 个专家的性能变化。

2. 多任务损失是否应该直接相加?

假设一个模型同时预测:

  • 信用卡是否违约:二分类。
  • 信用卡未来消费金额:回归。

如果消费金额范围在数万元,那么直接使用 MSE,其数值可能远大于 BCE。

这样在训练时,回归任务可能主导梯度更新。

因此,可以考虑:

  • 对连续目标进行标准化。
  • 为任务损失设置不同权重。
  • 分析各任务对共享参数产生的梯度大小。
  • 在更复杂的项目中研究动态损失权重。

本文第二个案例先将额度标签标准化,再进行多任务训练,就是为了减少两个任务损失尺度的差异。

但标准化只能缓解数值尺度问题,并不能自动消除任务之间的梯度冲突。

3. 如何判断发生了负迁移?

只比较 MMoE 和 Shared-Bottom 还不够。

更完整的实验应当至少包含三个基线:

模型 架构 比较目标
Single-Task 每个任务独立模型 观察各任务独立建模能力
Shared-Bottom 共享底层网络 判断简单共享是否有效
MMoE 共享专家+独立门控 判断任务动态共享是否有价值

例如,假设某次实验结果为:

模型 任务 A AUC 任务 B AUC
Single-Task 0.85 0.81
Shared-Bottom 0.84 0.83
MMoE 0.87 0.84

这是一组示意数据。

从中可以发现:

Shared-Bottom 提高了任务 B 的性能,但任务 A 出现下降,可能存在负迁移。

MMoE 则在两个任务上都获得改善,表明其门控共享机制可能缓解了冲突。

真实项目还应进一步使用多个随机种子重复实验,以防止把初始化或数据划分产生的偶然差异误判为架构优势。


七、MMoE 与 PLE 有什么区别?

研究 MMoE 时,经常会接触到另一个多任务架构:PLE(Progressive Layered Extraction)。

二者都试图处理多任务学习中的信息共享与任务冲突。

但专家组织方式有所区别。

MMoE:

所有任务共享一组专家,每个任务通过自己的 Gate 分配权重。

PLE:

进一步区分共享专家和任务专属专家,并通过分层结构逐步提取不同任务需要的信息。

维度 Shared-Bottom MMoE PLE
共享特征 是 是 是
独立任务塔 是 是 是
多专家结构 通常无 有 有
每任务独立 Gate 通常无 有 有
显式任务专属专家 通常无 无 有
结构复杂度 较低 中等 较高

在任务关联度较高、数据规模有限的情况下,Shared-Bottom 可能已经足够。

当不同任务对共享特征的需求存在明显差异时,可以尝试 MMoE。

如果任务之间冲突较明显,并且拥有足够数据支持更复杂的参数结构,可以进一步考虑 PLE。

需要注意,具体效果取决于任务设置和实验数据。正式项目中,应以统一的数据切分、训练预算和评价指标进行比较。


八、MMoE 在金融风控中需要特别注意什么?

MMoE 可以很自然地扩展到信用风险管理,但金融数据具有一些不同于普通推荐数据的特点。

1. 不同任务可能拥有不同的观察期

例如,某银行希望同时预测:

  • 未来 1 个月是否出现 30+ DPD。
  • 未来 3 个月是否出现 60+ DPD。
  • 未来 12 个月是否发生违约。

从网络架构上看,可以为三个任务分别设置 Gate 和 Tower。

但前提是每个任务都具有定义清晰的标签。

如果某个客户只观察了未来三个月,就无法直接确定其未来十二个月是否发生违约。

因此,需要根据各任务的表现期成熟情况,处理标签缺失和观察删失。

2. 不同任务的训练样本可能不一致

例如:

  • 动支意愿可以在全部申请客户中观察。
  • 还款表现通常只能在实际获得贷款的客户中观察。
  • 信用损失或回收率通常只能在满足特定违约条件的账户中观察。

这些任务可以共享部分输入信息,但标签可观测范围不同。

在真实项目中,通常需要对各任务设置相应的样本掩码:

[
L=
\sum_k\lambda_k
\frac{
\sum_i m_{ik}L_{ik}
}{
\sum_i m_{ik}
}
]

其中:

[
m_{ik}=
\begin{cases}
1,&\text{样本 }i\text{ 的任务 }k\text{ 标签有效}\
0,&\text{该标签不可观测}
\end{cases}
]

这种方法能够控制缺失标签参与训练的问题。

但它无法自动解决由历史授信审批、客户选择或数据缺失机制引起的样本偏差。

3. 多期限 PD 可能需要概率一致性约束

例如,同一观察时点、同一违约定义下:

[
PD_{1M}\leq PD_{3M}\leq PD_{12M}
]

这是累计违约概率应当满足的关系。

如果分别设置三个独立输出头,模型可能产生:

PD_1M  = 5%
PD_3M  = 4%
PD_12M = 8%

这种结果违反了累计概率随预测期限不下降的要求。

因此,进行多期限 PD 建模时,可以考虑预测逐期条件违约概率,再通过生存概率关系生成累计 PD。

例如:

[
PD(1,T)=1-\prod_{t=1}^{T}(1-h_t)
]

这里的 (h_t) 表示此前未违约条件下,第 (t) 期发生违约的概率。

MMoE 可以负责提取多期限任务所需的共享特征,但输出层仍然需要满足相应的业务与数学约束。


九、总结

MMoE 的核心思想可以概括为:

多个任务共享专家网络,每个任务通过独立 Gate 动态组合专家输出,再由任务专属 Tower 完成预测。

它主要用于解决多任务学习中的特征共享与任务差异问题。

本文通过两个应用场景介绍了 MMoE。

第一个案例基于公开人口统计数据,同时预测收入水平和婚姻状态,展示 MMoE 在两个二分类任务中的应用。

第二个案例参考真实消费金融研究,将违约风险预测与授信额度预测结合起来,展示 MMoE 如何同时处理分类和回归目标。

其中最重要的实验结论是:MMoE 的结构更加灵活,但模型复杂度的增加并不保证预测性能提高。

在本文的合成金融数据实验中,简单的 Shared-Bottom 就取得了与 MMoE 接近的效果,部分指标甚至略好。

这意味着,如果任务关系较简单,采用共享底层网络可能更高效。

只有当任务之间存在足够复杂的关联与差异,而且数据量能够支持多个专家网络的训练时,MMoE 才更有机会发挥其优势。

对于实际工程项目,建议首先建立独立任务模型和 Shared-Bottom 基线,再尝试 MMoE,并重点检查:

  • 各任务是否都得到稳定改善。
  • 是否存在负迁移或跷跷板现象。
  • Gate 是否学习到了具有差异的专家权重。
  • 模型增加的计算成本是否带来了实际业务收益。
  • 不同任务的标签定义、样本范围与预测时点是否一致。

在推荐系统、消费金融、客户经营和多期限信用风险预测中,MMoE 都提供了一种值得研究的多任务学习方法。

真正决定它是否值得采用的,是任务之间能够共享多少有效信息,以及共享机制是否带来了可验证的泛化收益。

【版权声明】本文为华为云社区用户原创内容,未经允许不得转载,如需转载请自行联系原作者进行授权。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。