ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

万能逼近定理:神经网络拟合任意连续函数的原理与实战

万能逼近定理:神经网络拟合任意连续函数的原理与实战

如果只让我用一种数学结论解释“神经网络为什么这么能打”,我大概率会选万能逼近定理。它听起来很像一句口号:任意连续函数,都可以用一个足够宽的前馈神经网络去逼近。但真较真起来,很多人会卡在一连串问题里——“足够宽”到底多宽?“任意连续函数”包不包括断掉的函数?“能逼近”是不是等于“能学出来”?网上关于这个定理的讨论很多,但大多停留在“它存在,所以神经网络牛逼”这个层面,很少讲清楚它到底证明到了哪一步、又没证明哪一步。

这篇文章是我最近重新梳理理论时的一个小总结。我打算先把万能逼近定理拆开讲明白,再手写一个最小神经网络,把它真正用来拟合一个非线性函数,最后把我在实际调试中踩过的坑整理成一张可参考的排查清单。适合正在学深度学习、被“黑盒”说法搞得一头雾水的同学,也适合已经跑了不少模型、想回头补一补根基的工程师。

1. 万能逼近定理到底证明了什么

1.1 一句话版本:任意连续函数都能逼近

万能逼近定理最早最经典的版本,针对的是带一个隐藏层的前馈神经网络。它说的是:只要给足够的隐藏神经元,激活函数选得合适,那么对任意定义在某个有界闭区间上的连续函数,以及任意一个允许的误差上限,都能找到一个网络,让这个网络在整个区间上的输出和真实函数值的最大差距小于那个误差上限。

用人话翻译一下:你想拟合的曲线只要是连续的、只在有限范围内折腾,那理论上就一定存在一个单隐层神经网络,能把它贴得足够近。误差想多小,就能多小。比如正弦函数、指数函数、多项式组合函数,都属于这个范围;图像分类里的决策边界,只要经过平滑处理,很多也能看成连续函数的等值面,所以也可以被神经网络逼近。

这里的“激活函数选得合适”,原始版本常用 sigmoid。后续有更一般的结论:只要激活函数是非常数的、不是多项式的,单隐层网络就能具备这种万能逼近能力。ReLU、tanh 这类实际操作里常用的激活,都是满足条件的。这基本把市面上主流网络的理论底座给铺平了。

1.2 容易忽略的隐含条件

很多初学者以为“能逼近任何函数”等于“做什么都能成功”,这是最大的误解。这个定理至少带着三个隐含条件。

第一,函数必须是连续的。实际任务里到处都是跳变、断点、噪声,比如点击事件、异常检测中的突变信号。这类不连续函数严格说不在定理保证范围内,只能靠连续函数去近似硬拟合,效果要看具体问题。

第二,范围限定在有界闭区间。定理保证的是在某个有限范围内部有效,超出这个范围,神经网络没有任何数学承诺。你在训练数据范围外随便给它一个输入,输出可能完全乱来。这就是为什么模型外推经常翻车,后面我还会专门说这个事。

第三,也是最重要的一点:定理是存在性证明,不是构造性证明。它告诉你“一定存在某个网络”,但没告诉你网络参数具体是多少,也没有保证梯度下降能找到它。换句话说,万能逼近定理像是一张藏宝图,上面画了宝箱的位置,但它不负责告诉你路上有悬崖还是坦途。现实中我们要面对的优化问题,是另一场仗。

1.3 从多层感知机到CNN、RNN:它们都在“逼近”什么

网络上另一个常见的疑问是:万能逼近定理讨论的是前馈神经网络,为什么现在主流用的是卷积神经网络、循环神经网络、图神经网络,这些结构和定理有什么关系?

我觉得可以这样理解:那些复杂结构本质上都是在对“映射关系”做假设。卷积神经网络假设特征是局部、可平移复用的,所以用卷积核做参数共享;循环神经网络假设数据是时序的,所以用循环状态把过去的信息带进来;图神经网络假设每个节点依赖邻居。它们都是在基本前馈网络“函数逼近”能力之上,加入了更符合应用场景的归纳偏置。

举个具体例子。一张人脸图像进入卷积神经网络,从边缘、纹理一路提取到五官、整体结构,最终输出一个高维向量。这个过程在数学上就是一个从图像像素空间到特征空间的函数映射。万能逼近定理告诉我们:在表达能力层面,纯前馈结构理论上已经足够完成这种复杂映射。但实际中如果不加卷积这种结构约束,纯全连接网络会需要天文数字级别的参数,学习效率极低。所以CNN、RNN这些结构,不是推翻万能逼近定理,而是在它的基础上,把“搜索空间”缩小到更合理的范围。

