ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

用Python+Keras绘制激活函数图像:从Sigmoid到Swish的可视化实战

用Python+Keras绘制激活函数图像:从Sigmoid到Swish的可视化实战

开头想先聊一个很实际的感受:很多朋友学 Keras 时,第一个接触的“非线性”就是激活函数,书里写“Sigmoid 将输入压缩到 0 到 1 之间”,你一看公式好像懂了,但等到真正搭网络、看 loss 曲线、分析某一层输出分布时,才发现自己对激活函数的“性格”根本不熟。后来我养成了一个习惯:每个新项目开始前,先把要用的激活函数用 Python 加 Matplotlib 画出来,放在手边当参照。这比背公式有效得多。这篇内容就围绕“用 Python + Keras 画出各种激活函数对应图像”这件事,把常见函数的原理、图像特征、绘制代码和踩坑记录一次讲清楚。适合刚上手深度学习的新手,也适合正在调参、想快速对比函数差异的工程师。

1. 为什么要给激活函数画图:从“纸上公式”到“直观手感”

1.1 激活函数决定神经网络的“表达性格”

神经网络本质上是在做一系列线性变换加非线性映射。如果没有激活函数,多层网络无论叠多少层,最终都等价于一个线性变换,那“深度”就没有意义了。激活函数就是给网络注入非线性能力的关键角色。

但不同激活函数的“非线性方式”差别巨大。Sigmoid 是光滑的 S 形曲线,Tanh 是零中心的 S 形,ReLU 则是一根折线。它们对梯度传播、神经元死亡、输出分布的影响完全不同。光看公式很难建立起直观判断,比如 ReLU 在负数区域输出恒为 0,这个“硬截断”到底会造成什么效果?你只有把图像画出来,再叠加它的导数曲线,才能一下子理解为什么深层网络里 ReLU 会导致部分神经元“坏死”。

我个人的经验是:画图不只是为了好看,而是为了建立“函数行为”与“网络训练现象”之间的映射。比如训练时发现某层输出大量接近 0,你回头看一眼激活函数图像,就能猜到是 ReLU 的稀疏性导致的;如果发现梯度爆炸,看看 Sigmoid 两端的饱和区,也就明白问题出在哪了。

1.2 图像可视化带给我们的三个关键信息

第一,看输出范围。Sigmoid 输出范围是 0 到 1,Tanh 是 -1 到 1,ReLU 是 0 到正无穷。输出范围直接影响下一层输入的分布,比如 BN 层之前用 Tanh 和用 ReLU,统计特性完全不同。

第二,看梯度行为。激活函数图像的“陡峭程度”就是导数的绝对值。函数在某个区间太平坦,意味着梯度趋近于 0,参数更新会非常慢,这就是“饱和”问题。图像能直观显示哪些区域是危险区。

第三,看非线性强度。ReLU 在正区间是线性的,但它整体又是非线性的,这种“分段线性”特性让优化更容易。而 Sigmoid 处处非线性但容易饱和。通过图像对比,你能理解为什么不同任务要选不同的函数。

所以,与其死记硬背“ReLU 比 Sigmoid 好”,不如自己动手画一次图。画完你自然会发现:Sigmoid 在 0 附近还有一点梯度,但到 ±3 之后就几乎是平的;ReLU 在正区间梯度恒为 1,负区间恒为 0,这是一种非常“极端”的性格。

2. 环境准备与绘图工具选型

2.1 最小依赖清单与安装

绘制激活函数图像,最核心的依赖是 NumPy 和 Matplotlib。Keras 本身也提供了激活函数接口,但绘图时我们通常直接用 NumPy 构造输入数据,再调用 Keras 的激活函数计算输出,或者干脆用 NumPy 自己实现函数表达式。

推荐环境版本:

  • Python 3.8 及以上
  • TensorFlow 2.x(内置 Keras)
  • NumPy 1.21 以上
  • Matplotlib 3.5 以上

安装命令很简单:

pip install tensorflow numpy matplotlib

