深度学习回顾:前馈神经网络、反向传播算法、训练与验证流程

说实话,聊分子生成之前,我必须先带你过一遍最基础的东西。

前馈神经网络、反向传播、训练验证——这些听起来有点枯燥对吧?但我做项目时吃过不少亏,发现90%的模型翻车都出在这些「老生常谈」上。

你想想看,如果连前向传播的数学流都理不清,后面探讨Dropout、分子性质预测,那不就是空中楼阁吗?

前馈神经网络:信息是怎么一路「馈」下去的?

前馈神经网络,说白了就是数据从输入层一路传到输出层,中间不回头。每一层对上一层的输出做一次线性变换,再加个非线性激活函数。

我用一个最简单的三层网络来举例:

核心公式链

z[1] = W[1] · x + b[1]          # 隐藏层线性变换
a[1] = σ(z[1])                  # 隐藏层激活
z[2] = W[2] · a[1] + b[2]      # 输出层线性变换
y_hat = softmax(z[2])          # 输出层激活(分类任务)

每个节点其实就干了两件事:先加权求和,再「决定」要不要激活。

有件事我觉得特别重要——激活函数的选择。我个人习惯在分子生成任务中优先用ReLU族,原因很直接:

  • ReLU:计算快,能缓解梯度消失。我第一次用ReLU时也纳闷,负值怎么办?后来发现,正是这种「不激活」让网络变得稀疏高效。
  • Sigmoid/Tanh:容易饱和。深层网络里用它们,反向传播时梯度基本就传不动了。

嗯,这里要注意:偏置项b经常被人忽略。我在一个分子活性预测项目里,去掉偏置后模型收敛慢了三倍。别小看这个偏移量,它给了网络「不经过原点」的自由度。

参数维度物理含义
W[l]n[l] × n[l-1]第l层到第l+1层的连接权重
b[l]n[l] × 1第l层节点的偏置向量
z[l]n[l] × 1第l层未经激活的线性输出
a[l]n[l] × 1第l层经过激活后的输出

小技巧:做分子生成时,输入层维度通常对应分子指纹或者原子特征数。我习惯先用一个宽一点的隐藏层(比如512维),再慢慢压缩。太窄的隐藏层容易丢失分子结构信息。

反向传播算法:误差是怎么「倒着传」回去的?

前馈搞定了,接下来是训练的核心——反向传播。

反向传播的本质就是链式法则。我刚开始学的时候被各种公式绕晕了,后来自己手推了一遍才算真正理解。

先从损失函数开始。对于分子性质预测这种回归任务,常用的均方误差:

L(y, y_hat) = (1/2) * (y - y_hat)²

目标是让这个L尽可能小。怎么做?梯度下降,更新每一层的参数:

W[l] := W[l] - η * ∂L/∂W[l]
b[l] := b[l] - η * ∂L/∂b[l]

关键在于怎么求∂L/∂W[l]。我手写一下这个过程:

  1. 先算输出层的误差δ[2] = (a[2] - y) ⊙ σ'(z[2])
  2. 然后误差往回传:δ[1] = (W[2]ᵀ · δ[2]) ⊙ σ'(z[1])
  3. 最后梯度:∂L/∂W[l] = δ[l] · (a[l-1])ᵀ

为什么这么设计?

你想想看,反向传播的每一层都在做同一件事——把上一层的误差「分摊」到当前层的每个参数上。分摊的比例就是链式法则里的偏导数。

我曾经犯过一个经典错误:在深层网络里直接用Sigmoid激活,结果训练几个epoch后损失就不动了。检查梯度发现,靠近输入层的参数几乎没更新——梯度在反向传播过程中已经「消失」了。

用代码实现一个简化的反向传播:

import numpy as np

def forward(x, W1, b1, W2, b2):
    z1 = np.dot(W1, x) + b1
    a1 = np.maximum(0, z1)           # ReLU
    z2 = np.dot(W2, a1) + b2
    a2 = 1 / (1 + np.exp(-z2))       # Sigmoid输出
    return a1, a2

