☰
神经网络实验1从零实现:前向传播、反向传播与调参实战
2026/10/14 12:46:22 网站建设 项目流程

简介:面向神经网络导论课程学习者,这份压缩包提供Adaline(自适应线性单元)模型LMS算法的Matlab实现代码,涵盖随机权重初始化、核心训练流程与数据集随机选取,帮助理解早期神经网络的工作原理。资源包共5个文件,含3个.m脚本和2个.mat数据文件,整体仅10KB,结构紧凑。脚本分别实现带随机初始化的adaline训练、标准LMS权重更新以及样本随机选择功能;mat数据文件存储训练集与测试集样本,便于直接加载验证模型泛化性能。实验聚焦LMS算法的误差修正过程,如Δw=η(d-y)x的更新规则,并强调权重初始化对收敛结果的影响;覆盖Adaline模型结构、LMS算法原理、随机权重初始化、数据集划分及Matlab编程实现五大知识点。目前已有300人学习浏览,通过运行与调试这些代码,可掌握Adaline模型训练的关键步骤,学会如何划分训练/测试数据以评估模型效果,并可将该思路迁移到感知器、BP网络等更复杂的模型中,是神经网络入门阶段不可多得的实用参考。

1. 神经网络导论课程实验1代码到底要做什么:从公式到能跑通的最小训练闭环

神经网络导论课程实验1代码,通常不是让你去微调某个现成大模型,而是要求你从零实现(或近乎从零实现)一个最简单的神经网络,在给定或自造的小数据集上完成训练与预测。这个实验的核心价值在于:它把课堂上讲的前向传播、反向传播、梯度下降这些名词,一次性变成一条真实下降的损失曲线。适合刚学完神经网络基础、还没有独立写过训练循环的同学,也适合从业者用来验证自己对链式法则的理解是否经得起代码考验。这篇文章按我平时带实验的思路,把实验1从选型、写代码、调参到排错完整拆开讲。

2. 实验1的两种常见选型:手写 NumPy 还是直接上框架

2.1 为什么实验1通常要求手写:理解优先于效率

实验1作为「神经网络导论」的第一个动手环节,重点本来就不是性能,而是把网络结构、前向计算、误差反传这三件事用代码复述一遍。常见做法是限制只用 NumPy,不允许直接调框架的自动求导。原因很直接:如果一上来就model.fit(),你根本看不到梯度是如何从损失函数流回每一层参数的。手写反向传播时,每一行矩阵运算都对应课堂上的一个公式,哪怕写得慢,写完以后对维度匹配、链式法则、梯度消失的体感都会完全不一样。

我一般会把选型分成两个层级:

层级允许使用的库工作量适合对象
A标准库 + NumPy较大想彻底弄懂底层机制的初学者
BNumPy + Matplotlib中等需要可视化损失曲线和决策边界的场景

如果你的课程允许用框架,我的建议是:第一遍仍然手写 NumPy 版本跑通,第二遍再用框架复现同一组数据,对比两者的训练曲线。这样做的收获比单纯跑通实验大得多,尤其是「手写版本收敛慢、框架版本收敛快」这个对比,能让你直观体会到自动求导和底层优化的价值。

2.2 实验1的经典任务:二维二分类与异或问题

实验1最常见的数据是二维平面上的两类点,或者教科书里经典的异或(XOR)问题。选 XOR 是有讲究的:它线性不可分,单层感知机永远学不出来,必须引入隐藏层和非线性激活函数。这个「学不出来」本身就是实验要你体会的核心现象——同样是训练,为什么单层不行、多层就行。

我建议数据用 NumPy 直接构造,而不是下载外部数据集。两个原因:一是数据量小(几十到几百个点),可视化方便,可以直接画出决策边界;二是数据是自己生成的,你清楚真实分布,验证模型是否学到东西非常直观。生成二分类数据的典型做法是这样:

import numpy as np import matplotlib.pyplot as plt # 固定随机种子,保证结果可复现 np.random.seed(42) # 类别0:以 (0.3, 0.3) 为中心的高斯点 cls0 = np.random.randn(50, 2) * 0.15 + np.array([0.3, 0.3]) # 类别1:以 (0.7, 0.7) 为中心的高斯点 cls1 = np.random.randn(50, 2) * 0.15 + np.array([0.7, 0.7]) X = np.vstack([cls0, cls1]) y = np.array([0] * 50 + [1] * 50).reshape(-1, 1) plt.scatter(X[:50, 0], X[:50, 1], label="class 0") plt.scatter(X[50:, 0], X[50:, 1], label="class 1") plt.legend() plt.savefig("data_dist.png", dpi=120)

逻辑说明:randn生成标准正态分布随机数,* 0.15控制类内离散程度,+ 中心坐标控制类中心位置。reshape(-1, 1)把标签从一维数组变成列向量,这一步是为了让后续矩阵运算的维度直接匹配。np.random.seed(42)是新手最容易漏的一行——不固定种子,每次跑出来的数据和实验结果都不一样,后面调参时根本分不清效果变化是来自参数改动还是随机波动。

2.3 数据预处理:标准化与训练测试划分

数据生成之后不要直接喂给网络。二维高斯数据尺度相近可能问题不大,但换成真实数据集时,特征之间的量纲差异会让梯度更新不稳定。常见做法是做标准化,把每个特征缩放到均值 0、方差 1:

# 标准化:均值0方差1,加1e-8防止除零 mean = X.mean(axis=0) std = X.std(axis=0) X = (X - mean) / (std + 1e-8) # 按7:3划分训练集和测试集 idx = np.random.permutation(len(X)) train_idx, test_idx = idx[:70], idx[70:] X_train, X_test = X[train_idx], X[test_idx] y_train, y_test = y[train_idx], y[test_idx]

标准化里加1e-8是防御性写法:万一某个特征是常数,标准差为 0,直接除会得到无穷大,后面所有计算都跟着报废。np.random.permutation是随机打乱索引,用打乱后的索引划分训练测试集,避免原始数据顺序里类别扎堆。7:3 的划分比例在实验1场景下比较稳妥:测试集太小,评估结果的随机性会很大,调参时容易被噪声误导。

3. 核心代码实现:前向传播、反向传播与训练循环

3.1 网络结构定义:一个隐藏层的 MLP

实验1的网络结构通常不超过一个隐藏层。原因很实际:一个隐藏层已经足以逼近任意连续函数(这是逼近定理告诉我们的),也足以演示反向传播的全部关键步骤。我习惯用 2-4-1 结构:输入 2 个特征,隐藏层 4 个神经元,输出 1 个神经元。隐藏层用 Sigmoid 激活,输出层也用 Sigmoid,配合二分类的交叉熵损失。

class SimpleMLP: def __init__(self, n_input=2, n_hidden=4, n_output=1): # 输入层到隐藏层的权重和偏置 self.W1 = np.random.randn(n_input, n_hidden) * np.sqrt(1.0 / n_input) self.b1 = np.zeros((1, n_hidden)) # 隐藏层到输出层的权重和偏置 self.W2 = np.random.randn(n_hidden, n_output) * np.sqrt(1.0 / n_hidden) self.b2 = np.zeros((1, n_output)) def sigmoid(self, z): # 数值稳定版Sigmoid,避免exp溢出 return 1.0 / (1.0 + np.exp(-np.clip(z, -30, 30))) def forward(self, X): # 前向传播:逐层计算并缓存中间结果 self.z1 = np.dot(X, self.W1) + self.b1 self.a1 = self.sigmoid(self.z1) self.z2 = np.dot(self.a1, self.W2) + self.b2 self.a2 = self.sigmoid(self.z2) return self.a2