2. 神经元是怎么把函数“拼”出来的

2.1 神经元就是一块可调节的积木

要理解神经网络为什么能逼近任意函数,很有效的办法是回到几何直觉。

一个神经元,接收输入,做加权求和,然后过一个激活函数。以 sigmoid 为例,它的图像是一条 S 形曲线。如果我们改变权重和偏置,这条 S 形曲线会左右平移、变陡、变缓。如果一个网络层里有 32 个神经元,那就相当于有 32 条可以自由调节形状的 S 形曲线。

这些曲线加在一起,就能拼出更复杂的形状。数学上有一个直观的解释:把两条不同位置、不同陡峭度的 S 形曲线相减,可以得到一个类似“脉冲”的凸起;多个这样的凸起叠加起来,就能拼出峰和谷;再继续加密凸起的密度和大小,任何一条连续曲线都能被逐步逼近。这其实和傅里叶级数、泰勒多项式的思路异曲同工,都是把复杂函数拆成一组简单基函数的组合。

所以万能逼近定理看起来神秘,本质上说的就是:神经元是一块足够灵活的积木。给够积木数量,什么房子都能搭。这里的“给够数量”很关键,后面实战部分你就会看到,网络宽度不够时,函数拟合会出现明显的“钝角感”。

2.2 非线性激活为什么是硬性要求

有人可能会想:如果没有激活函数,多层线性层的组合不就是另一种更强大的线性模型吗?对不起,不是。多个线性变换连续做,结果还是一个线性变换。矩阵的乘积还是矩阵,线性叠加还是线性。不管你的网络堆多深,最终表达能力和一个单层线性模型没有本质区别,你连一条弯曲的边界都画不出来。

非线性激活函数是给网络提供“弯折能力”的关键。拿经典异或问题来说,单层线性模型永远没法分隔异或的四个点,因为那条边界是折线。但一个带 tanh 或 ReLU 隐藏层的网络,可以轻松表达出那条折线边界。这正是因为非线性把每一层输出的空间做了扭曲,让网络可以拟合出任意弯曲的决策边界。

所以,万能逼近定理里要求的“非常数非多项式激活函数”,不是随便说说,而是整个逼近能力的地基。

2.3 单隐层已经“万能”,为什么还要往深了做

到这里有个很自然的反问:既然单隐层网络已经具备万能逼近能力,那深度学习为什么还要堆这么多层?单层不就够了吗?

问题出在“效率”和“可学性”上。理论上的存在性不代表现实中的可行性。要逼近一个特别复杂的函数,单隐层网络可能需要极其多的隐藏神经元,参数规模大到根本无法训练,甚至一个函数需要几千几万个神经元一起拼,组出来的曲面会病态地不平滑,优化极其困难。

深度网络的优势在于层级化分解。浅层网络先学习简单的局部结构,比如边缘、纹理、短时序规律;深层网络再把这些简单结构组合成更高层的语义。这样做参数量更少,数据利用效率更高,也更适合梯度下降在实践中的表现。从逼近论的角度看,深度结构相当于把一个大问题拆成很多个小的逼近问题,每个子问题都很简单。

我习惯用流水线来类比:单隐层网络是一位全能工人,一个人干所有活,但要么沟通成本高,要么体力跟不上;深度网络是一条流水线,每个岗位只做一件极简的事,组合起来却能搞定大规模生产。这也是为什么现代模型普遍选择“深而窄”,而不是“浅而宽”。

3. 亲手把一个非线性函数拟合出来

3.1 目标函数与网络结构选择

理论讲得再多,不如自己跑一遍。我选一个非常经典的非线性函数作为拟合目标:f(x) = sin(x)。正弦函数光滑、有界、连续,是检验网络拟合能力的好对象。

网络结构选得很简单:输入层 1 个神经元,隐藏层 32 个神经元,输出层 1 个神经元。隐藏层激活函数用 tanh,输出层不用激活函数,保持线性输出。为什么不用 sigmoid?因为 sigmoid 输出范围是 0 到 1,还得做均值平移;tanh 输出范围是 -1 到 1,和正弦的值域正好匹配,而且梯度的中心化更好,训练时不容易出现输出层偏置漂移。

隐藏层神经元数量是 32,不是特别讲究,但对一个单变量正弦函数来说已经足够。如果你发现拟合不足,可以先从 32 加到 128 看变化;如果过拟合,就往小了减。这是最朴素也是最好用的调参方式。

3.2 数据准备与输入归一化

训练数据我直接在 [-3π, 3π] 上均匀采样 2000 个点。这个范围选得足够大,能覆盖几个完整的正弦周期,又不至于让输入数值大到激活函数饱和。

