头一回认真研究RVFLNN(随机向量函数链神经网络,Random Vector Functional Link Neural Network),是因为一个在线预测的项目。数据量不大,几千条样本、十几个特征,但对训练耗时和更新频率卡得很死。用传统BP网络跑了一轮,调学习率、调轮数、调初始化,前前后后折腾了两天,预测误差还是上不去。后来翻到随机权值神经网络这个方向,才发现原来有一类网络压根不走反向传播,隐藏层权重随机生成之后就直接锁死,只需要解一个线性方程组就能得到输出权重。当时的第一反应是:这也太偷懒了,能行吗?真正上手跑完测试之后,我反而开始反思,很多时候我们是不是把"神经网络必须端到端梯度训练"这件事想成了唯一解。
这篇博文就围绕RVFLNN的原理、实现和实测展开,会附一份基于NumPy的手写代码,以及和MLP、Ridge回归的对比结果。适合想快速搭一个非线性baseline、做特征投影相关实验、或者对"不用BP也能训练网络"这个方向感兴趣的读者。不需要你很熟悉深度学习,只要会NumPy和sklearn的基本操作就能跟着跑完。
1. 放弃反向传播?先看看RVFLNN到底在解决什么
1.1 为什么会有不训练隐藏层的网络
传统神经网络的训练逻辑是:前向传播算出损失,反向传播把梯度逐层传回去,然后更新每一层的权重。这套机制很强大,但它有两个绕不开的代价:一是迭代次数多,几千上万个epoch很常见;二是梯度路径一旦太长,就容易出现梯度消失、梯度爆炸,或者陷入局部极小。
RVFLNN的思路完全不同。它把一个神经网络拆成了两部分看待:一部分负责把原始输入做非线性变换,形成高维特征;另一部分负责在这个高维特征空间里做线性拟合。非线性变换这一层,不需要"学"出最优参数,只要随机生成一组权重和偏置,把输入映射到另一个特征空间就够了。真正需要学习的,只有最后那个线性输出层的权重系数。而线性输出层学到的东西,是有解析解的,不需要迭代。
这个逻辑听起来很反直觉,但它的理论根基是扎实的。Pao等人在20世纪90年代提出RVFLNN时就证明过,只要隐藏层节点数足够多、激活函数满足一定条件,随机生成的隐藏层再加上输入到输出的直接连接,就能以任意精度逼近连续函数。换句话说,"随机投影+线性回归"这种组合,理论上是完备的。
1.2 名字里的"函数链"到底指什么
很多初学者看到"函数链"这三个字会比较懵。其实它指的是网络输入不是直接进入输出层,而是先经过一个增强变换,把原始特征扩展成一组新的特征表示,再和原始特征拼接在一起,形成一条"函数链"。
这里的增强方式有两种流派。早期的Functional Link Neural Network(FLNN)会用多项式展开,比如把[x1, x2]扩展成[x1, x2, x1², x1*x2, x2²]这种人工构造特征。而RVFLNN做得更省事:直接用一层随机权重的隐藏层来做特征变换。每个隐藏节点相当于一个随机非线性特征提取器,输入经过它之后被映射到一个随机方向上,再经过激活函数做非线性压缩。多个这样的节点合在一起,就构成了一个高维随机特征空间。
关键细节在于:RVFLNN会把原始输入X也直接拼接到隐藏层输出H后面,形成增强矩阵D = [X | H]。这个"直接连接"(direct link)是RVFLNN和另一个常见模型ELM(极限学习机)最重要的区别之一。ELM只保留随机隐藏层的输出,丢弃了原始输入;RVFLNN则保留了原始特征。别小看这个区别,原始特征中本来就已经包含了一些线性信息,直接保留它,可以让输出层同时利用"原始线性特征"和"随机非线性特征"两套信息,拟合能力通常会更强。
1.3 RVFLNN适合什么场景
根据我的实际经验,RVFLNN在以下场景里特别吃香:
- 数据规模不大、特征维度适中(几百到几千条样本、十几个到几十个特征),BP网络还没训完一个像样的模型,RVFLNN已经出结果了;
- 需要频繁更新模型,比如在线预测、实时流式数据,每次新数据来了只要重新求一次伪逆矩阵就行,不需要重放历史梯度;
- 嵌入式或资源受限环境,没有GPU,纯CPU推理,RVFLNN的前向计算就是一个矩阵乘加操作,非常轻量;
- 做研究或工程实验时需要一个快速的非线性baseline,和线性模型、树模型、深度模型做对比。
它不适合的场景也很明确:海量数据(几百万条以上)、极高维原始输入(比如图像像素级输入)、需要层次化特征抽象的任务(比如图像识别、语音识别)。这些场景下,浅层的RVFLNN很难和深度网络抗衡。
2. RVFLNN的学习机制拆解:随机固定层与输出权重解析解
2.1 数学形式化
假设训练集有n条样本,每条样本有d维特征,即X∈R^(n×d),目标变量Y∈R^(n×c),其中c在回归任务中是输出维度,在分类任务中是类别数(使用one-hot编码)。
RVFLNN的前向过程分三步。
第一步,随机生成隐藏层权重W∈R^(d×L)和偏置b∈R^L,其中L是隐藏层节点数。W和b不需要训练,生成后固定住。生成方式通常是均匀分布或正态分布,具体区间后面会讲。
第二步,计算隐藏层输出:
H = σ(XW + b)
其中σ是激活函数,H∈R^(n×L)。
第三步,把原始输入X和隐藏层输出H沿列方向拼接,得到增强矩阵:
D = [X | H],D∈R^(n×(d+L))
然后求解输出层权重β∈R^((d+L)×c),使得Dβ ≈ Y。
这里的目标函数是最小化:
min ||Dβ − Y||² + λ||β||²
其中λ是正则化系数。这是一个带L2正则的线性最小二乘问题,也叫岭回归。对它求导并令导数为0,可以得到解析解:
β = (DᵀD + λI)⁻¹DᵀY
如果不用正则化,也可以直接用伪逆求解:
β = pinv(D)Y
预测阶段只需要把新输入X_new同样过一遍随机层:
Ŷ = [X_new | σ(X_new W + b)] β
2.2 为什么解析解可行
接触过神经网络的人应该都知道,传统MLP的损失函数关于所有参数不是凸的,所以需要梯度下降去逼近局部最优解。RVFLNN巧妙地把问题拆分成了两部分:非线性特征提取部分靠随机投影完成,不做优化;线性输出部分的优化目标是关于β的凸函数。凸函数没有局部极小值的问题,求出来就是全局最优。
这个设计带来的好处非常直观。训练RVFLNN时不需要设置学习率、不需要决定多少次epoch、不需要担心梯度爆炸或消失,输入数据喂进来,解一个线性方程组,结束。计算量主要花在求解(d+L)×(d+L)矩阵的逆上,这个矩阵的维度通常只有几百乘几百,在CPU上跑也是毫秒级别。
2.3 和ELM的异同
ELM(Extreme Learning Machine,极限学习机)是2006年前后很火的一个模型,和RVFLNN有很强的亲缘关系。两者的共同点是:隐藏层权重随机初始化并固定,输出权重用最小二乘或伪逆求解。
差异也很明显:ELM的增强矩阵D只包含隐藏层输出H,没有原始输入X;RVFLNN则把X和H拼接在一起。这就意味着,RVFLNN在原始特征本身已经含有较强线性信号的任务中,通常比ELM表现更好,因为输出层可以直接利用原始特征的信息,不需要通过随机投影来"重构"线性关系。而ELM如果要用随机节点模拟出线性关系,往往需要更多节点才能覆盖。
如果任务中原始特征经过标准化后已经比较适合线性模型,我通常会优先用RVFLNN而不是ELM。反过来,如果原始特征里噪声很大,直接连接反而可能把噪声带入输出层,这时候ELM的纯随机投影结构可能更稳。不过这个规律不是绝对的,还是得靠实验验证。
3. 从零手写RVFLNN:一份可运行的测试代码
3.1 核心类实现
先说环境。下面的代码只需要Python 3.8以上、NumPy和scikit-learn。SciPy也建议装上,因为我会用它的expit函数作为sigmoid实现,数值稳定性比手写的1/(1+exp(-x))更好。
import numpy as np from scipy.special import expit class RVFLNN: def __init__(self, n_hidden=100, activation="sigmoid", reg=1e-3, random_state=42): self.n_hidden = n_hidden self.activation = activation self.reg = reg self.random_state = random_state def _activate(self, X): if self.activation == "sigmoid": return expit(X) elif self.activation == "tanh": return np.tanh(X) elif self.activation == "relu": return np.maximum(X, 0.0) else: raise ValueError(f"Unsupported activation: {self.activation}") def fit(self, X, y): rng = np.random.RandomState(self.random_state) n_samples, n_features = X.shape # 随机生成隐藏层权重与偏置(-1, 1区间),生成后固定 self.W = rng.uniform(-1.0, 1.0, size=(n_features, self.n_hidden)) self.b = rng.uniform(-1.0, 1.0, size=(self.n_hidden,)) # 隐藏层输出 H = self._activate(X @ self.W + self.b) # 增强矩阵:原始特征 + 隐藏层特征 D = np.hstack([X, H]) # 岭回归解析解 n_aug = D.shape[1] I = np.eye(n_aug) self.beta = np.linalg.solve(D.T @ D + self.reg * I, D.T @ y) return self def predict(self, X): H = self._activate(X @ self.W + self.b) D = np.hstack([X, H]) return D @ self.beta这里我用了np.linalg.solve而不是np.linalg.inv,原因是solve直接求解线性方程组,计算效率和数值稳定性都优于先求逆再乘。DᵀD + λI是对称正定矩阵,用solve求解完全没问题。
如果你不想做L2正则,想直接用伪逆,np.linalg.pinv(D) @ y也能得到结果,但实际中几乎总是建议保留一个很小的正则项。原因在于随机投影产生的D可能存在多重共线性,即某些列之间高度相关,这种情况下直接求伪逆会让输出权重方差变大,模型泛化能力被削弱。加一个小λ相当于给权重加了收缩,反而是免费的午餐。
3.2 回归测试:非线性函数拟合
先测回归。这里构造一个非线性程度较高的人工数据集:
from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler from sklearn.metrics import mean_squared_error rng = np.random.RandomState(0) n_samples = 500 X = rng.uniform(-1, 1, size=(n_samples, 2)) y = (0.6 * np.sin(3 * X[:, 0]) + 0.4 * np.cos(4 * X[:, 1]) + 0.2 * X[:, 0] * X[:, 1]) y = y.reshape(-1, 1) + 0.05 * rng.randn(n_samples, 1) X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.3, random_state=1 ) scaler = StandardScaler() X_train_scaled = scaler.fit_transform(X_train) X_test_scaled = scaler.transform(X_test) model = RVFLNN(n_hidden=200, activation="tanh", reg=1e-4, random_state=42) model.fit(X_train_scaled, y_train) y_pred = model.predict(X_test_scaled) rmse = mean_squared_error(y_test, y_pred, squared=False) print(f"RVFLNN Test RMSE: {rmse:.4f}")我在实际跑的时候,RVFLNN在这个数据集上的测试RMSE通常能到0.04到0.06之间。可以对比一下:如果直接用线性回归在原始特征上预测,RMSE大概在0.3以上。这个差距足以说明随机非线性特征的价值。
3.3 分类测试:用one-hot编码跑通流程
分类任务的处理方式和回归略有不同。RVFLNN的输出层本质上是最小二乘求解,所以我需要把分类标签转化为数值矩阵——通常用one-hot编码。
from sklearn.datasets import make_moons from sklearn.preprocessing import OneHotEncoder from sklearn.metrics import accuracy_score X_cls, y_cls = make_moons(n_samples=400, noise=0.25, random_state=42) encoder = OneHotEncoder(sparse_output=False) y_cls_onehot = encoder.fit_transform(y_cls.reshape(-1, 1)) X_train_c, X_test_c, y_train_c, y_test_c = train_test_split( X_cls, y_cls_onehot, test_size=0.3, random_state=1 ) scaler_c = StandardScaler() X_train_c = scaler_c.fit_transform(X_train_c) X_test_c = scaler_c.transform(X_test_c) clf = RVFLNN(n_hidden=300, activation="tanh", reg=1e-4, random_state=0) clf.fit(X_train_c, y_train_c) prob_pred = clf.predict(X_test_c) pred_labels = np.argmax(prob_pred, axis=1) true_labels = np.argmax(y_test_c, axis=1) print(f"RVFLNN Classification Accuracy: {accuracy_score(true_labels, pred_labels):.4f}")这里的预测输出是连续值,但我们可以用argmax取概率最大的类别作为预测标签。RVFLNN用最小二乘训练分类任务,形式上没有softmax和交叉熵那套流程,但实践下来在不少二分类任务上效果并不差。这点后面实验部分会展开说。
3.4 代码里的几个容易出错的小地方
第一,一定要做特征缩放。RVFLNN的隐藏层计算是X@W+b,如果X的量纲千差万别,比如一列在0到1,另一列在0到10000,那么W和X点乘后,大数值列会完全主导隐藏节点的输入,激活函数很容易饱和,其他列的信息就丢失了。用StandardScaler标准化到零均值单位方差后,这个风险大大降低。
第二,one-hot编码在旧版sklearn里参数是sparse=False,新版改成了sparse_output=False。如果你用的版本比较旧,注意回头改一下。
第三,随机权重区间不是固定必须[-1,1],这个后面专门有一节讲。
4. 回归、分类对比实测:RVFLNN和MLP、Ridge的差距有多大
4.1 对比实验设计
光看RVFLNN自己的表现还不够,把它放到基准对比中才看得出价值。我设计了两个实验:一个回归,一个分类。对比对象包括:
- Ridge回归:只有原始特征,没有非线性变换,等价于一个强线性baseline;
- RVFLNN:随机投影+输出层解析解,本次的主角;
- MLP(scikit-learn实现):传统反向传播网络,作为神经网络baseline。
MLP参数设置上,隐藏层大小我选(100,),激活函数relu,最大迭代1000次,其他用默认值。RVFLNN隐藏层节点数取和MLP第一层相同的100个,保证一定可比性。所有模型都做相同的标准化处理。
4.2 非线性回归实验
数据集仍然用前面那个0.6*sin(3*x1)+0.4*cos(4*x2)+0.2*x1*x2的构造数据,500条样本,测试集占30%。
from sklearn.linear_model import Ridge from sklearn.neural_network import MLPRegressor models = { "Ridge": Ridge(alpha=1e-2), "RVFLNN": RVFLNN(n_hidden=100, activation="tanh", reg=1e-4, random_state=42), "MLP": MLPRegressor(hidden_layer_sizes=(100,), activation="relu", max_iter=1000, random_state=42, early_stopping=True), } for name, mdl in models.items(): mdl.fit(X_train_scaled, y_train) pred = mdl.predict(X_test_scaled) rmse = mean_squared_error(y_test, pred, squared=False) print(f"{name:10s} RMSE: {rmse:.4f}")我本地跑过一次,结果大致如下:
| 模型 | 测试RMSE |
|---|---|
| Ridge | 0.3241 |
| RVFLNN | 0.0487 |
| MLP | 0.0512 |
这个结果挺有意思。RVFLNN在这个数据集上不仅远超Ridge,还略微赢了MLP。注意MLP我开了early_stopping,迭代次数可能没有跑满1000轮,但即便跑满,优势也未必能扳回来。原因在于这个任务的非线性结构比较规整,RVFLNN的随机投影加直接输入就能充分覆盖,而MLP反而要花大量迭代找合适的特征组合。
4.3 二分类实验
分类用make_moons,噪声参数0.25,400条样本。对比模型换成RidgeClassifier、RVFLNN、MLPClassifier。
from sklearn.linear_model import RidgeClassifier from sklearn.neural_network import MLPClassifier from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler from sklearn.metrics import accuracy_score # 注意这里y_cls是原始0/1标签,不必one-hot models_cls = { "RidgeClf": RidgeClassifier(alpha=1e-2), "RVFLNN": RVFLNN(n_hidden=100, activation="tanh", reg=1e-4, random_state=42), "MLP": MLPClassifier(hidden_layer_sizes=(100,), activation="relu", max_iter=1000, random_state=42, early_stopping=True), } X_tr, X_te, y_tr, y_te = train_test_split(X_cls, y_cls, test_size=0.3, random_state=1) scaler = StandardScaler() X_tr = scaler.fit_transform(X_tr) X_te = scaler.transform(X_te) y_tr_onehot = OneHotEncoder(sparse_output=False).fit_transform(y_tr.reshape(-1, 1)) for name, mdl in models_cls.items(): if name == "RVFLNN": mdl.fit(X_tr, y_tr_onehot) prob_pred = mdl.predict(X_te) pred_labels = np.argmax(prob_pred, axis=1) else: mdl.fit(X_tr, y_tr) pred_labels = mdl.predict(X_te) acc = accuracy_score(y_te, pred_labels) print(f"{name:10s} Accuracy: {acc:.4f}")一次典型结果:
| 模型 | Accuracy |
|---|---|
| RidgeClf | 0.8583 |
| RVFLNN | 0.8917 |
| MLP | 0.8833 |
这里RVFLNN又压过了MLP一点,但领先不多。能明显看到的是,Ridge在这个非线性明显的月亮数据集上吃亏,只有原始特征构建不出好的分隔边界,而RVFLNN和MLP都能通过非线性变换找到更合适的边界。
4.4 训练时间对比
训练耗时上差距也很值得拍个照。由于MLP内部要迭代,通常耗时显著高于RVFLNN。我粗测过,同样的分类任务,MLP大概需要0.1秒量级,传统TensorFlow里那种多隐层网络更慢;RVFLNN的耗时基本小于10毫秒。差距有几个数量级。
这里要说明一点:不是RVFLNN比MLP"更强"。RVFLNN在小规模、规整非线性任务上确实又快又准,但深度学习模型在更大规模数据、更复杂结构上还有很大的能力空间。对比实验只想说明一件事:如果不需要深度特征抽象,RVFLNN完全可以用更低的成本换来和MLP相当的精度。
5. 隐藏层规模、随机区间与正则化:调参经验与踩坑记录
5.1 随机权重区间到底怎么选
我在第3章的代码里用了[-1,1]区间,这是最常见的选择,但它不是最优的万金油。随机权重的区间在很大程度上决定了隐藏层输入的数值范围,进而影响激活函数的工作区域。
以sigmoid为例,如果输入数据已经标准化到均值0、方差1,当权重区间取[-1,1]时,每个隐藏节点的输入z = Σx_i*w_i的方差大约等于特征数量d的1/3。当d比较大时,z很容易跑到±5甚至±10,sigmoid函数在这些区域已经接近饱和,梯度非常平,输出几乎只有0或1,这会严重损失特征表达的细腻程度。
解决办法是使用类似Xavier初始化的思路:把权重方差控制在1/d或2/(d+L)量级。具体来说,可以令区间为
bound = 1 / sqrt(n_features)
或
bound = sqrt(6 / (n_features + n_hidden))
前者对应均匀分布U(-bound, bound)。我实测下来,针对激活函数为tanh的情况,取1/sqrt(n_features)比直接[-1,1]在很多任务上更稳。针对relu,因为它在正半轴无饱和问题,[-1,1]通常还好,但如果输入维度很高,也建议缩小区间。
你可以在代码里简单加一个参数控制:
bound = 1.0 / np.sqrt(n_features) self.W = rng.uniform(-bound, bound, size=(n_features, self.n_hidden)) self.b = rng.uniform(-bound, bound, size=(self.n_hidden,))5.2 隐藏层节点数:不是单调越多越好
增加隐藏层节点数L,相当于增加了随机投影的维度,原则上能增强非线性表达。但L增大也会带来两个副作用:一是D的维度变大,求解(DᵀD+λI)⁻¹的复杂度是O((d+L)³),节点数到几千时CPU版训练会明显变慢;二是投影维度增加的同时,若正则化强度不够,输出层更容易过拟合训练数据的噪声。
我的经验是:先用200、500、1000这几个量级各跑一遍,对比验证集指标。通常到某个值之后指标会进入平台期甚至下降。遇到平台期就不要再加节点了,省下来的时间可以做更有价值的事情。
5.3 正则化系数λ:和噪声程度强相关
λ的作用是控制输出权重β的幅度。λ太小,模型对训练数据拟合得过于彻底,把噪声也学进去了;λ太大,模型被过度压缩,欠拟合。
在无噪声或低噪声场景,λ取1e-6到1e-4都能跑出不错的结果。在噪声较大的场景,我习惯把λ放到1e-2甚至1e-1。一个相对有效的粗调方式:先在1e-6、1e-4、1e-2、1e0四个量级上各跑一遍,观察验证集误差曲线,找到误差明显下降的量级后再细化。
5.4 隐藏节点"死掉"的现象
用sigmoid或tanh激活时,随机初始化可能导致某些节点对所有训练样本的输出几乎是一个常数。原因是该节点的权重向量W_j和偏置b_j组合得不合适,导致所有样本的z = XW_j + b_j都落在激活函数的饱和区。这个节点相当于"死掉了",对最终结果没有贡献,反而浪费了计算资源。
检查方法很直接:fit之后计算隐藏层输出矩阵H,看每一列的方差。如果某一列的标准差接近0,说明这个节点基本没在表达。出现这种情况时,可以重新调整随机区间,或者简单地把n_hidden增大一些,让冗余节点互相弥补。真正的解决思路是不要迷信单次随机结果,多换几个random_state跑,取验证集指标最好的那组。
5.5 多跑几次取平均:随机性其实没那么可怕
有人会担心:隐藏层权重是随机生成的,那每次跑出来的结果不就不一样了吗?确实,不同随机种子会带来一定波动,但波动幅度通常比想象中小很多。我在同一个回归数据集上换5个不同的random_state跑,测试RMSE的方差一般在0.005以内,相对波动百分之十左右。这在大多数实际应用中完全可以接受。
如果希望结果更稳,有两个方案。一是投票或平均策略:用不同随机种子训练多个RVFLNN,预测时取平均,效果通常比单模型更好、方差更小。二是尽量增大隐藏层节点数,节点越多,随机初始化带来的个体差异越容易被稀释。当然,如果追求严格可复现,固定random_state就够了。
5.6 不要忽略原始特征的质量
DVFLNN和ELM这类模型有个共同特点:因为隐藏层不训练,模型的性能上限很大程度取决于原始特征质量。如果原始特征里有效信息很少,随机投影再怎么变换也变不出有用的模式。所以用RVFLNN之前,该做的特征工程、特征筛选还是不能省。它代替的是"训练深度学习模型"这一步,但代替不了"理解数据"这一步。
我在做那个在线预测项目时,最后的生产方案就是RVFLNN加一个简单的特征筛选流程。每次新数据进来,特征筛选逻辑直接复用历史结果,RVFLNN重新求解一次输出权重,整个更新过程不到20毫秒,比之前用BP的方案整整快了两个量级。精度上,和精心调过的MLP持平,但省下的时间全部留给了特征迭代和业务分析,这个性价比是实打实的。
如果你正在被"训练慢、调参烦、没有GPU"三个问题同时困扰,不妨先花一个小时把RVFLNN在本地跑通。它的原理够简单、实现够短、效果够直观,属于那种"看完就能用、用了就有结果"的模型。就算以后你回到深度学习的正轨上,RVFLNN"随机固定层+解析解输出层"这种思路,也值得留在工具箱里,说不定在某个任务里就成了最合适的答案。