参数说明:randn产生标准正态分布随机数,乘以sqrt(1.0 / n_input)是为了控制初始权重量级,避免信号在逐层传播中放大或衰减。偏置初始为 0 在实践上是安全的,不需要特殊处理。Sigmoid 里的np.clip(z, -30, 30)是数值稳定性的关键:当z是一个很大的负数时,np.exp(-z)可能溢出为无穷大,导致结果为 nan。forward里用self.z1、self.a1缓存中间结果,是因为反向传播时还需要用到它们计算梯度,这是手写反向传播的标准做法。

3.2 反向传播:链式法则的代码落地

反向传播是实验1的核心,也是最容易写错的地方。从损失函数对输出层的梯度开始,逐层往回推。这里用二元交叉熵损失配合 Sigmoid 输出,有一个经典简化:损失对输出层加权输入z2的梯度恰好等于a2 - y。建议你自己动手推一遍这个式子再写进代码,印象会深很多。

def backward(self, X, y, lr=0.1): m = X.shape[0] # 样本数量 # 输出层梯度:交叉熵 + Sigmoid 的简化结果 dz2 = self.a2 - y # 隐藏层到输出层的权重梯度 dW2 = np.dot(self.a1.T, dz2) / m db2 = np.sum(dz2, axis=0, keepdims=True) / m # 误差反向传播到隐藏层 da1 = np.dot(dz2, self.W2.T) dz1 = da1 * self.a1 * (1 - self.a1) # Sigmoid导数:a*(1-a) dW1 = np.dot(X.T, dz1) / m db1 = np.sum(dz1, axis=0, keepdims=True) / m # 梯度下降更新参数 self.W2 -= lr * dW2 self.b2 -= lr * db2 self.W1 -= lr * dW1 self.b1 -= lr * db1

逐行说明。dz2 = self.a2 - y是输出层残差,形状为 (m, 1),它同时包含了损失函数和 Sigmoid 激活函数的导数信息。dW2 = np.dot(self.a1.T, dz2) / m中的self.a1.T是隐藏层输出的转置,与dz2做矩阵乘法得到权重梯度;除以m是取平均,对应损失函数中求和再取均值的约定。da1 = np.dot(dz2, self.W2.T)把输出层残差沿权重传回隐藏层,再乘以 Sigmoid 的导数a1 * (1 - a1),这就是链式法则中梯度逐层传递的具体实现。

这里最容易犯的错误是忘记除以m。如果不除,梯度会随着批大小线性放大,学习率必须跟着数据量调整,否则训练必然震荡。统一除以样本数之后,学习率的含义和批大小解耦,调参时省很多事。

3.3 训练循环与损失记录

有了前向和反向,训练循环就是把两者串起来,并记录每轮的损失,方便后面画曲线判断训练状态。

def train(model, X_train, y_train, X_test, y_test, epochs=500, lr=0.1): train_loss_history = [] for epoch in range(epochs): # 前向传播得到预测值 y_pred = model.forward(X_train) # 二元交叉熵损失,加1e-8防止log(0) loss = -np.mean(y_train * np.log(y_pred + 1e-8) + (1 - y_train) * np.log(1 - y_pred + 1e-8)) # 反向传播更新参数 model.backward(X_train, y_train, lr) train_loss_history.append(loss) # 每50轮在测试集上评估一次 if epoch % 50 == 0: test_pred = model.forward(X_test) test_acc = ((test_pred > 0.5).astype(int) == y_test).mean() print(f"epoch {epoch}: loss = {loss:.4f}, " f"test acc = {test_acc:.4f}") return train_loss_history

训练循环的逻辑不复杂,但有两个习惯值得养成。第一,交叉熵损失里加1e-8:预测值经过 Sigmoid 后可能非常接近 0,直接取log会得到负无穷,加上极小值就安全了。第二,训练过程中周期性评估测试集准确率,而不是训练结束才看,这样能尽早发现过拟合或训练不收敛的问题。打印日志用:.4f格式化,保证输出可读。训练结束后,强烈建议把损失曲线画出来,损失下降说明梯度计算大概率是对的。

4. 实验1的四个必调参数:从玄学到有章法