这里有一个特别容易踩的坑:输入要归一化。如果直接把 -9.4 到 9.4 的原始数值喂给 tanh,tanh 在输入绝对值大于 4 的时候输出基本贴到 ±1,导数趋近于 0,梯度会非常小,网络学不动。所以我把每个 x 除以 3π,把输入归一化到 [-1,1] 左右,这样激活函数工作在比较敏感的区域,梯度能正常流动。

实际项目里,不光要做到这一步,更稳妥的做法是做标准化,让输入均值接近 0、标准差接近 1。我这里因为数据对称均匀,除以范围上限就够了。输出 y 本身在 [-1,1],可以直接用,不需要再处理。

3.3 一个可运行的最小实现

为了让整个链路透明,我直接用手写梯度的方式实现,不依赖深度学习框架。这样你还能看清楚反向传播每一步在干什么。生产环境你当然可以用 PyTorch、TensorFlow,但理解原理之后再上框架,心态会完全不一样。

import numpy as np rng = np.random.default_rng(42) N = 2000 # 生成原始数据,并做输入归一化 x_raw = np.linspace(-3 * np.pi, 3 * np.pi, N).reshape(-1, 1) y = np.sin(x_raw) x = x_raw / (3 * np.pi) # 简单打乱并划分训练、验证集 idx = rng.permutation(N) train_idx, val_idx = idx[:1600], idx[1600:] x_train, y_train = x[train_idx], y[train_idx] x_val, y_val = x[val_idx], y[val_idx] # 初始化:输入1维,隐藏层32维,输出1维 w1 = rng.normal(size=(1, 32)) * 0.5 b1 = np.zeros((1, 32)) w2 = rng.normal(size=(32, 1)) * 0.5 b2 = np.zeros((1, 1)) def tanh(z): return np.tanh(z) def tanh_prime(z): return 1 - np.tanh(z) ** 2 lr = 0.02 for step in range(20000): # 前向传播 z1 = x_train @ w1 + b1 a1 = tanh(z1) z2 = a1 @ w2 + b2 pred = z2 loss = np.mean((pred - y_train) ** 2) # 反向传播:MSE损失对pred的导数 grad = 2 * (pred - y_train) / len(x_train) # 输出层梯度 dw2 = a1.T @ grad db2 = np.sum(grad, axis=0, keepdims=True) # 隐藏层梯度 da1 = grad @ w2.T dz1 = da1 * tanh_prime(z1) dw1 = x_train.T @ dz1 db1 = np.sum(dz1, axis=0, keepdims=True) # 更新参数 w1 -= lr * dw1 b1 -= lr * db1 w2 -= lr * dw2 b2 -= lr * db2 if step % 2000 == 0: val_pred = np.tanh(x_val @ w1 + b1) @ w2 + b2 val_loss = np.mean((val_pred - y_val) ** 2) print(step, loss, val_loss)

这里初始化为什么要乘以 0.5?因为如果初始权重太大,隐藏层的加权输入会落到 tanh 的饱和区,一上来梯度就没了;乘以一个比较小的缩放系数,可以让神经元从“工作点”比较敏感的位置出发。学习率取 0.02,配合全量梯度下降,对这个规模的数据集来说足够稳定。如果学习率取 0.1 以上,就会看到损失上下震荡,很难收敛。

3.4 怎么判断训练是否成功

跑完上面这段代码,你会看到训练集和验证集的 loss 同步下降,最后稳定在一个很小的量级。把预测值和真实值画在一张图上,在 [-3π, 3π] 范围内基本是重合的,肉眼很难看出差别。

我复现这个例子的经验是:当 loss 小到 0.001 量级时,形状已经对了,但如果你把局部放大,会发现正弦曲线被拟合成了带轻微“多段折线”的曲线,不够光滑。这其实是隐藏层神经元数量有限的正常表现。想更光滑,就把隐藏层从 32 加到 128,或者增加训练轮数,误差会继续下降。但想降到 0 是做不到的,因为有限神经元组合出来的基函数数量有限,始终存在一个微小残余误差,这个残余误差正是万能逼近定理里“给定误差下限”的体现。

4. 实操中最常见的坑和排查方法

4.1 输入范围没归一化,梯度直接消失

我在 3.2 里已经提过一次归一化,但它值得单独拿一节来说,因为这是新手翻车概率最高的点。

有人会把 x 直接取成从 -18 到 18,甚至更大范围,然后发现损失函数完全不下降。原因很简单:tanh(18) 约等于 1,导数约等于 0。激活函数饱和以后,梯度反传回去全部被乘成接近 0 的数,参数根本更新不动。