如果只想画图不想装完整的 TensorFlow,也可以只装 Keras 相关的轻量包,但实际项目中反正都要用 Keras,直接装完整版最省事。我习惯用虚拟环境隔离项目依赖,避免不同版本之间互相干扰。这里多说一句:TensorFlow 2.x 的 Keras 接口和早期 1.x 有一些差异,但激活函数部分基本没变,大家放心用。

2.2 为什么用 NumPy 手动计算而不是只用 Keras 内置调用

Keras 里每个激活函数都有对应实现,比如tf.keras.activations.sigmoid(x)。我们当然可以直接调用它来生成 y 值,但有一个问题:如果你只想看函数的数学形态,不涉及 Tensor 的梯度计算,直接用 NumPy 写公式其实更轻快,而且能在任何环境下运行,不依赖深度学习框架。

比如 Sigmoid 的公式是1 / (1 + np.exp(-x)),一行代码就搞定。用 Keras 调用也完全可以,但初学者容易混淆“激活函数作为网络层的一部分”和“激活函数作为一个纯数学函数”这两种用法。绘图是为了理解数学形态,我建议先用 NumPy 理解公式,再用 Keras 验证接口调用,这样两头都清楚。

实际上在后面的代码示例里,我会把两种方式都展示一遍。先用 Keras 的get方法拿到激活函数对象,然后对 NumPy 数组调用它,注意这种方式需要先将数组转换为 Tensor 或者直接用 Keras 内置函数处理,具体细节下面会讲。

2.3 一套可复用的绘图模板

我把自己常用的绘图模板整理成了一个函数,输入激活函数名和区间范围,就能输出一张带网格、带零轴参考线的图像。模板的关键点有三个:

  • 用np.linspace生成密集的 x 点,一般取 1000 个点以上,曲线才够平滑。
  • 用plt.axhline(0)和plt.axvline(0)画出坐标轴参考线,方便观察函数在零点附近的行为。
  • 用plt.grid(True)开启网格,网格线建议设置半透明,否则会干扰曲线阅读。

模板代码如下:

import numpy as np import matplotlib.pyplot as plt def plot_activation(func, x_range=(-6, 6), title="Activation Function"): x = np.linspace(x_range[0], x_range[1], 2000) y = func(x) plt.figure(figsize=(8, 5)) plt.plot(x, y, linewidth=2.5, label=title) plt.axhline(0, color='gray', linestyle='--', linewidth=1) plt.axvline(0, color='gray', linestyle='--', linewidth=1) plt.grid(True, alpha=0.3) plt.legend() plt.title(title) plt.xlabel("x") plt.ylabel("f(x)") plt.tight_layout() plt.show()

这套模板基本可以覆盖所有激活函数。遇到需要画导数曲线的情况,再用np.gradient或手动求导公式补一条曲线就行。

3. Keras 常用激活函数逐个拆解与图像解读

3.1 Sigmoid:平滑饱和的“压扁器”

Sigmoid 的公式是:

f(x) = 1 / (1 + exp(-x))

它的图像是一条单调递增的 S 形曲线,输出范围是 (0, 1)。在 x 接近 0 时曲线斜率最大,大约为 0.25;x 越大或越小,曲线越平坦,梯度越接近 0。

画图时你会看到,当 x 超过 4 或者小于 -4 时,曲线几乎完全贴住上下边界。这就是“饱和区”。饱和区的梯度非常小,如果网络某一层的输入长期落在这些区间,反向传播时梯度会逐层衰减,导致深层网络训练缓慢,这就是 Sigmoid 在深层网络中不再受欢迎的主要原因。

另外注意 Sigmoid 的输出不是零中心的,它总是正的。这会导致上一层输出的分布均值整体偏移,后续层的梯度更新可能出现“Z 字形”震荡。虽然单看图像觉得挺平滑,实际用起来并没有那么友好。

在 Keras 中可以用tf.keras.activations.sigmoid(x)直接调用,或者作为层参数传入:Dense(64, activation='sigmoid')。但我个人建议只在二分类输出层或需要输出概率的场景使用,隐藏层尽量避开它。

3.2 Tanh:零中心的改进版

Tanh 的公式是:

f(x) = (exp(x) - exp(-x)) / (exp(x) + exp(-x))