def backward(x, y, a1, a2, W2):
    m = x.shape[1]
    dz2 = a2 - y                      # 输出层误差
    dW2 = (1/m) * np.dot(dz2, a1.T)   # 输出层权重梯度
    db2 = (1/m) * np.sum(dz2, axis=1, keepdims=True)
    dz1 = np.dot(W2.T, dz2) * (a1 > 0) # 隐藏层误差(ReLU导数)
    dW1 = (1/m) * np.dot(dz1, x.T)
    db1 = (1/m) * np.sum(dz1, axis=1, keepdims=True)
    return dW1, db1, dW2, db2

提示:上面的代码里,(a1 > 0)就是ReLU的「导数」——正值梯度为1,非正值梯度为0。这个设计让误差只能通过激活的神经元回传,自然会迫使网络学习到更关键的特征。

训练与验证流程:别让你的模型「死记硬背」

模型能跑了,但怎么知道它好不好?这就来到了训练与验证的环节。

我在验证这块吃过不少亏,所以想重点说说。

标准流程一般是这样的:

  1. 数据分割:训练集、验证集、测试集。分子生成任务里,我习惯按分子骨架来分,而不是随机分。否则会导致训练集和验证集出现非常相似的分子,验证指标会虚高。
  2. 一个epoch内:遍历训练集 -> 前向传播 -> 计算损失 -> 反向传播 -> 更新参数
  3. 每个epoch结束:在验证集上跑一次前向传播,不更新梯度,只计算损失和指标
  4. 早停:如果验证损失连续几个epoch不降,就停止训练。我在一个分子构象生成项目里,不加早停直接训了200个epoch,结果模型过拟合得一塌糊涂。

避坑指南:我曾经犯过一个低级错误——在训练过程中「偷看」测试集。当时为了调参方便,直接用测试集上的指标来选模型。直到最后提交时发现结果差了一截才反应过来。记住:测试集只能用来做最终评估,一次也不能提前用。

这里有个常见的问题:验证集和测试集的大小怎么定?

我个人习惯:数据量大(百万级)时,98-1-1分割;数据量小(千级)时,80-10-10。做分子生成,数据量通常不大,六四开或七三开比较常见。

为什么需要验证集?

直接点说:防过拟合。你想想看,一个好的模型能完美记住训练集的所有噪声和异常点,但对于新的分子,它是什么表现?验证集就是一面镜子,让你看到模型在未见数据上的真实水平。

训练过程中的典型「健康指标」:

现象可能原因怎么办
训练损失不断下降,验证损失上升过拟合加Dropout、加正则化、减小模型容量
训练损失和验证损失都高欠拟合增大模型、加层、加训练轮数
训练损失下降慢梯度消失/学习率太小换激活函数、增大学习率
损失震荡剧烈学习率太大/batch太小减小学习率、增大batch size

做分子生成的时候,我还会额外监控一个指标——生成分子的可合成性分数。光看损失还不够,有时候损失很低,但生成出来的分子结构全是化学上不合理的。这时候就需要用验证集上的领域知识来把关。

经验补遗:我曾经为了一批分子活性预测的数据,花了三天时间清洗和归一化。结果模型性能提升明显。所以别把训练与验证流程只当作调模型的事——数据处理的质量决定了模型性能的天花板。

整个深度学习训练的流程,用一张图来总结:

深度学习训练与验证流程图 深度学习训练与验证核心流程 数据准备与预处理 训练集 / 验证集 / 测试集 模型与参数初始化 训 练 循 环 前向传播 损失计算 反向传播 更新参数 下一个batch / epoch 验证集评估(每epoch) 早停 / 模型保存 每epoch结束 验证集不参与反向传播,只用来监控模型泛化能力

你看这个流程,本质上就是一个闭环。前向传播算输出,反向传播调参数,验证集做监控。这里面任何一个环节出问题,整个训练都会跑偏。

总结一下重点:

  • 前馈网络就是多层「线性变换 + 非线性激活」的堆叠
  • 反向传播本质是链式法则,关键在于误差的逐层回传
  • 训练和验证必须严格分离,验证集是你的「体检报告」
  • 过拟合、欠拟合、梯度消失——这些老问题依然值得你警惕

嗯,这些基础打牢了,后面谈Dropout、谈分子生成里的各种技巧,你才能理解它们到底在解决什么问题。