排查方法很直接:看隐藏层的 z1 分布。如果大部分绝对值大于 3,基本可以断定饱和了。解决办法就是输入标准化,让大部分数据落在 [-1,1] 附近。很多人以为标准化只是为了加快收敛,其实它更像是给神经元提供了正常的“工作环境”,不做的话整个网络可能直接罢工。

4.2 损失震荡:学习率、初始化与批次大小逐个排查

损失不降是第一种问题,损失反复震荡是第二种。

震荡最常见的原因是学习率太大。你想象一下一个小球在碗底来回弹,步子太大就会冲过最低点,来回弹跳。把学习率从 0.02 改到 0.005,震荡通常就缓解了。

初始化也会带来震荡。如果初始权重太大,有些神经元一开始就饱和,这些神经元其实在“假死”,梯度很小,后面慢慢恢复;恢复过程中损失曲线可能出现一个平台期,然后再降。所以我习惯在初始化时加一个缩放系数,让每个隐藏神经元的初始输出不要一开始就贴到极端值。

另一个容易被忽视的是批次大小。全量梯度下降很稳但算得慢;小批量梯度下降梯度估计有噪声,损失曲线会呈现明显的毛刺。如果你的损失曲线像锯齿一样特别密,可以先试试加大 batch size,或者降低学习率,看哪边变化最明显。

4.3 边缘区域的“外推幻觉”:你的样本真的覆盖了吗

有一个现象在训练完成之后特别容易被忽略:在训练区间内部,拟合效果很好;一旦把预测点放到训练区间外面,网络输出立刻变得离谱。

这不是训练没学好,而是万能逼近定理的适用范围天然限定在“有界闭区间”。定理只保证在训练数据覆盖的那个范围内存在逼近,对范围外部没有任何保证。神经网络本质上是在训练数据分布附近做插值,而不是在做全局解析延拓。它并不知道你的正弦函数在区间外还会继续周期震荡。

所以我做实际项目时,非常注意预测输入是否还在训练分布范围内。如果生产环境必然出现超出范围的数据,要么在边缘补数据,让网络看到更广的模式;要么干脆加一层规则判断,对超范围输入单独处理。指望神经网络自己“外推”出正确规律,通常只会得到幻觉式结果。

4.4 环境与复现问题顺手排雷

理论、代码都能看懂,结果倒在了运行环境上,这种事我见过太多。最典型的是在 Windows 的 PowerShell 里输入pip或者conda,直接提示“无法识别”。

这通常有三个原因:一是对应工具根本没有安装;二是安装了但可执行文件所在的路径没有加到环境变量 PATH 里;三是当前终端的脚本执行策略限制。前两个原因最常见。

我的建议是不要试图背一堆命令,而是养成一个习惯:先用python -m pip --version看能不能正常运行。如果这个能通,说明 Python 没问题,只是裸的pip入口不在 PATH 里,之后都改成python -m pip install xxx这种调用方式,问题自然绕过去。conda 类似,也可以优先用conda run python xxx.py这样的形式。复现本文例子只需要 numpy,别让环境问题消磨掉你对理论的学习热情。

4.5 一张表解决80%的训练问题

症状可能原因快速排查与处理
损失一直不降输入范围过大、激活饱和、学习率太小先检查输入归一化,再看隐藏层z1分布,最后调学习率
损失震荡或变成NaN学习率太大、梯度爆炸、数据有异常值降低学习率、做梯度裁剪、检查数据里有没有NaN
训练loss低,验证loss偏高过拟合增加数据量、加正则化、减小模型容量、提前停止
训练loss高,验证loss也高欠拟合增加隐藏层宽度、加深网络、增加训练轮数
训练区间内很好,区间外乱飞数据覆盖不足,超出定理适用范围限制预测输入范围,或补充边界数据
曲线拟合出来有明显折角隐藏层神经元数量不足增加神经元数量,或者改用更光滑的激活函数如GELU

一个持续有效的小习惯

我个人对万能逼近定理的理解是:它是一个关于“可能性”的罗盘,不是一张保证“一定能到达”的藏宝图。它告诉你神经网络在表达能力上确实足够强,但表达能力和最终学到的能力之间存在一条鸿沟。数据质量、网络结构、优化算法、正则化手段,填的就是这条鸿沟。

最后分享一个我自己用了很久的调试技巧。每次搭一个新网络,不管多复杂,我都会先把它压到一个极小的任务上验证:用三五十个点拟合一个正弦或二次函数,固定随机种子跑一遍。如果这么简单的目标都学不好,问题多半在梯度计算、初始化或数据预处理上;如果小目标能学好,再去跑真实任务,心里会踏实很多。这个习惯帮我省掉了大量“网上说是玄学”的排查时间。

RELATED READING

延伸阅读

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