ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

Adaline与LMS权重更新实战:损失曲线、决策边界与调参排错

Adaline与LMS权重更新实战:损失曲线、决策边界与调参排错

简介:这份代码包面向神经网络导论课程的学习者与Matlab初学者,围绕Adaline自适应线性神经元及LMS学习规则展开,用可运行脚本呈现课程实验一的核心内容,帮助读者理解线性最小均方误差算法如何更新权重、完成训练与测试评估。压缩包共5个文件,以3个.m脚本和2个.mat数据文件为主,整体约10KB;脚本承担算法实现、随机权重初始化与样本随机划分,数据文件则保存训练集和测试集样本,便于直接载入实验。已有300人学习,适合希望从代码层面建立Adaline模型直观认识、补充神经网络基础实验练习的读者。内容覆盖权重更新公式、学习率选择、迭代训练、随机初始化对收敛的影响,以及训练/测试集划分与泛化性能评估等知识点,可作为课程实验复现、算法对照和后续类似神经元模型实现的参考素材。

1. 神经网络导论课程实验1代码:从一行权重更新开始把 Adaline 跑明白

拿到这份神经网络导论课程实验1代码,多数人第一反应是打开主脚本点运行,看见 loss 曲线往下走就交差。可真到验收被问一句「Adaline的LMS和感知机到底差在哪」,很多人当场卡壳。这个实验包干的事很具体:用自适应线性神经元配合最小均方规则做二分类,把权重更新、损失下降、决策边界这几件事串成一条能跑通的链路。它适合刚学完感知机想动手验证的同学,也适合需要一份干净基线代码做对比实验的开发者——代码量不大,但每个环节都值得拆开看。

2. 实验包结构与运行环境:从数据文件到训练入口的目录拆解

2.1 目录长什么样、每个文件管什么

这类课程实验包一般不会做太重,但结构基本是固定的:数据、模型、指标、可视化、入口脚本分开放。我见过的版本大致长这样,不同人交上来的名字略有差异,职责划分八九不离十。

nn-lab1/ ├── data/ │ ├── iris_binary.csv # 鸢尾花两分类子集,最后一列是标签 │ └── gaussian_2class.csv # 两类高斯分布生成的线性可分数据 ├── src/ │ ├── dataset.py # 读取、划分、标准化 │ ├── adaline.py # Adaline 模型 + LMS 更新 │ ├── perceptron.py # 感知机对照实现 │ ├── metrics.py # 准确率、混淆矩阵 │ └── visualize.py # 损失曲线、决策边界 ├── train.py # 训练入口,命令行参数在这层解析 ├── requirements.txt └── README.md

src/adaline.py是整份代码的核心,只有几十行,但它决定了实验报告里所有结论能不能站住。dataset.py负责把原始表格变成模型能吃的float64矩阵,这一步做错,后面调参全是玄学。train.py很薄,只做参数拼装和调用,方便你改学习率、迭代轮数而不用动模型代码。

最容易被忽略的是metrics.py和visualize.py。二分类的准确率一行就能写完,但实验报告要的是「为什么这个学习率下准确率高」——那就得靠混淆矩阵和决策边界图说话。我一般建议先把这两个文件读一遍,知道最后能产出什么图,再回头理解训练流程,目标感会强很多。

提示:如果下载到的包没有data/目录,先看README.md里有没有生成脚本。很多实验包为了控制体积,数据是用numpy.random现场生成的,跑一次dataset.py就会落到本地。

2.2 环境准备与三条运行命令

依赖不会多,numpy、pandas、matplotlib三件套就够跑通主流程,scikit-learn只在需要做数据划分或者对照实验时用得上。Python 版本建议 3.9 以上,numpy用 1.24 之后的版本,避免np.float64、np.int64这类类型在旧版本里行为不一致带来奇怪的报错。

# 1. 建虚拟环境,别把系统 Python 搞乱 python -m venv .venv source .venv/bin/activate # Windows 用 .venv\Scripts\activate # 2. 装依赖 pip install -r requirements.txt # 3. 跑训练入口,先看默认参数下的结果 python train.py --data data/iris_binary.csv --eta 0.01 --epochs 50

这三条命令跑完,终端会打印每个 epoch 的损失和最终准确率,同时弹出一张损失曲线图。如果图没弹出来,多半是脚本里用了matplotlib.use('Agg')后端,去visualize.py顶部把它注释掉,或者改成保存到outputs/目录再看。