4.1 权重初始化:量级决定训练成败

实验1里最容易被忽视但影响最大的参数是权重初始化的量级。初始权重如果太大,Sigmoid 的输入落在饱和区,梯度接近 0,网络几乎学不动;如果太小,梯度信号在反向传播中逐层衰减,同样学不动。常见做法是把初始化量级和网络层输入维度挂钩,也就是前面代码里用的sqrt(1.0 / n_input)。

这个缩放系数来自一个简单直觉:多个输入加权求和后,方差会随输入维度累加,除以输入维度的平方根能把方差拉回 1 附近。这样初始时每个神经元都处于激活函数的敏感区间,梯度能有效回传。实验1的数据维度小,初始化问题表现不明显,但养成这个习惯以后,做更大网络时能少踩很多坑。

4.2 学习率:梯度下降里最敏感的旋钮

学习率是整个实验里最需要调、也最容易让人挫败的参数。常见现象是:学习率设 0.5,损失曲线剧烈震荡;设 0.001,训练 500 轮损失还在高位慢慢爬。实验1我建议的范围是 0.01 到 0.3,先试 0.1,观察损失曲线形态再决定方向。

学习率观察到的现象处理方式
0.5 及以上损失震荡或直接发散降低学习率,尝试 0.1
0.1损失平滑下降保持,训练后期可考虑衰减
0.01损失下降缓慢可用,但需要更多轮数

判断学习率是否合适的核心方法是看损失曲线形态:平滑下降说明偏小但可用;快速下降后趋于平稳说明比较合适;像心电图一样上下跳动说明偏大;直接变 nan 说明学习率过大或数值不稳定。实验1不需要学习率衰减这种进阶技巧,把固定学习率调对就够了。

4.3 迭代轮数与批大小:小数据集可以粗犷一些

实验1的数据量很小,常见做法是使用全批量梯度下降,每次更新都用全部训练数据。这样梯度方向稳定,损失曲线平滑,方便观察学习率是否合适。轮数方面,几百轮足够看到收敛趋势,跑 1000 轮也不会花多少时间,但要留意过拟合信号:训练损失持续下降而测试准确率不再上升,说明模型开始记忆训练样本了。

4.4 随机种子:让实验可复现的唯一办法

实验1有一个尴尬场景:同样代码跑两次,结果不一样。原因在于随机初始化和数据生成都用了随机数。如果不在代码开头固定随机种子,你分不清实验效果的变化来自参数调整还是随机波动。我的习惯是每个实验脚本第一行就写np.random.seed(42)。这行代码不参与任何计算,但决定了你后续所有调试是否有意义。

5. 实验1避坑指南:新手最容易翻车的五个细节

5.1 损失不降反升,甚至直接变成 nan

现象:训练刚开始损失正常下降,几轮之后突然跳成 nan,或者从一开始就上升。

原因:最常见的是学习率过大,参数更新越过了最优区域,损失震荡发散。另一个可能是交叉熵里log(0)计算出无穷大,但这种情况通常一开始就出现 nan,而不会中途出现。

解决:先把学习率从 0.1 降到 0.01 试一次;同时在损失计算中加1e-8防止log(0)。如果还是 nan,检查反向传播里是否有除零操作,特别是除以样本数m时确认m不为 0。

5.2 训练集准确率高、测试集准确率明显偏低

现象:训练集准确率 95%,测试集只有 70% 左右。

原因:过拟合。实验1数据量小、网络简单,过拟合本不该严重,但如果轮数跑太多,或数据噪声比较大,模型会把噪声也记住。

解决:减少训练轮数,观察测试准确率什么时候开始不再上升;或者把高斯点数量从 50 提到 200,增大训练数据量。实验1引入正则化有点重,优先从前两者入手。

5.3 反向传播矩阵维度不匹配

现象:报错信息类似shapes (50,4) and (2,4) not aligned,看到这种报错就懵。

