
1. 从“画”开始为什么我们要亲手绘制激活函数如果你刚开始接触深度学习或者神经网络可能会在无数的教程和论文里看到Sigmoid、ReLU这些名字。它们通常被称作“激活函数”是神经网络里决定神经元是否“兴奋”的关键组件。很多初学者包括当年的我一开始都是直接调用torch.nn.ReLU()或者tf.nn.sigmoid把函数当成一个黑盒来用。公式虽然也看但总觉得隔着一层纱理解不够透彻。直到有一次我在调试一个模型时发现梯度消失得特别快训练几乎停滞。排查了半天最后定位到是某一层错误地选用了Sigmoid函数。那一刻我才真正意识到仅仅知道函数的名字和调用方式是远远不够的。你必须“看见”它——看见它的形状看见它的导数梯度变化看见它在不同输入区间下的行为。而“看见”最直观的方式就是亲手把它画出来。用Python画图远不止是得到一个漂亮的曲线。这个过程强迫你去理解函数的定义域、值域、数学表达式以及最重要的——它的特性如何影响了神经网络的训练。比如Sigmoid的输出为什么会被压缩在0到1之间ReLU的“死区”到底在哪里Softmax为什么能把一堆数字变成概率分布这些问题在动手编码绘图的过程中答案会变得异常清晰。所以这个系列的第一篇我们不谈复杂的网络结构也不做项目实战就做一件最基础但至关重要的事用Python的Matplotlib库把Sigmoid、ReLU、Softmax、Tanh、Leaky ReLU这几个最核心的激活函数以及它们对应的导数函数清晰地绘制出来。我会带你一步步搭建绘图环境编写每一个函数并详细解释图像背后的每一个关键点。当你完成时这些函数对你而言将不再是抽象的符号而是有形状、有性格的“工具”你能清楚地知道在什么场景下该请谁“出场”。2. 绘图基石搭建你的Python可视化环境工欲善其事必先利其器。在开始画函数之前我们需要一个稳定、顺手的环境。对于科学计算和可视化我强烈推荐使用Anaconda来管理Python环境它集成了几乎所有我们需要的科学计算库并且能很好地处理包依赖问题避免“装了这个那个又报错”的窘境。2.1 环境安装与核心库介绍首先如果你还没有安装Python可以去Python官网下载最新版本但我更建议直接安装Anaconda。安装过程很简单一路下一步即可。安装完成后你会拥有一个独立的、干净的Python环境。我们这次绘图的核心武器是两个库NumPy和Matplotlib。NumPy它是Python科学计算的基石。我们画函数图本质上是在坐标轴上描点。我们需要生成一系列在X轴上的点比如从-10到10然后根据函数公式计算出每个点对应的Y值。NumPy的np.linspace和np.array操作能极其高效地完成这项任务。Matplotlib这是Python绘图领域的事实标准功能强大且灵活。我们将主要使用它的pyplot模块这个模块提供了一套类似MATLAB的绘图接口非常容易上手。在Anaconda环境中这两个库通常已经预装好了。你可以打开终端Windows叫Anaconda PromptMac/Linux叫Terminal输入以下命令来确认和安装# 检查是否已安装 conda list numpy matplotlib # 如果未安装使用conda安装推荐能自动处理依赖 conda install numpy matplotlib # 或者使用pip安装 pip install numpy matplotlib安装无误后我们就可以在代码中导入它们了。标准的导入惯例是import numpy as np import matplotlib.pyplot as plt这里as np和as plt是别名是社区约定俗成的写法能让代码更简洁。2.2 构建统一的绘图画布与坐标轴一个好的对比图应该让观众一眼就能看出差异。如果我们把五个函数曲线杂乱地画在一起会显得非常混乱。因此我习惯为每个函数及其导数创建独立的子图Subplot并将它们排列在一个大画布上。这里会用到Matplotlib的plt.subplots函数。它一次性创建画布Figure和一组坐标轴Axes对象非常方便。我们来规划一下布局我们有5个激活函数每个函数我们既要画它的曲线也要画它的导数曲线。所以总共需要10个子图。我们可以把它们排列成5行2列。# 创建一个画布和10个子图5行2列画布尺寸设置为宽15英寸高20英寸以保证每个子图有足够的空间 fig, axes plt.subplots(5, 2, figsize(15, 20)) # fig是画布对象axes是一个5行2列的二维数组存储了每个子图的坐标轴对象 # 例如axes[0, 0] 就是第一行第一列的子图Sigmoid函数图axes[0, 1] 就是第一行第二列的子图Sigmoid导数图接下来我们需要为所有函数生成统一的X轴输入数据。为了能清晰展示函数在正负区间的行为我们选择一个对称且范围足够的区间比如从-10到10。# 生成从-10到10共1000个等间距的点。点数越多曲线越平滑。 x np.linspace(-10, 10, 1000)准备工作就绪现在我们可以开始逐个“绘制”这些函数了。记住我们的目标不是简单地画出线而是要理解这条线为什么长这样。3. 经典元老Sigmoid函数的平滑与困境Sigmoid函数可以说是神经网络激活函数的“开国元老”在早期感知机模型中广泛应用。它的数学表达式是[ \sigma(x) \frac{1}{1 e^{-x}} ]3.1 Sigmoid函数的实现与绘图用NumPy实现它非常简单因为NumPy提供了指数计算np.exp。def sigmoid(x): 计算Sigmoid函数值 return 1 / (1 np.exp(-x)) # 计算y值 y_sigmoid sigmoid(x)现在我们在第一个子图axes[0, 0]上绘制它。ax axes[0, 0] # 获取第一行第一列的坐标轴 ax.plot(x, y_sigmoid, labelSigmoid, colorblue, linewidth2) ax.set_title(Sigmoid Activation Function) ax.set_xlabel(Input (x)) ax.set_ylabel(Output σ(x)) ax.grid(True, linestyle--, alpha0.6) # 添加网格线方便观察 ax.legend() ax.set_ylim(-0.1, 1.1) # 将Y轴范围固定在-0.1到1.1因为Sigmoid输出在(0,1)运行这段代码你会看到一条漂亮的S型曲线。它有几个关键特性输出范围在(0, 1)无论输入多大或多小输出都被压缩到0和1之间。这使得它可以被解释为一种“概率”例如二分类问题的输出。单调连续函数处处可导且是平滑的。中心对称点在x0时输出为0.5。3.2 Sigmoid的导数梯度消失问题的根源一个函数的导数描述了其变化率。在神经网络的反向传播中我们需要计算梯度而梯度就是通过激活函数的导数来传递的。Sigmoid的导数有一个很有趣的性质[ \sigma(x) \sigma(x) \cdot (1 - \sigma(x)) ]我们可以用两种方式计算并绘制它一是直接用上面的导数公式二是用数值方法如中心差分法来近似后者在验证我们公式正确性时很有用。# 方法一使用导数公式 def sigmoid_derivative(x): s sigmoid(x) return s * (1 - s) y_sigmoid_derivative sigmoid_derivative(x) # 方法二数值导数用于验证 def numerical_derivative(f, x, h1e-5): 计算函数f在点x处的数值导数中心差分法更精确 return (f(x h) - f(x - h)) / (2 * h) y_sigmoid_derivative_num numerical_derivative(sigmoid, x) # 绘制到第二个子图 (axes[0, 1]) ax axes[0, 1] ax.plot(x, y_sigmoid_derivative, labelDerivative (Formula), colorred, linewidth2, linestyle-) ax.plot(x, y_sigmoid_derivative_num, labelDerivative (Numerical), colorgreen, linewidth1, linestyle--, alpha0.7) ax.set_title(Derivative of Sigmoid) ax.set_xlabel(Input (x)) ax.set_ylabel(dσ/dx) ax.grid(True, linestyle--, alpha0.6) ax.legend() ax.set_ylim(-0.1, 0.3)观察导数图你会发现一个严重的问题当输入x的绝对值很大时无论是正还是负Sigmoid的导数都趋近于0。在反向传播中梯度是链式相乘的。如果某一层的梯度接近0那么乘以这个梯度后传递到更前面层的梯度会以指数级速度衰减直至消失。这就是著名的“梯度消失”问题。这使得深层网络使用Sigmoid时变得极难训练。因此在现代深度学习中Sigmoid通常只用于输出层做二分类概率而隐藏层已很少使用。实操心得画导数图时用数值导数中心差分法和解析导数公式一起画用虚线稍微区分。这不仅能验证你写的导数公式是否正确还能直观地向读者展示“导数”这个抽象概念其实就是函数在某一点切线的斜率。当两条线完全重合时说明你的公式推导和代码实现都是正确的。4. 现代王者ReLU家族及其变种为了解决Sigmoid带来的梯度消失问题在正区间其实也有但更严重的是饱和区的梯度消失ReLURectified Linear Unit修正线性单元被提出并迅速成为深度隐藏层的默认选择。4.1 标准ReLU简单粗暴的有效ReLU的定义简单得令人惊讶[ \text{ReLU}(x) \max(0, x) ]它的意思是如果输入x大于0输出就是x本身如果输入x小于等于0输出就是0。用NumPy实现我们可以利用其向量化操作的特性一行代码搞定def relu(x): 计算ReLU函数值 return np.maximum(0, x) y_relu relu(x)在子图axes[1, 0]上绘制ax axes[1, 0] ax.plot(x, y_relu, labelReLU, colororange, linewidth2) ax.set_title(ReLU Activation Function) ax.set_xlabel(Input (x)) ax.set_ylabel(Output ReLU(x)) ax.grid(True, linestyle--, alpha0.6) ax.legend() # ReLU输出范围是[0, ∞)我们设定一个合理的显示范围 ax.set_ylim(-1, 11)图像是一条折线在负半轴是平的值为0在正半轴是一条斜率为1的直线。它的优点非常突出计算极其高效只需要比较和取最大值没有指数、除法等复杂运算。缓解梯度消失在正区间导数为常数1梯度可以毫无衰减地传递下去极大地促进了深层网络的训练。带来稀疏性负半轴输出为0使得网络中的一部分神经元被“关闭”这相当于一种隐式的稀疏化可能让模型更易解释且具有更好的泛化能力。4.2 ReLU的导数与“死区”问题ReLU的导数也不复杂 [ \text{ReLU}(x) \begin{cases} 1 \text{if } x 0 \ 0 \text{if } x \le 0 \end{cases} ] 注意在x0处导数在数学上是不确定的不可导。但在实际实现中通常约定俗成地将其设为0或1PyTorch和TensorFlow中通常设为0。def relu_derivative(x): 计算ReLU的导数 # 这里我们采用x0时为1否则为0的约定 return np.where(x 0, 1.0, 0.0) y_relu_derivative relu_derivative(x) # 绘制到 axes[1, 1] ax axes[1, 1] ax.plot(x, y_relu_derivative, labelDerivative of ReLU, colordarkorange, linewidth2, drawstylesteps-post) # 使用阶梯图样式更直观 ax.set_title(Derivative of ReLU) ax.set_xlabel(Input (x)) ax.set_ylabel(dReLU/dx) ax.grid(True, linestyle--, alpha0.6) ax.legend() ax.set_ylim(-0.1, 1.5)从导数图可以清晰地看到ReLU的**“死区”Dying ReLU问题**一旦某个神经元的输入加权和落入了负半轴x0它的梯度就变成了0。在后续的训练中这个梯度为0的神经元将无法再通过梯度下降法更新其权重相当于“死亡”了永远输出0。如果这种情况大面积发生网络的有效容量会大幅下降。4.3 Leaky ReLU给负区间一点生机为了解决“死区”问题Leaky ReLU被提出。它对负区间不再一概置零而是赋予一个很小的斜率比如0.01。[ \text{LeakyReLU}(x) \begin{cases} x \text{if } x 0 \ \alpha x \text{if } x \le 0 \end{cases} ] 其中(\alpha)是一个很小的正数如0.01。def leaky_relu(x, alpha0.01): 计算Leaky ReLU函数值 return np.where(x 0, x, alpha * x) y_leaky_relu leaky_relu(x, alpha0.01)在子图axes[2, 0]绘制。你会发现它的图像在负半轴是一条斜率很小的斜线不再与X轴重合。ax axes[2, 0] ax.plot(x, y_leaky_relu, labelfLeaky ReLU (α{0.01}), colorpurple, linewidth2) ax.set_title(Leaky ReLU Activation Function) ax.set_xlabel(Input (x)) ax.set_ylabel(Output LeakyReLU(x)) ax.grid(True, linestyle--, alpha0.6) ax.legend() ax.set_ylim(-0.5, 11)它的导数在负区间是一个很小的常数(\alpha)保证了梯度永远不会完全消失。def leaky_relu_derivative(x, alpha0.01): 计算Leaky ReLU的导数 return np.where(x 0, 1.0, alpha) y_leaky_relu_derivative leaky_relu_derivative(x, alpha0.01) # 绘制到 axes[2, 1] ax axes[2, 1] ax.plot(x, y_leaky_relu_derivative, labelfDerivative (α{0.01}), colordarkviolet, linewidth2, drawstylesteps-post) ax.set_title(Derivative of Leaky ReLU) ax.set_xlabel(Input (x)) ax.set_ylabel(dLeakyReLU/dx) ax.grid(True, linestyle--, alpha0.6) ax.legend() ax.set_ylim(-0.02, 1.5)Leaky ReLU是ReLU一个简单有效的改进在实践中尤其是使用较深网络时往往能获得比标准ReLU更稳定、更好的训练效果。参数(\alpha)通常作为超参数也可以学习得到这就是Parametric ReLU, PReLU。注意事项在绘制Leaky ReLU时因为负区间的值很小-10*0.01 -0.1如果Y轴范围设置得和ReLU一样如-1到11负区间的那条线会几乎贴在X轴上看不清楚。因此我特意将Y轴下限设为-0.5以便清晰展示负区间的微小斜率。这是绘图时的一个小技巧根据数据范围动态调整坐标轴以突出你想展示的关键特征。5. 双曲正切Tanh一个中心化的SigmoidTanh双曲正切函数可以看作是Sigmoid的“放大平移”版本。它的公式是[ \tanh(x) \frac{e^{x} - e^{-x}}{e^{x} e^{-x}} 2 \cdot \sigma(2x) - 1 ]从最后一个等式可以看出它其实就是把Sigmoid函数的输入放大2倍输出范围从(0,1)线性变换到了(-1,1)。5.1 Tanh的实现与特性NumPy直接提供了np.tanh函数我们也可以自己实现来加深理解。def tanh_custom(x): 自定义实现Tanh函数 return (np.exp(x) - np.exp(-x)) / (np.exp(x) np.exp(-x)) # 使用NumPy内置函数更高效稳定 y_tanh np.tanh(x) # 或 y_tanh tanh_custom(x)在子图axes[3, 0]绘制ax axes[3, 0] ax.plot(x, y_tanh, labelTanh, colorbrown, linewidth2) ax.set_title(Tanh Activation Function) ax.set_xlabel(Input (x)) ax.set_ylabel(Output tanh(x)) ax.grid(True, linestyle--, alpha0.6) ax.legend() ax.set_ylim(-1.1, 1.1) # 输出范围是(-1, 1)与Sigmoid对比Tanh具有零中心化的特性即其输出以0为中心。这在某些情况下是有益的因为下一层神经元的输入是正负交替的可能有助于缓解梯度更新时的“Z”字形震荡加速收敛。因此在循环神经网络RNN中Tanh仍然被广泛使用。然而它和Sigmoid一样在两端也存在饱和区导数会趋近于0因此同样存在梯度消失的问题。5.2 Tanh的导数Tanh的导数公式为 [ \tanh(x) 1 - \tanh^2(x) ] 它的值域在(0, 1]之间当x0时导数值最大为1。def tanh_derivative(x): 计算Tanh的导数 return 1 - np.tanh(x) ** 2 y_tanh_derivative tanh_derivative(x) # 绘制到 axes[3, 1] ax axes[3, 1] ax.plot(x, y_tanh_derivative, labelDerivative of Tanh, colorsaddlebrown, linewidth2) ax.set_title(Derivative of Tanh) ax.set_xlabel(Input (x)) ax.set_ylabel(dtanh/dx) ax.grid(True, linestyle--, alpha0.6) ax.legend() ax.set_ylim(-0.1, 1.1)观察导数图其形状和Sigmoid的导数类似都是一个“钟形”曲线但最大值是1在x0处且关于Y轴对称。这意味着Tanh在0附近有更强的梯度信号。6. 概率转换器Softmax函数的特殊性与可视化挑战Softmax函数与前几个函数有本质区别。Sigmoid、ReLU、Tanh都是逐元素操作每个神经元的输出只依赖于自己的输入。而Softmax是向量函数它的输出是一个概率分布每个元素的输出依赖于整个输入向量的所有值。对于一个K维向量(\mathbf{z} [z_1, z_2, ..., z_K])Softmax的定义是 [ \text{Softmax}(z_i) \frac{e^{z_i}}{\sum_{j1}^{K} e^{z_j}} ] 它的输出满足两个重要性质1) 每个元素在(0,1)之间2) 所有元素之和为1。这使得它非常适合作为多分类神经网络输出层的激活函数。6.1 Softmax的绘图需要升维思考由于Softmax的输入和输出都是向量我们无法像之前那样简单地用yf(x)在二维平面上画出一条线。为了可视化我们需要固定一个视角。最常见的方法是假设我们有一个3维的输入向量[x1, x2, x3]然后固定其中两个维度的值观察第三个维度变化时三个输出概率的变化。例如我们令x2 1.0,x3 0.5然后让x1在区间[-10, 10]上变化观察三个输出p1, p2, p3。def softmax(z): 计算Softmax函数值z可以是一个向量或一批向量 # 减去最大值防止指数运算溢出数值稳定技巧 exp_z np.exp(z - np.max(z, axis-1, keepdimsTrue)) return exp_z / np.sum(exp_z, axis-1, keepdimsTrue) # 生成x1的变化范围 x1_range np.linspace(-10, 10, 1000) # 固定x2和x3 x2_fixed 1.0 x3_fixed 0.5 # 初始化存储三个概率值的数组 p1_list, p2_list, p3_list [], [], [] for x1 in x1_range: # 构造输入向量 z np.array([x1, x2_fixed, x3_fixed]) # 计算softmax probs softmax(z) p1_list.append(probs[0]) p2_list.append(probs[1]) p3_list.append(probs[2]) p1_array np.array(p1_list) p2_array np.array(p2_list) p3_array np.array(p3_list)现在我们在子图axes[4, 0]上绘制三条曲线分别代表p1,p2,p3随x1变化的情况。ax axes[4, 0] ax.plot(x1_range, p1_array, labelp1 (x1变化), colorteal, linewidth2) ax.plot(x1_range, p2_array, labelp2 (x2固定1.0), colorskyblue, linewidth2) ax.plot(x1_range, p3_array, labelp3 (x3固定0.5), colorlightcoral, linewidth2) ax.set_title(Softmax Output (3-class, x21.0, x30.5)) ax.set_xlabel(Input x1) ax.set_ylabel(Probability) ax.grid(True, linestyle--, alpha0.6) ax.legend() ax.set_ylim(-0.05, 1.05)从图中你可以直观地看到Softmax的“竞争”特性当x1远大于x2和x3时比如x1 5p1接近1p2和p3接近0。当x1远小于其他值时p1接近0。三条曲线的概率之和在任何一点都恒等于1。6.2 Softmax的“导数”Jacobian矩阵Softmax的“导数”更为复杂因为它的输出是一个向量输入也是一个向量。它的导数是一个Jacobian矩阵其中第i行第j列的元素是(\frac{\partial p_i}{\partial z_j})。这个矩阵不是对角阵因为每个输出(p_i)都对所有输入(z_j)有依赖。对于一个具体的输出(p_i)其关于输入(z_j)的偏导数为 [ \frac{\partial p_i}{\partial z_j} p_i (\delta_{ij} - p_j) ] 其中(\delta_{ij})是克罗内克δ函数当ij时为1否则为0。这意味着Softmax的梯度计算涉及到整个输出向量。可视化这个完整的Jacobian矩阵很困难。通常在反向传播的特定上下文中比如计算交叉熵损失对logits的梯度会有一个非常简洁的形式。但为了本教程的完整性我们可以选择一个简单的标量输出来观察其梯度。例如我们固定x21.0, x30.5只考虑第一个类别的概率p1相对于其输入x1的变化率即(\frac{\partial p_1}{\partial x_1} p_1(1 - p_1))。注意这不是完整的梯度因为p1也随x2和x3变化但这里我们只变化x1。# 计算 p1 对 x1 的偏导数在x2, x3固定的前提下 p1_derivative_wrt_x1 p1_array * (1 - p1_array) # 绘制到 axes[4, 1] ax axes[4, 1] ax.plot(x1_range, p1_derivative_wrt_x1, label∂p1/∂x1 (approx.), colordarkcyan, linewidth2) ax.set_title(Partial Derivative of p1 w.r.t x1\n(x2, x3 fixed)) ax.set_xlabel(Input x1) ax.set_ylabel(∂p1/∂x1) ax.grid(True, linestyle--, alpha0.6) ax.legend() ax.set_ylim(-0.05, 0.3)这个图看起来很像Sigmoid的导数图也是一个钟形曲线。它告诉我们当p1的概率接近0.5时即x1与x2、x3的值相近竞争激烈时梯度最大当p1接近0或1时即某个类别概率绝对占优或绝对劣势时梯度很小。这符合直觉当模型对当前样本的预测非常确信或非常不确定时梯度信号弱参数更新幅度小当模型“犹豫不决”时梯度信号最强。重要提示Softmax的可视化是本章最难的部分因为它本质上是高维函数。我们这里采用的方法是“切片”可视化即固定其他维度观察一个维度变化的影响。这是一种理解高维函数的常用技巧。在实际编程中softmax函数及其梯度计算都是由深度学习框架如PyTorch、TensorFlow自动完成的但理解其背后的数学和图像对于调试模型、理解损失函数的行为至关重要。7. 最终整合与图像美化我们已经完成了所有函数和导数的计算与绘制。现在我们需要调整一下整体布局让这10个子图看起来更整洁、专业然后显示最终图像。# 调整子图之间的间距防止标题和标签重叠 plt.tight_layout() # 显示图形 plt.show()如果你是在Jupyter Notebook或类似交互式环境中运行plt.show()会弹出一个窗口显示图像。如果你是在脚本中运行你可能需要将图像保存到文件plt.savefig(activation_functions.png, dpi300, bbox_inchestight) # 保存为高清PNG plt.show()运行完整的代码你会得到一张包含10个子图的大图清晰地展示了五大激活函数及其导数的全貌。通过这张图你可以直观地比较Sigmoid/Tanh的平滑饱和与梯度消失。ReLU的线性区域与“死区”。Leaky ReLU如何尝试解决“死区”。Softmax如何将竞争性输入转化为概率分布。这张图本身就是一份极佳的参考资料。我建议你在学习深度学习时经常回顾它思考不同激活函数的特性如何影响你网络的前向传播和反向传播。当你下次为网络中的某一层选择激活函数时脑海中能立刻浮现出这些曲线的形状那么你的选择将会更加有理有据。代码写到这里一个完整的、可复现的激活函数可视化工具就完成了。但更重要的是在这个过程中我们不是被动地接受定义而是主动地通过代码去探索、验证和理解每一个数学公式背后的几何意义。这才是“动手”学习的真谛。在下一篇文章中我们可以基于这个基础进一步探索如何将这些激活函数集成到一个简单的神经网络中并可视化每一层的输出分布那将是另一个有趣的故事。