--eta是学习率,--epochs是遍历整个训练集的轮数。默认值通常给的是0.01和50,这是标准化之后比较稳的组合。第一次跑不建议改,先用默认参数确认链路通,再动参数。跑不通的时候优先怀疑依赖版本和数据路径,而不是模型写错了——血泪经验是,九成的「跑不起来」都发生在环境层。

2.3 数据从哪来、长什么样

iris_binary.csv是把三分类的鸢尾花数据砍成两类,只留山鸢尾和变色鸢尾,取花瓣长度和花瓣宽度两个特征。为什么留这两个:它们的量纲接近,标准化前后的对比不会太夸张,画决策边界也好看。标签列一般被映射成1和-1,这一点非常关键,后面讲公式时会展开。

gaussian_2class.csv是两类二维高斯分布各采样若干点拼出来的,均值分别放在(2, 2)和(-2, -2),协方差矩阵接近单位阵。它的价值在于「线性完全可分」,用来验证模型能不能收敛到零损失;而鸢尾花数据存在少量重叠,用来观察误分类样本长什么样。

原始表格进模型之前要走三步:取特征矩阵X、取标签向量y、做标准化。标准化的写法是(X - X.mean(axis=0)) / X.std(axis=0),按列减均值除标准差。不标准化直接用原始特征,LMS 的梯度会被数值大的特征主导,学习率稍微大一点就发散,这是新手翻车最集中的地方。

3. Adaline 与 LMS 核心实现:权重更新那几行到底在算什么

3.1 模型类骨架与初始化

整个模型不到四十行,结构照搬了 scikit-learn 的风格:构造器只存超参数,fit里训练,predict里推理。这样写的好处是你后面想换成sklearn的交叉验证工具,接口能对得上。

import numpy as np class Adaline: """自适应线性神经元,使用批量梯度下降(LMS / Widrow-Hoff 规则)""" def __init__(self, eta=0.01, n_iter=50, random_state=42): self.eta = eta # 学习率,控制每次更新的步长 self.n_iter = n_iter # 遍历训练集的轮数 self.random_state = random_state # 随机种子,保证结果可复现 def fit(self, X, y): # 用小方差随机数初始化权重,避免所有神经元从同一点出发 rgen = np.random.RandomState(self.random_state) self.w_ = rgen.normal(loc=0.0, scale=0.01, size=X.shape[1]) self.b_ = np.float64(0.0) self.losses_ = [] # 记录每轮损失,画收敛曲线用 for _ in range(self.n_iter): output = self.net_input(X) errors = y - output # 批量 LMS:一次性用全部样本的误差和更新权重 self.w_ += self.eta * X.T.dot(errors) self.b_ += self.eta * errors.sum() # 平方误差和的一半,方便求导后系数为 1 loss = (errors ** 2).sum() / 2.0 self.losses_.append(loss) return self def net_input(self, X): # 线性组合,Adaline 的「激活函数」就是恒等映射 return np.dot(X, self.w_) + self.b_ def activation(self, X): return self.net_input(X) def predict(self, X): # 用符号函数把连续输出压成类别 return np.where(self.activation(X) >= 0.0, 1, -1)

权重初始化用normal(0, 0.01)而不是全零,这是个细节。全零初始化时,如果标签分布恰好对称,第一轮梯度会让所有权重同步移动,模型退化成只学偏置,表现就是准确率长期卡在 0.5 附近。scale=0.01足够小,不会让初始输出过大,又保留了打破对称的随机性。

net_input里的线性组合就是 Adaline 与多层网络最大的区别:它没有非线性激活。整流、sigmoid、tanh 在这里都不存在,输出是实数,可以是1.83也可以是-0.42。正因为输出连续,才能定义平方误差,才能求导,LMS 才有意义。这一点想明白了,Adaline 和感知机的分界线就清楚了。

3.2 fit 方法里的批量 LMS 更新

核心就三行:算输出、算误差、按误差和更新。写成公式是

w := w + η · Xᵀ · (y − Xw − b) b := b + η · Σ(yᵢ − zᵢ)

推导不复杂,损失函数取J(w) = ½ Σ(yᵢ − zᵢ)²,对w求偏导得到−Σ(yᵢ − zᵢ)xᵢ,沿负梯度方向走一步就是上面的更新式。系数½的作用是求导时把平方的2约掉,让表达式干净,对最终结果没有影响,但代码里得和公式保持一致,否则学习率的量级会差一倍。

X.T.dot(errors)这一步是把每个样本的误差按特征维度加权求和,得到的是一个和w同形状的向量。假设X是(n_samples, n_features),errors是(n_samples,),那么X.T是(n_features, n_samples),点乘后得到(n_features,),正好能直接加到w_上。这种批量写法比逐个样本循环快一个数量级,也让更新方向更稳定。