它的图像和 Sigmoid 很像,也是 S 形,但输出范围是 (-1, 1),而且关于原点中心对称。这个“零中心”特性让 Tanh 在隐藏层中的表现通常优于 Sigmoid,因为输出均值更接近 0,有利于下一层权重的梯度更新。

图像上最直观的区别是:Tanh 在 x=0 附近的斜率比 Sigmoid 更陡,达到 1,而 Sigmoid 只有 0.25。这意味着同样在 0 附近,Tanh 的梯度信息更强。

但 Tanh 的饱和问题依然存在。当 x 的绝对值大于 3 时,曲线同样趋于平坦,梯度接近 0。所以 Tanh 适合浅层网络或 RNN 的门控结构,直接堆很多层 Tanh 照样会遇到梯度消失。

在 Keras 里调用方式同样是两种:Dense(64, activation='tanh')或tf.keras.activations.tanh(x)。如果你需要把输出限制在 -1 到 1 之间,Tanh 是一个比 Sigmoid 更好的选择。

3.3 ReLU:稀疏激活的“整流器”

ReLU 的公式极其简单:

f(x) = max(0, x)

它的图像是一条在 x=0 处转折的折线:x 小于 0 时输出恒为 0,x 大于 0 时输出等于 x,是一条 45 度直线。

这个函数看起来“太简单”,却是现代深度学习默认配置。为什么?因为它在正区间的梯度恒为 1,不会饱和,能有效缓解梯度消失。同时它把负数全部置为 0,产生稀疏激活,让网络更高效。

但 ReLU 有一个致命问题,观察图像左半边就能发现:所有负数输入都被“杀掉”了。如果某个神经元的输入长期为负,它的输出永远是 0,梯度也永远是 0,这个神经元就“死”了,而且不会复活。这就是所谓的“Dead ReLU Problem”。学习率设置过大时,这种情况尤其常见。

画 ReLU 图像时建议顺便把梯度曲线画出来,你会看到梯度在 x=0 处从 0 直接跳到 1,这是不连续点。虽然数学上不可导,但在工程实现里通常把 x=0 处的导数定义为 0 或 1,不影响实际使用。

Keras 中 ReLU 是默认常用的激活函数:Dense(64, activation='relu')。从图像上理解为什么它在 CNN 和全连接层中这么流行,比单纯记住结论要深刻得多。

3.4 Leaky ReLU / PReLU / ELU:ReLU的变体家族

ReLU 的负区间直接把输出置零,太“暴力”了。于是出现了各种改进版,核心思路就是让负数区域也能有一点点输出,避免神经元彻底死亡。

Leaky ReLU 在负区间引入一个很小的斜率,比如 0.01:

f(x) = x (x > 0) f(x) = 0.01 * x (x <= 0)

图像上,负数区间变成一条非常平缓的向下直线,不再是水平线。这样负数也能携带很小的梯度,神经元不容易死。

PReLU 是 Leaky ReLU 的“学习版”,它把负区间的斜率 alpha 也当作可训练参数,网络自己学出来。图像形状和 Leaky ReLU 一样,但斜率会随着训练变化。

ELU 则更平滑,公式是:

f(x) = x (x > 0) f(x) = alpha * (exp(x) - 1) (x <= 0)

ELU 的负区间不是直线,而是一条逼近 -alpha 的平滑曲线,在 x=0 处连续且可导。它在负区间的输出均值更接近 0,能进一步缓解偏移问题,但计算量比 ReLU 稍大。

画这些变体时,最好把 ReLU 当作基准同时画出,才能直观看出差异。它们的共同点是正区间都保持线性,所以正区间的梯度传播能力与 ReLU 一致。

Keras 中调用方式:LeakyReLU(alpha=0.01)需要作为一个层来使用,不能直接写在activation参数里,比如:

model.add(Dense(64)) model.add(LeakyReLU(alpha=0.01))

PReLU 和 ELU 也有对应的层接口,用起来都很方便。

3.5 Softmax:概率分布的“竞争机制”

Softmax 和前面所有激活函数有本质区别。它是针对一个向量进行运算,输出每个元素的概率,所有输出加起来等于 1。公式如下:

f(x_i) = exp(x_i) / sum(exp(x_j)) for all j

