1. 为什么要在 Keras 里手写一个混合密度网络
如果你做过回归任务,大概率遇到过这种场景:模型在某个输入区间上表现得很好,但一到数据稀疏或者本质多峰的区间,预测出来的结果就“糊”了——它给出的是一条平滑的曲线,而真实的数据分布其实有好几个聚集中心。普通回归模型输出的是单点估计,配上 MSE 损失,本质上假设了目标服从单峰高斯分布。一旦数据是多峰的,这个假设就崩了。
混合密度网络(Mixture Density Network,简称 MDN)就是来解决这个问题的。它不直接预测一个值,而是预测一整个概率分布的参数——具体来说,是若干个高斯分布的加权组合。每个高斯有自己的均值、方差,外加一个混合系数。这样一来,模型就能表达“这个输入对应的输出可能落在 A 附近,也可能落在 B 附近,而且 A 的概率更大”这种信息。
我第一次接触 MDN 是在做一个传感器反演的项目,输入是几个测量通道,输出是某个物理量。问题是这个物理量在某些工况下存在多解,用普通全连接网络怎么调都只能学到均值,误差下不去。换成 MDN 之后,验证集上的负对数似然直接降了一大截,而且预测出来的分布形状和实际数据的直方图对得上。从那以后,MDN 就成了我处理多峰回归问题的默认工具。
这篇内容我会把基于 TensorFlow.Keras 实现 MDN 的完整思路拆开讲。包括网络结构怎么设计、损失函数为什么是负对数似然、参数怎么初始化、训练时有哪些坑、推理阶段怎么从混合分布里取点。代码部分我会给出可以直接跑的版本,参数也会解释清楚为什么这么选。适合已经会用 Keras 搭普通网络、但想进一步处理不确定性建模的读者。如果你只是想做单峰回归,那 MDN 属于杀鸡用牛刀,但如果你发现残差呈现明显的多峰结构,那这篇就是给你准备的。
2. 混合密度网络的核心原理拆解
2.1 从单高斯到混合高斯:模型到底在学什么
普通回归网络最后一层通常是一个线性层,输出一个标量或向量,代表预测值。MDN 的最后一层不一样,它输出的是混合分布的参数。假设我们用 K 个高斯分量,输出维度就是 K 个均值、K 个方差(或者标准差)、K 个混合系数,总共 3K 个值。
这里有个关键点:混合系数必须满足非负且和为 1,方差必须为正。这两个约束不能靠网络自己学出来,得在结构上强制。混合系数用 softmax 激活,方差用 exp 激活或者 softplus 激活。均值不加约束,线性输出就行。
为什么是高斯混合而不是别的分布?因为高斯混合有通用逼近性质——足够多的高斯分量可以逼近任意连续分布。而且高斯的参数有闭式表达,训练起来稳定。当然你也可以用拉普拉斯分布或者其他,但高斯是最常用的起点。
数学上,给定输入 x,输出 y 的条件概率密度是:
p(y|x) = Σ_k π_k(x) * N(y | μ_k(x), σ_k(x))
其中 π_k 是混合系数,μ_k 是均值,σ_k 是标准差。网络要学的就是这三个关于 x 的函数。
2.2 损失函数为什么选负对数似然而不是 MSE
这是 MDN 和普通回归最大的区别。MSE 损失对应的是单高斯假设下的极大似然,一旦分布是多峰的,MSE 就会把多个峰平均掉。MDN 直接用负对数似然作为损失:
L = -log Σ_k π_k * N(y | μ_k, σ_k)
这个损失函数有个很好的性质:它同时优化混合系数、均值和方差。如果某个样本的 y 离所有均值都很远,那方差会被推大以降低损失;如果 y 靠近某个均值,那个分量的混合系数会被推高。整个过程是自洽的。
实际实现的时候要注意数值稳定性。直接算 log-sum-exp 容易溢出,得用数值稳定的版本。TensorFlow 里有tf.reduce_logsumexp,或者用tf.math.log_softmax配合。我一般会自己写一个稳定的 log-sum-exp,避免 NaN。
2.3 分量数量 K 怎么选:不是越多越好
K 是 MDN 最重要的超参数。K 太小,表达不了复杂分布;K 太大,训练慢还容易过拟合,而且会出现分量塌缩——几个高斯挤在一起,混合系数分配不均。
我的经验是:先看数据的直方图,数一下大概有几个峰,K 取峰数的 1.5 到 2 倍。比如数据明显有两个峰,K 取 3 到 4。如果数据是连续的但形状复杂,K 可以取 5 到 8。超过 10 个分量在大多数任务上收益就很小了。
还有一个技巧:训练完之后检查每个分量的混合系数。如果某些分量的 π_k 在所有样本上都接近 0,说明这些分量是多余的,可以减 K 重训。反过来如果所有分量的 π_k 都差不多,可能 K 还不够。
3. 基于 Keras 的完整实现方案
3.1 网络骨架:共享层加三个输出头
MDN 的网络结构可以分成两部分:共享的特征提取层和三个并行的输出头。共享层就是普通的全连接层或者卷积层,负责从输入中提取特征。三个输出头分别输出混合系数、均值、标准差。
import tensorflow as tf from tensorflow.keras import layers, Model def build_mdn(input_dim, output_dim, n_components, hidden_units=(128, 128)): inputs = layers.Input(shape=(input_dim,)) x = inputs for units in hidden_units: x = layers.Dense(units, activation='relu')(x) x = layers.Dropout(0.1)(x) # 混合系数头,softmax 保证非负且和为 1 pi = layers.Dense(n_components, activation='softmax', name='pi')(x) # 均值头,线性输出 mu = layers.Dense(n_components * output_dim, name='mu')(x) mu = layers.Reshape((n_components, output_dim))(mu) # 标准差头,exp 保证为正 sigma = layers.Dense(n_components * output_dim, name='sigma')(x) sigma = layers.Reshape((n_components, output_dim))(sigma) sigma = layers.Lambda(lambda t: tf.exp(t) + 1e-6)(sigma) model = Model(inputs=inputs, outputs=[pi, mu, sigma]) return model这里有几个细节值得说。Dropout 加在共享层后面,不要加在输出头上,否则会破坏分布的参数。sigma 用 exp 激活之后加一个极小值 1e-6,防止训练初期方差为 0 导致 log 爆炸。均值头输出维度是 n_components * output_dim,然后 reshape 成 (n_components, output_dim),这样每个分量对每个输出维度都有自己的均值和方差。
3.2 负对数似然损失的稳定实现
损失函数是 MDN 最容易出问题的地方。我见过不少人直接写 log(pi) + log(N(y|mu,sigma)) 然后求和,结果训练几个 epoch 就 NaN 了。原因是当 sigma 很小时,log(N) 会变成很大的负数,加上 log(pi) 之后数值范围超出浮点精度。
稳定的做法是用 log-sum-exp:
def mdn_loss(y_true, y_pred): pi, mu, sigma = y_pred y_true = tf.expand_dims(y_true, axis=1) # (batch, 1, output_dim) # 计算每个分量的 log 概率密度 log_sigma = tf.math.log(sigma) diff = y_true - mu log_prob = -0.5 * tf.reduce_sum( tf.square(diff) / tf.square(sigma) + 2 * log_sigma + tf.math.log(2 * np.pi), axis=-1 ) # (batch, n_components) log_pi = tf.math.log(pi + 1e-8) log_sum = tf.reduce_logsumexp(log_pi + log_prob, axis=-1) return -tf.reduce_mean(log_sum)注意tf.reduce_logsumexp会自动处理数值稳定性,它内部会减去最大值再算。log_pi加 1e-8 是防止 pi 为 0 时 log 变成负无穷。这个损失函数返回的是标量,可以直接传给model.compile。
3.3 训练配置与参数选择
编译模型的时候,优化器我一般用 Adam,学习率从 1e-3 开始。如果训练不稳定,降到 1e-4。batch size 取 64 或 128,太小的话梯度噪声大会让方差参数震荡。
model = build_mdn(input_dim=1, output_dim=1, n_components=5) model.compile(optimizer=tf.keras.optimizers.Adam(1e-3), loss=mdn_loss) history = model.fit(X_train, y_train, validation_split=0.2, epochs=500, batch_size=64, verbose=0)训练轮数通常比普通回归多,因为 MDN 要同时学三个参数组,收敛慢一些。我一般设 500 到 1000 轮,配合 EarlyStopping 监控验证集损失。注意监控的是负对数似然,不是 MSE,这两个指标趋势可能不一致。
还有一个坑:初始化。默认的 glorot 初始化对 sigma 头不太友好,容易一开始就输出很大的方差。我习惯把 sigma 头的 bias 初始化成 -1 左右,这样初始方差接近 exp(-1)≈0.37,比较合理。均值头的 bias 可以初始化成 0,混合系数头的 bias 初始化成 0 让 softmax 输出均匀分布。
4. 实操过程中的关键环节与调试技巧
4.1 数据准备:归一化比什么都重要
MDN 对输入的尺度很敏感。如果输入特征量纲差异大,共享层学起来会很吃力,间接影响分布参数的估计。我一般对所有输入做标准化,均值 0 方差 1。输出 y 也要归一化,但要注意:归一化之后 sigma 的物理意义变了,反归一化的时候均值和方差都要乘回原来的尺度。
具体来说,如果 y_norm = (y - mean) / std,那预测出来的 mu_norm 和 sigma_norm 要还原成 mu = mu_norm * std + mean,sigma = sigma_norm * std。混合系数不用变。这一步很容易忘,忘了的话预测出来的分布位置和宽度都是错的。
4.2 训练过程中的监控指标
光看损失不够,我一般会加几个自定义指标。一个是平均方差,监控 sigma 有没有塌缩到 0 或者爆炸。另一个是混合系数的熵,如果熵接近 0 说明所有样本都只用一个分量,K 可能设大了;如果熵接近 log(K) 说明所有分量均匀使用,可能 K 不够。
class MDNMetrics(tf.keras.callbacks.Callback): def on_epoch_end(self, epoch, logs=None): pi, mu, sigma = self.model.predict(X_val, verbose=0) avg_sigma = np.mean(sigma) entropy = -np.mean(np.sum(pi * np.log(pi + 1e-8), axis=-1)) logs['avg_sigma'] = avg_sigma logs['pi_entropy'] = entropy实测下来,健康的训练过程应该是 avg_sigma 先下降后稳定,pi_entropy 在 log(K) 的 60% 到 80% 之间。如果 avg_sigma 一直往下掉到 1e-4 以下,说明模型在作弊——把方差压小来降低损失,这时候预测分布会变成尖峰,泛化很差。解决办法是给 sigma 加一个下界,或者在损失里加正则项惩罚过小的方差。
4.3 推理阶段:从分布到具体预测
训练完之后,模型输出的是混合分布的参数。如果你只需要一个点预测,可以取混合分布的均值:
y_pred = Σ_k π_k * μ_k
这个值在最小化 MSE 的意义下是最优的。但如果你关心不确定性,可以取混合分布的众数,或者直接采样。采样的话按 π_k 选一个分量,然后从对应的 N(μ_k, σ_k) 里抽一个数。重复多次就能得到预测分布的样本。
我一般会画三张图:真实数据的直方图、预测分布的等高线图、以及几个典型输入下的预测分布曲线。这三张图能直观看出模型有没有学到多峰结构。
5. 常见问题排查与避坑指南
5.1 损失变成 NaN 怎么办
这是 MDN 最高频的问题。原因通常有三个:sigma 变成 0 或负数、pi 变成 0 导致 log 负无穷、梯度爆炸。排查顺序是:先检查 sigma 有没有加 exp 激活和极小值保护,再检查 pi 有没有加 1e-8,最后看学习率是不是太大。
如果都加了还是 NaN,试试梯度裁剪:
optimizer = tf.keras.optimizers.Adam(1e-3, clipnorm=1.0)clipnorm 设 1.0 或 0.5 都行。另外把 sigma 的激活从 exp 换成 softplus 也能缓解,softplus 在负数区域更平滑。
5.2 分量塌缩:多个高斯挤在一起
训练完之后如果发现几个分量的均值和方差几乎一样,说明发生了分量塌缩。这时候模型实际上退化成了单高斯,MDN 的多峰表达能力就浪费了。解决办法有几个:增加 K 然后靠混合系数自动抑制多余分量、在损失里加一个排斥项让不同分量的均值拉开、或者用不同的初始化让分量从不同起点开始。
我常用的是初始化时给均值头的 bias 加一点随机扰动,让初始分量分散开。另外训练初期可以先用较小的学习率,等分量分开了再恢复正常学习率。
5.3 预测分布太宽或太窄
如果预测出来的分布比真实分布宽很多,说明模型不确定,可能是训练数据不够或者 K 太小。如果太窄,说明过拟合了,方差被压得太小。前者加数据或加 K,后者加正则化或提前停止。
还有一个容易被忽略的点:验证集的负对数似然和测试集的表现可能差很多。如果验证集损失很低但测试集很差,说明模型在验证集上过拟合了,需要重新划分数据或者加 Dropout。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 损失 NaN | sigma 为 0 或 pi 为 0 | 打印 sigma 和 pi 的最小值 | 加 1e-6 保护、梯度裁剪 |
| 分量塌缩 | 初始化太集中 | 看各分量的均值差异 | 均值 bias 加扰动、加排斥项 |
| 分布太宽 | 欠拟合或 K 太小 | 对比预测和真实直方图 | 加数据、加 K、加训练轮数 |
| 分布太窄 | 过拟合 | 看验证集损失 | 加 Dropout、提前停止 |
| 训练不收敛 | 学习率太大 | 看损失曲线 | 降学习率到 1e-4 |
| 预测偏移 | 忘了反归一化 | 检查 mu 和 sigma 的尺度 | 乘回 std 加回 mean |
6. 一个完整的端到端示例
6.1 构造多峰数据
为了演示,我构造一个经典的多峰回归数据集:输入 x 在 [-1, 1] 均匀采样,输出 y = x + 0.3 * sin(2πx) + 噪声,但噪声的方差随 x 变化,而且在 x=0 附近有两个模态。
np.random.seed(42) N = 5000 x = np.random.uniform(-1, 1, N) y = np.zeros_like(x) for i in range(N): if abs(x[i]) < 0.3: # 双峰 if np.random.rand() < 0.5: y[i] = x[i] + 0.5 + np.random.normal(0, 0.1) else: y[i] = x[i] - 0.5 + np.random.normal(0, 0.1) else: y[i] = x[i] + np.random.normal(0, 0.1)这个数据集在 x 接近 0 的时候明显有两个分支,普通回归网络会预测出中间值,而 MDN 能捕捉到两个峰。
6.2 训练与结果可视化
把数据归一化之后喂给 MDN,K 取 5,训练 800 轮。训练完之后在 x 从 -1 到 1 均匀取 200 个点,对每个点预测混合分布参数,然后画出均值曲线和 ±1 标准差的区域。
x_test = np.linspace(-1, 1, 200).reshape(-1, 1) pi, mu, sigma = model.predict(x_test, verbose=0) y_mean = np.sum(pi[:, :, None] * mu, axis=1) y_std = np.sqrt(np.sum(pi[:, :, None] * (sigma**2 + mu**2), axis=1) - y_mean**2)画出来的图会显示:在 x=0 附近,均值曲线分叉成两条,标准差区域也变宽,正好对应数据的双峰结构。这就是 MDN 的价值所在。
6.3 从预测分布中采样
如果你想生成新的样本,可以按混合系数选分量再采样:
def sample_from_mdn(pi, mu, sigma, n_samples=1): samples = [] for _ in range(n_samples): k = np.random.choice(len(pi), p=pi) samples.append(np.random.normal(mu[k], sigma[k])) return np.array(samples)这个采样过程在生成式任务里很有用,比如你有一个条件生成模型,MDN 可以作为输出层来建模连续值的分布。
7. 几个我踩过的坑和实用建议
第一个坑是损失函数的实现。我一开始用tf.nn.log_softmax配合手动 log-sum-exp,结果发现tf.reduce_logsumexp在 axis 设置不对的时候会广播错误。后来统一用tf.reduce_logsumexp(log_pi + log_prob, axis=-1),axis 一定要设成 -1,因为 batch 维度是 0,分量维度是最后一维。
第二个坑是 sigma 的激活函数。exp 激活在训练初期容易输出很大的值,导致梯度爆炸。后来我改成 softplus 加一个小的偏移,训练稳定多了。softplus(x) = log(1 + exp(x)),在 x 很大时接近 x,在 x 很小时接近 0,比 exp 温和。
第三个坑是验证集损失的解读。MDN 的负对数似然和 MSE 不是线性关系,有时候 MSE 在降但 NLL 在升,这是因为模型在调整分布形状而不是点预测。这时候不要慌,看 NLL 的趋势就行,它才是 MDN 真正优化的目标。
第四个坑是 K 的选择。我试过 K=20,结果训练特别慢,而且大部分分量的混合系数都接近 0,白白浪费计算资源。后来固定用 K=5 到 8,效果反而更好。K 不是越大越好,够用就行。
最后一个建议:如果你的数据维度很高,比如输出是 10 维向量,那 MDN 的参数会爆炸。每个分量要输出 10 个均值和 10 个方差,K=5 就是 100 个参数。这时候可以考虑对角协方差或者低秩近似,但那是另一个话题了。对于大多数一维或二维输出任务,标准 MDN 完全够用。
这个实现方案我在几个项目里反复用过,从传感器反演到时间序列预测,只要数据存在多峰结构,MDN 基本都能带来明显提升。代码不长,核心就是那几十行,但里面的细节决定了能不能训起来。如果你第一次跑遇到 NaN,别急着换模型,先检查 sigma 和 pi 的保护有没有加对。