批量更新和随机更新的差别值得说一句。批量 LMS 每轮只更新一次,梯度方向由全部样本决定,损失曲线平滑单调;随机 LMS 每看一个样本就更新一次,收敛快但曲线抖。课程实验一般用批量,因为曲线上行下行一眼能看出来,写报告好解释。真要上生产,随机或者小批量更常见。

偏置的更新是errors.sum(),本质是把偏置当成一个输入恒为 1 的权重。有些实现会把1拼到X后面变成增广矩阵,代码更短,但可读性差一点。两种写法等价,选一种坚持用就行。

3.3 预测与决策边界:为什么用 ±1 而不是 0/1

predict用np.where(activation >= 0, 1, -1),标签空间是{+1, -1}。这不是随便定的。LMS 的误差项是y − z,如果标签用0/1,那么当模型输出z = 0.5、真实标签为0时,误差是-0.5;而真实标签为1时误差是+0.5。两类的误差不对称,梯度会把决策边界往一边推,收敛后的边界位置会偏。

换成±1之后,两类误差关于零点对称,模型学到的超平面正好落在两类中间。这也是为什么dataset.py里要做一次标签映射,把原始的0/1或者字符串标签转成±1。很多人跑出来准确率不低但决策边界明显偏,回头查就是这一步没做。

决策边界本身由wᵀx + b = 0定义,在二维平面上是一条直线。w是法向量,方向垂直于直线,b决定直线到原点的距离。可视化的时候把网格点喂给predict,得到类别后填色,就能看到两个色块和中间那条线。标准化之后,w的两个分量大小可以直接比较,哪个特征权重大,直线就更垂直于哪个轴,这是实验报告里可以写的一段分析。

4. 训练过程可视化与参数对比:学习率、标准化和收敛曲线怎么调

4.1 损失曲线与学习率扫描

单次训练的损失曲线只能说明「收敛了没有」,看不出学习率选得好不好。要做对比,就把同一份数据、同一个随机种子下不同eta的曲线画在一张图上,纵轴取对数。

import numpy as np import matplotlib.pyplot as plt from src.adaline import Adaline # X_std、y 是已经标准化并映射到 ±1 的数据 etas = [0.0001, 0.001, 0.01, 0.1] plt.figure(figsize=(8, 5)) for eta in etas: model = Adaline(eta=eta, n_iter=30, random_state=1) model.fit(X_std, y) plt.plot(range(1, len(model.losses_) + 1), model.losses_, label=f'eta={eta}') plt.yscale('log') # 对数纵轴,小学习率的缓慢下降才看得清 plt.xlabel('epoch') plt.ylabel('SSE / 2 (log scale)') plt.legend() plt.tight_layout() plt.savefig('outputs/loss_vs_eta.png', dpi=150)

纵轴用对数是因为不同学习率下的损失能差好几个数量级,线性坐标下小学习率的曲线会贴在地上,看不出形状。跑完这张图,通常能看到三种典型形态:eta=0.0001的曲线缓慢下降但 30 轮远没到底,说明步子太小;eta=0.01的曲线快速下降后走平,是理想状态;eta=0.1可能在前几轮就冲到nan或者来回震荡,说明步子太大越过了谷底。

nan的出现机制值得单独说。批量 LMS 在标准化后的数据上,损失函数的等高线接近圆形,稳定学习率的上界大约在1/λ_max量级,λ_max是XᵀX的最大特征值。特征维度越高、样本越多,这个上界越小。不标准化时不同特征方差差几十倍,λ_max被最大的那个特征撑大,上界被压到很小,所以「不标准化 + 默认学习率」是发散的高发组合。

注意:损失曲线出现nan不要第一反应去改代码,先打印前三个 epoch 的losses_,确认是第几轮炸的。如果第一轮就是inf,问题在数据(有nan或者量纲离谱);如果是第三四轮才炸,就是学习率过大。

4.2 标准化前后决策边界的差别

决策边界图是实验报告里最有说服力的一张。做法是在特征空间铺网格,逐点预测,用contourf填色,再把真实样本散点叠上去。