图像没法简单地用“一个输入一个输出”的二维曲线表示,因为它的输入输出都是高维向量。通常我们在多分类任务的输出层使用它,将网络输出的 logits 转换成概率分布。

如果一定要可视化 Softmax,可以做两类图:一类是固定其他元素不变,改变某一个元素看它的概率变化;另一类是画出二维输入空间上的概率等高线。大多数人会用第一种方式,也就是保持一个向量里其余元素为 0,只让某个位置变化,看对应输出概率如何从接近 0 增加到接近 1。

从图像里能看到一个关键特征:指数运算放大了输入之间的差异。如果 logits 是 [2, 1, 0],经过 Softmax 后概率大约为 [0.67, 0.24, 0.09]。它让最大值更突出,但不会像 Hardmax 那样把其他项直接置为 0,保留了一定的梯度信息。

在 Keras 中,Dense(num_classes, activation='softmax')是分类模型的标准结尾层。单独画 Softmax 曲线时不要和 ReLU 放在同一套对比图里,因为它本质是向量函数。

3.6 Softplus、Swish / SiLU、GELU:新锐激活函数

Softplus 可以看作是 ReLU 的平滑版本,公式是:

f(x) = log(1 + exp(x))

图像上它整体和 ReLU 很像,但负数区域不是 0,而是一个缓慢衰减到 0 的正值,正区间渐渐逼近一条斜率为 1 的直线。好处是光滑可导,坏处是计算量稍大,而且负区间的输出不是严格 0,稀疏性不如 ReLU。

Swish 是近年来非常流行的函数,公式是:

f(x) = x * sigmoid(x)

它的图像很有趣:在负数区域会先下降再回升,有一个“V 形”凹槽,全局最小值大约在 x=-1.28 附近。这个非单调特性让它在一些深层次模型上效果不错。SiLU 其实就是 Swish 当 beta=1 时的别名,Keras 里可以直接用activation='swish'。

GELU 的公式是:

f(x) = 0.5 * x * (1 + erf(x / sqrt(2)))

其中 erf 是高斯误差函数。图像上 GELU 与 Swish 非常接近,都是负数区域先微微下降再回升。它在 Transformer 类模型中非常常见。

这些新型激活函数的图像差异很细微,但不要小看这些细微之处。非单调的凹槽能提供一定的负向梯度,让激活值有机会“自我修正”。画出图像对比时,要重点观察负区间的形状,而不是正区间,因为正区间基本都是线性的。

4. 实操:用 Keras + Matplotlib 绘制激活函数图像全流程

4.1 绘制单个激活函数图像的完整代码

下面给出一段可以直接运行的代码,同时使用 Keras 激活函数接口和 NumPy 生成输入数据。

import numpy as np import matplotlib.pyplot as plt import tensorflow as tf x = np.linspace(-6, 6, 2000).astype(np.float32) x_tensor = tf.convert_to_tensor(x) # 通过 Keras 激活函数模块计算输出 y_sigmoid = tf.keras.activations.sigmoid(x_tensor).numpy() y_tanh = tf.keras.activations.tanh(x_tensor).numpy() y_relu = tf.keras.activations.relu(x_tensor).numpy() y_softplus = tf.keras.activations.softplus(x_tensor).numpy() y_swish = tf.keras.activations.swish(x_tensor).numpy() plt.figure(figsize=(12, 8)) plt.subplot(2, 3, 1) plt.plot(x, y_sigmoid) plt.title('Sigmoid') plt.grid(True, alpha=0.3) plt.axhline(0, color='gray', linewidth=1) plt.subplot(2, 3, 2) plt.plot(x, y_tanh) plt.title('Tanh') plt.grid(True, alpha=0.3) plt.axhline(0, color='gray', linewidth=1) plt.subplot(2, 3, 3) plt.plot(x, y_relu) plt.title('ReLU') plt.grid(True, alpha=0.3) plt.axhline(0, color='gray', linewidth=1) plt.subplot(2, 3, 4) plt.plot(x, y_softplus) plt.title('Softplus') plt.grid(True, alpha=0.3) plt.axhline(0, color='gray', linewidth=1) plt.subplot(2, 3, 5) plt.plot(x, y_swish) plt.title('Swish') plt.grid(True, alpha=0.3) plt.axhline(0, color='gray', linewidth=1) plt.tight_layout() plt.show()