原因:前向传播时输入维度每层都在变化(比如从 50x2 变成 50x4 再变成 50x1),反向传播时需要对应使用前一层的输出及其转置。很多同学在写dW1 = np.dot(X.T, dz1)时把X.T和dz1的位置弄反了。

解决:写反向传播之前,先把每层输入输出形状写在注释里。我自己的习惯是画一张维度流转表,前向时 (m, 2) -> (m, 4) -> (m, 1),反向时 (m, 1) -> (m, 4) -> (m, 2),对照着写公式基本不会错。

5.4 换一次随机种子结果就变,无法判断改动是否有效

现象:同一份代码,种子从 42 改成 0,准确率从 90% 变成 80%,你以为是代码出了问题。

原因:数据划分和权重初始化都依赖随机数,不同种子导致不同的数据分布和初始参数。小数据量下这种波动非常正常。

解决:调参过程中固定一个种子,才能对比不同参数的效果。固定后波动依然很大,说明数据量太少,增加样本或用交叉验证评估。

5.5 决策边界画出来是直线而不是曲线

现象:模型能收敛,但决策边界是一条直线,怎么都分不开两类数据。

原因:大概率是隐藏层激活函数写错了,或者隐藏层神经元太少。另一个常见原因是网络退化成了单层——比如把激活函数写成了线性函数,多层叠加后仍然等价于一层。

解决:检查forward中隐藏层是否真的用了 Sigmoid,确认权重形状是否正确。把隐藏层神经元从 4 加到 8,通常边界会变得更平滑。加了神经元还是直线,基本可以断定激活函数那行代码写成了恒等映射。

6. 实验1的验证与延伸:画图确认学到了,再动手做三件事

图形化验证是整个实验1最值得花时间的一步。损失曲线下降只证明「训练在发生」,不能证明「模型学到了」。在二维数据上画出决策边界,用 0.02 步长在平面上铺网格,把每个点的预测概率用等高线画出来,你立刻能看到模型的判断边界在什么位置。

# 绘制决策边界:网格上逐点预测并画等高线 x_min, x_max = X[:, 0].min() - 0.1, X[:, 0].max() + 0.1 y_min, y_max = X[:, 1].min() - 0.1, X[:, 1].max() + 0.1 xx, yy = np.meshgrid(np.arange(x_min, x_max, 0.02), np.arange(y_min, y_max, 0.02)) grid = np.c_[xx.ravel(), yy.ravel()] probs = model.forward(grid)[:, 0].reshape(xx.shape) plt.contourf(xx, yy, probs, levels=20, cmap="RdBu", alpha=0.6) plt.scatter(X[:, 0], X[:, 1], c=y.ravel(), cmap="RdBu", edgecolor="k") plt.savefig("decision_boundary.png", dpi=120)

决策边界画出来后,你要验证三件事:第一,边界是否大致分开了两类点,如果是,说明训练闭环是通的;第二,两类之间的过渡带是否平滑,过渡带越宽说明模型不确定性越大;第三,边界附近是否有孤立的错误点,这些通常靠近噪声,不必过分在意。

验证通过后,我建议往三个方向延伸。方向一是把 Sigmoid 换成 tanh 或 ReLU,观察收敛速度和边界形状的变化。方向二是把隐藏层神经元从 4 改到 16,看训练误差和测试误差的差距是否变大,直观体会过拟合。方向三是把交叉熵损失换成均方误差,你会发现收敛明显变慢,这个对比能让你真正理解为什么分类任务用交叉熵。

最后说一个我带实验时反复强调的习惯:每次改代码只动一个地方,改完就跑一次并记录结果。实验1代码量不大,很多人喜欢同时改学习率、层数和激活函数,出了问题根本定位不到是哪一步导致的。我当年调 XOR 实验的初始权重时,同时改了初始化方式和层数,折腾了一整晚才发现是初始化的问题。从那以后,所有调参实验我都坚持一次只动一个变量。这个习惯对后续课程和实际项目帮助很大,希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询