import numpy as np import matplotlib.pyplot as plt def plot_decision_regions(X, y, clf, resolution=0.02, ax=None): markers = ('o', 's') colors = ('#1f77b4', '#d62728') ax = ax or plt.gca() x1_min, x1_max = X[:, 0].min() - 1, X[:, 0].max() + 1 x2_min, x2_max = X[:, 1].min() - 1, X[:, 1].max() + 1 xx1, xx2 = np.meshgrid( np.arange(x1_min, x1_max, resolution), np.arange(x2_min, x2_max, resolution)) # 网格点拼成 (n_points, 2) 后一次性预测,比双重循环快得多 grid = np.array([xx1.ravel(), xx2.ravel()]).T Z = clf.predict(grid).reshape(xx1.shape) ax.contourf(xx1, xx2, Z, alpha=0.25, colors=colors) for idx, cl in enumerate(np.unique(y)): ax.scatter(X[y == cl, 0], X[y == cl, 1], c=colors[idx], marker=markers[idx], edgecolor='k', label=f'class {cl}') ax.set_xlabel('feature 1') ax.set_ylabel('feature 2') ax.legend(loc='upper left') return ax

resolution控制网格密度,0.02画出来的边界已经够平滑,再小会让predict的调用量翻倍,画图时间从一秒变十秒,没必要。alpha=0.25让填色半透明,散点不会被盖住。

把标准化前后的两次训练结果并排画,差别会非常直观。未标准化时,两个特征量纲差几倍,w会被大量纲特征主导,边界几乎垂直于那个轴,另一侧的空隙被浪费;标准化之后边界落在两类中间,间隔更均匀。这也是为什么标准化被叫做「免费的午餐」——不改模型、不加数据,只改预处理就能提点。

4.3 和感知机放一起对比的实验脚本

课程实验一般要求把 Adaline 和感知机放一起比。感知机的更新规则是w += η · Xᵀ · (y − ŷ),注意误差用的是符号化之后的预测ŷ,只有分错才更新,分对了误差为零。这个差别导致感知机在数据线性不可分时永远不收敛,权重会一直震荡。

class Perceptron: """最朴素的感知机,用于和 Adaline 对照""" def __init__(self, eta=0.01, n_iter=50, random_state=42): self.eta = eta self.n_iter = n_iter self.random_state = random_state def fit(self, X, y): rgen = np.random.RandomState(self.random_state) self.w_ = rgen.normal(0.0, 0.01, X.shape[1]) self.b_ = 0.0 self.errors_ = [] # 记录每轮误分类数 for _ in range(self.n_iter): errors = 0 for xi, target in zip(X, y): # 逐样本更新,误差用 sign 后的预测 update = self.eta * (target - self.predict_one(xi)) self.w_ += update * xi self.b_ += update errors += int(update != 0.0) self.errors_.append(errors) return self def predict_one(self, x): return 1 if np.dot(x, self.w_) + self.b_ >= 0 else -1 def predict(self, X): return np.array([self.predict_one(x) for x in X])

对比脚本里让两个模型用同一份标准化数据、同一个random_state,一个画losses_,一个画errors_。在完全线性可分的gaussian_2class.csv上,感知机的误分类数会快速归零,Adaline 的损失则是指数衰减但不为零(因为它是回归意义上的拟合);在鸢尾花数据上,感知机的误分类数会在 1 到 3 之间跳,永远停不下来,Adaline 的损失则稳定在一个小的正值。这两条曲线放在一起,比任何文字解释都清楚。

5. 常见问题排查:loss 变 nan、准确率卡 0.5 的五个坑

5.1 loss 第一轮就变成 nan 或者 inf

现象:python train.py跑起来,第一个 epoch 打印出来的loss就是inf或者nan,后面全是nan。

原因:数据里混进了nan或者无穷值,或者某些特征量纲极大(比如某一列是「以分为单位的金额」而另一列是「0 到 1 的比例」),导致XᵀX的最大特征值极大,学习率一乘就爆。

解决:读数据后立刻走一遍np.isfinite(X).all()和np.isnan(X).any(),有脏值先删行或者填充;然后强制标准化,把eta从0.01降到0.001再跑。我一般会加一行断言assert np.isfinite(X).all(), "X 里有 nan/inf",比事后看堆栈省事得多。

5.2 损失不降反升,曲线一路向上

现象:losses_前几轮还正常,从某一轮开始加速上升,最后爆掉。

原因:学习率超过稳定上界。批量 LMS 的稳定条件和XᵀX的特征值有关,学习率大于1/λ_max时每次更新都会跨过谷底落到更高的位置,误差被放大,下一轮跨得更远,正反馈发散。

解决:把eta缩小一个数量级再试,同时开标准化。判断标准是看收敛需要的 epoch 数,如果降到0.001需要几百轮才收敛,说明数据维度高或者特征相关性强,可以考虑换小批量或者加正则,但课程实验里一般降学习率就够。