这段代码里有两个值得注意的转换点:第一,np.linspace生成的是 NumPy 数组,需要tf.convert_to_tensor转成 Tensor,Keras 激活函数才会正常处理;第二,计算结果需要用.numpy()转回 NumPy 数组,Matplotlib 才能绘制。这种“来回转换”看起来麻烦,但能让你同时熟悉 Tensor 和 NumPy 两种数据结构。

如果你不想接触 Tensor,也可以用 NumPy 直接实现函数公式,效果完全一样。两种方式我都测试过,数值精度差异可以忽略。

4.2 多子图对比的封装函数

实际项目里,我经常需要把六七个激活函数放在同一个大图里对比。手动写subplot太繁琐,我封装了一个函数,输入函数列表,自动生成网格子图。

def plot_activations(functions, x_range=(-6, 6), cols=3): x = np.linspace(x_range[0], x_range[1], 2000) rows = (len(functions) + cols - 1) // cols plt.figure(figsize=(5 * cols, 4 * rows)) for idx, (name, func) in enumerate(functions.items(), 1): plt.subplot(rows, cols, idx) y = func(x) plt.plot(x, y, linewidth=2) plt.title(name) plt.grid(True, alpha=0.3) plt.axhline(0, color='gray', linewidth=0.8, linestyle='--') plt.axvline(0, color='gray', linewidth=0.8, linestyle='--') plt.tight_layout() plt.show()

调用时传入一个字典,键是函数名,值是接收 NumPy 数组并返回 NumPy 数组的函数。比如:

funcs = { 'Sigmoid': lambda x: 1 / (1 + np.exp(-x)), 'Tanh': lambda x: np.tanh(x), 'ReLU': lambda x: np.maximum(0, x), 'LeakyReLU': lambda x: np.where(x > 0, x, 0.01 * x), 'ELU': lambda x: np.where(x > 0, x, 1.0 * (np.exp(x) - 1)), 'Swish': lambda x: x / (1 + np.exp(-x)), } plot_activations(funcs)

这种封装的好处是:以后想加新函数,只要往字典里加一行 lambda 表达式就行,不需要改动绘图逻辑。对比多个阶段实验时,这个函数帮我省了大量重复代码。

4.3 输出图像保存与高清配置

画图只是第一步,很多场景下需要把图像保存下来放进文档或实验报告。保存图像时建议用dpi参数控制清晰度,同时开启bbox_inches='tight'裁掉空白边。

plt.savefig('activation_functions.png', dpi=300, bbox_inches='tight')

关于配色和样式,我个人建议使用简洁的白色背景、深色曲线,尽量不用花哨的渐变。激活函数图像本身以信息展示为主,不是艺术创作,可读性优先。如果要在论文或博客里使用,推荐设置成矢量图格式:

plt.savefig('activation_functions.pdf', format='pdf', dpi=300, bbox_inches='tight')

PDF 格式在文档中放大不会失真,投稿和打印都方便。如果只是屏幕观看,PNG 加 300 dpi 完全够用。还有一个小技巧:如果同一张图里要画多条曲线做对比,用不同颜色同时加label,并在plt.legend()里显示图例,标注清楚每条曲线的含义。

5. 常见问题与排查技巧

5.1 图像中出现 NaN 或锯齿怎么办?

画激活函数图像时,最常遇到的问题就是 NaN。尤其是 Softplus 和 GELU 这类包含指数运算的函数,当 x 非常小(比如 -100)时,exp(x)接近 0 没问题,但log(1 + exp(x))在 x 极大时会溢出,变成 inf,再参与运算就可能出现 NaN。

解决办法有两种:一是把 x 范围控制在合理区间,比如 -10 到 10,避免极端值;二是使用数值稳定的公式。比如 Softplus 在 x 很大时可以近似为x,可以分段计算:

def softplus_stable(x): return np.where(x > 20, x, np.log1p(np.exp(x)))

用np.log1p代替np.log(1 + exp(x))能提高小值区域的精度。另外一个常见问题是 matplotlib 绘图时数据点不够多,导致 ReLU 在转折处看起来是斜线而不是直角。解决方法是增加np.linspace的点数,或者把转折点单独插入数组中。

5.2 为什么Sigmoid图像“看起来太平”?

如果你把 Sigmoid 曲线的坐标范围放得很大,比如 x 从 -100 到 100,那么中间 0 附近的变化就会被压缩成一条几乎垂直的线,两侧变成水平线,看起来整个图像就像一个非常窄的过渡带。这不是函数画错了,而是坐标系选择的问题。

解决办法是优先使用 -6 到 6 的区间,这是观察激活函数的标准范围。少数函数比如 GELU 和 Swish 会用到 -8 到 8,因为它们的凹槽位置在 -1.3 附近,但左右各有延伸。如果始终觉得太平,可以检查是不是 x 的步长太大,导致曲线拐点没被采到。

另外,Sigmoid 图像“平”还有一层含义是梯度接近 0,这是真实现象。如果你的网络初始化不当,输入到激活函数前的值落在 ±5 之外,那梯度的确会非常小。这时不是图像的问题,而是网络设计的问题。

5.3 Keras中自定义激活函数如何参与绘图?

有时候我们需要画自己定义的激活函数,比如带参数的 Leaky ReLU,或者某种实验性函数。Keras 允许自定义激活函数,只需要定义一个接收 Tensor 并返回 Tensor 的函数即可。

import tensorflow as tf def my_activation(x): return tf.where(x > 0, x, 0.05 * x)

绘图时,可以直接对这个函数传入 NumPy 数组吗?不行,因为tf.where需要 Tensor 输入。简单做法是在自定义函数内部用tf.convert_to_tensor转一下,或者在外面先转成 Tensor 再调用:

x_tensor = tf.convert_to_tensor(x, dtype=tf.float32) y = my_activation(x_tensor).numpy()

如果你想完全避开 Tensor,也可以写纯 NumPy 版本的自定义函数,原理一样。但既然项目用 Keras,我还是建议保留 Tensor 版本,这样自定义函数也能直接用在网络构建中。

5.4 不同版本Keras的API差异

Keras 经历过独立库到 TensorFlow 内置的变化。TensorFlow 2.x 中推荐使用tf.keras.activations;早期 Keras 的keras.activations在独立版中也能用,但如果你装的是较新的 TensorFlow,继续用keras开头可能会遇到兼容性警告。

我这里整理了一个小表,方便大家对照:

激活函数Keras 层参数写法独立调用写法
Sigmoidactivation='sigmoid'tf.keras.activations.sigmoid(x)
Tanhactivation='tanh'tf.keras.activations.tanh(x)
ReLUactivation='relu'tf.keras.activations.relu(x)
LeakyReLULeakyReLU(alpha=0.01)作为层tf.keras.layers.LeakyReLU
Softmaxactivation='softmax'tf.keras.activations.softmax(x)
Swish/SiLUactivation='swish'tf.keras.activations.swish(x)
ELUactivation='elu'tf.keras.activations.elu(x)

如果你的代码报错,比如module 'tensorflow' has no attribute 'keras',说明你的 TensorFlow 版本太老或安装不完整,建议升级到 2.x 最新稳定版。还有一种情况是混用了keras和tf.keras,这会导致模型权重不通用,最好统一使用tf.keras。

6. 一点实操心得

画激活函数图像这件事,看起来很简单,但它是理解深度学习模型行为最廉价的手段。我在实际项目中,每次设计新网络都会把候选激活函数的图像和导数图像放在一起比较。尤其是遇到训练不收敛或者 loss 波动异常时,回到激活函数图像上检查输入范围是否落在饱和区,往往比盲目调学习率更有效。

最后分享一个小技巧:不要只画函数本身,把它的导数也画在同一个坐标系里。你可以直接用 NumPy 的数值差分计算导数,或者手动写出导函数公式。观察函数值与导数的叠加关系,你会更清楚“饱和”“梯度消失”“神经元死亡”这些词到底在说什么。每个函数都有自己的脾气,摸透了,用起来才能得心应手。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进