5.3 准确率长期卡在 0.5 附近

现象:无论怎么调学习率、跑多少轮,测试集准确率稳定在0.5,损失曲线看着还在降。

原因:标签没映射到±1,用的是0/1。前面说过,0/1标签下两类误差不对称,模型学到的超平面整体偏移,正好把数据切错。另一个可能是权重全零初始化,模型只学偏置,输出恒为常数。

解决:在dataset.py里显式做y = np.where(y == 0, -1, 1),并在训练前打印np.unique(y)确认只有-1和1。初始化用random_state加小方差正态分布,别用np.zeros。这两个点检查完,准确率基本就上来了。

5.4 同一份数据两次运行结果不一样

现象:不改代码、不改数据,跑两次train.py,准确率差几个百分点,损失曲线形状也不同。

原因:random_state没固定,或者代码里某处用了全局np.random而不是RandomState实例。全局随机会被其他调用污染,比如matplotlib内部或者数据打乱操作,导致每次的初始权重不同。

解决:所有随机性统一从self.random_state派生的rgen出,禁止裸调np.random.normal。数据划分也用train_test_split(..., random_state=42)固定。固定种子之后结果可复现,调参时才能判断性能变化是参数带来的还是随机波动。

5.5 训练集准确率很高,测试集掉一截

现象:训练集能到0.98,测试集只有0.8左右。

原因:特征维度相对样本量偏高,或者数据划分时没有分层,某一类在训练集里占比过少;也可能是标准化用了全体数据的均值方差,把测试集的信息泄漏进来了。

解决:标准化只在训练集上fit,用训练集的均值和方差去变换测试集,这是标准做法,很多实验代码图省事在全量数据上算一次,评估结果会虚高。数据划分加stratify=y保证两类比例一致,样本量少于几百时尤其要做。

提示:排查顺序建议是「数据 → 初始化 → 学习率 → 划分」,从便宜到贵。数据问题一眼能看出来,改代码的时间成本最高,别一上来就怀疑公式写错。

6. 二次开发:把实验脚本改造成能复用的估计器

实验代码和工程代码的差距,通常不在算法本身,而在接口和可观测性。课程包里的Adaline能跑,但你想拿它去接网格搜索、想加早停、想扩到多分类,就得动几处结构。我的改法一般分三步走,代价不大,收益很明显。

第一步,补齐sklearn风格的接口约定:fit返回self(原代码已经做了),加get_params/set_params,这样GridSearchCV能直接扫eta和n_iter。第二步,加一个早停开关,fit里每轮比较losses_[-1]和losses_[-2],连续若干轮改善小于阈值就break,省掉无意义的 epoch。第三步,把predict里的二分类符号函数抽成策略,用 one-vs-rest 循环套一层就能处理多分类。

改造点原实现改造后收益
参数接口构造器位置参数get_params/set_params可接GridSearchCV
停止条件固定n_iter轮早停 + 最大轮数双约束省训练时间,防过拟合
分类策略硬编码±1符号OVR 包装器支持多分类
日志终端打印结构化字典 + 可选文件结果可追溯
def fit(self, X, y, tol=1e-4, patience=5): # ... 初始化部分不变 ... best = float('inf') wait = 0 for epoch in range(self.n_iter): # ... 前向与更新部分不变 ... self.losses_.append(loss) if loss < best - tol: best, wait = loss, 0 else: wait += 1 if wait >= patience: # 连续 patience 轮没改善就停 break return self

tol是判定「有改善」的最小幅度,设太小会被浮点噪声触发不了早停,设太大又会在损失还明显下降时就停,1e-4在标准化后的数据上比较稳。patience给 5 轮缓冲,避免一次波动就误判。这两个值不必写死,挂到构造器上作为超参数更好。

验证改造是否成功,我习惯用两个快速检查:一是同一份数据下改造前后的w_差距在1e-8以内,说明算法逻辑没被改坏;二是用GridSearchCV扫三组eta,确认每组都能跑完不报错,说明接口约定对上了。这两步过了,再拿去接真实数据才放心。

从那以后我每次改这类实验代码,都强制先把「数据是否标准化、标签是否±1、随机种子是否固定」这三项打印出来,确认无误再调参。吃过太多次「参数调到怀疑人生,结果是数据没处理」的亏。希望这份拆解能帮你少走几段弯路,代码跑通之后,真正值得花时间的是那几行更新公式背后的几何直觉。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

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