利用神经网络拟合一元线性方程
在现代人工智能技术中,神经网络通常被应用于复杂的非线性问题,如图像分类、语音识别和自然语言处理。然而,即便是简单的数学关系,像一元线性方程(y = ax + b)
的拟合,对于理解神经网络的基本原理、训练过程及其局限性会是很好的学习案例。
本文将探索如何利用神经网络来拟合这样一个简单的线性方程。我们将从零开始搭建一个基础网络,通过逐步训练,观察网络如何学习到输入与输出之间的线性关系。通过这个简单的示例,我们不仅可以验证神经网络的学习能力,还可以揭示一些训练过程中的常见挑战和优化技巧。
选择拟合的目标方程是:

目标
- 对可视化探索、模型构建、模型训练、模型测试有一个全程直观了解。
- 观察学习率、epoch调整对结果训练结果带来的影响。
数据构建
先来手动制作一批训练数据,以便为神经网络提供可用于拟合的样本数据。可以快速编写一个小脚本,生成 100 对 (x, y) 数据点,并将其保存在 train.csv
文件中。我们选择了一元线性方程 y = ax + b
的形式,后续构建神经网络及训练的目标是,通过训练逐步学习到系数 a
和 b
的真实值。
在实验环境中,csv文件格式简洁明了、读取方便,适合快速验证实验。但在生产环境中,数据的来源通常更为复杂,甚至需要与大数据部门对接,数据存储在数据仓库、对象存储、数据库中,查询和存取机制更复杂。
在深度学习中,数据往往是复杂度和工作量的最大来源,也是模型成功与否的关键。数据的质量直接决定了模型的表现,如果数据本身没有任何价值或代表性,那么再多的努力和优化也无济于事。因此,数据不仅仅是模型训练的输入,更是整个深度学习过程的核心环节。
# 生成准备y = 2x + 1数据
import numpy as np
# 生成输入数据,在[-10, 10]范围内生成100个数据点
x = np.linspace(-10, 10, 10)
# 生成目标输出数据
y = 2 * x + 1
# 保存数据到CSV文件
data = np.array([x, y])
data = data.T # 转置
np.savetxt('train.csv', data, delimiter=',', header='x,y', comments='')
print("数据保存成功!")
打开train.csv文件,看看数据长什么样,有个直观感觉。

数据探索与可视化
现在开始构建神经网络,以拟合数据集中未知的关系。尽管我们清楚这个数据集是由一元线性方程生成的,但为了模拟实际情况,我们要有意地“忘掉”这一点。假设我们面对的是一个完全未知的数据集,不清楚其中的模式或潜在规律,这就要求我们不能直接进入模型构建环节,而是先对数据进行全面的探索和分析。
面对未知的数据集,首先需要从整体和外围着手,全面了解数据的分布、趋势和特征。这样不仅可以避免盲目开始,也能为我们选择合适的模型结构提供依据。这个探索过程涉及数据的多层次观察和对潜在模式的初步推测,既考验对数据的理解,也要求对模型选择有经验积累,而非简单地选择某一网络结构就开始训练。
利用可视化手段开始探索步骤,对数据集有个大局了解。
import matplotlib.pyplot as plt
import numpy as np
# 读取data.csv文件到本地变量x和y中
data = np.loadtxt('data.csv', delimiter=',', skiprows=1)
x = data[:, 0]
y = data[:, 1]
# 可视化数据
plt.scatter(x, y, color='green')
plt.xlabel('x')
plt.ylabel('y')
plt.title('data exploit')
plt.legend()
plt.show()
数据绘制如下:

选择合适的模型复杂性是深度学习建模的重要一步。在初步的数据可视化中,如果我们观察到 (x, y)
之间显著的线性关系,那么使用简单的线性模型可能就是一个理想的起点。这意味着可以选择一个无隐藏层的单层神经网络,也就是我们常说的线性回归模型。这样的模型结构简单,能够直接拟合线性关系,且不会引入不必要的复杂度,是对线性关系数据的一种高效、直接的处理方式。如果数据稍微复杂一点,但仍主要表现为近线性趋势,可以加入一个隐藏层用于初步实验。
模型构造
选用的模型结构如下:

# 定义模型方法,简单线性模型,无隐含层
class LinearModel(nn.Module):
def __init__(self):
super(LinearModel, self).__init__()
self.linear = nn.Linear(1, 1) # 只有输出层
def forward(self, x):
return self.linear(x)
相反,如果数据探索和可视化揭示出显著的非线性特征,单层线性模型可能无法有效拟合数据,这时就需要考虑更复杂的模型结构。可以通过引入隐藏层并使用非线性激活函数(如 ReLU、tanh 等)来增强模型的表达能力,使其能够捕捉数据中的非线性模式。
对于隐藏层,一是可以适应复杂性,简单的线性模型只能拟合完全的直线关系。如果数据表现出轻微的非线性趋势或噪声,增加一个隐藏层(例如用 ReLU 或其他激活函数)能给模型一些灵活性,使其能够更好地捕捉这些偏差;二是应对噪声,隐藏层能够增强模型对数据的鲁棒性,尤其是当数据中存在随机噪声或微小的非线性模式时。
增加隐藏层和激活函数的网络结构使得模型不再局限于线性映射,而是具备更强的灵活性和适应性,能够拟合出复杂的非线性关系。
线性模型和非线性模型,从简单的直观知觉就是是否添加隐藏层和非线性激活函数,可以先有这么个直觉经验来理解。
模型训练
思绪再回到主线来,训练要开始了,优化器选择Adm、损失函数选择均方差MSE,对优化器、损失函数、超参值的选择,先不过多纠结,可以理解为基于基本经验,本篇主旨在于将神经网络训练过程有个全面的了解,至于超参的调优选择,后续篇章会逐一讨论。

学习率从0.001开始、权重初始化值为0.5、偏置初始化值为0.1(需要注意,手动初始化权重的设置并不是一个好的方式,这里为了控制变量,防止自动权重初始化的随机性干扰训练效果 )。
# 初始化模型
model = LinearModel()
# 固定初始权重和偏置,例如权重为 0.5,偏置为 0.1
with torch.no_grad():
model.linear.weight.fill_(0.5)
model.linear.bias.fill_(0.1)
# 输出初始权重和偏置
print("Initial weights:", model.linear.weight.data)
print("Initial bias:", model.linear.bias.data)
# 定义损失函数和优化器
criterion = nn.MSELoss()
optimizer = optim.Adam(model.parameters(), lr=0.001)
# 训练前评估模型
initial_loss = criterion(model(x), y).item()
print(f"Initial Loss: {initial_loss}")
# 用于存储每个epoch的损失值
losses = []
# 训练模型
num_epochs = 50
for epoch in range(num_epochs):
model.train()
optimizer.zero_grad()
outputs = model(x)
loss = criterion(outputs, y)
loss.backward()
optimizer.step()
# 记录损失
losses.append(loss.item())
print(f"Epoch {epoch + 1}, Loss: {loss.item()}")
初步实验从epoch=50开始,观察损失值,从下图控制台日志输出可以看到,loss有下降趋势,但是下降速度还不是那么快,自然有两个想法,一是增加epoch次数,二增加学习率,加速收敛效率。

先尝试增加学习率,默认学习率的是0.001,将它调整到0.01,保持其他参数不变,可以看到,通过改变学习率,整个收敛速度得到显著提升。
optimizer = optim.Adam(model.parameters(), lr=0.01)

再来看看不改变学习率,而只增加epoch的效果,将epoch从50提升到500。
num_epochs = 500
可以看到,通过增加epoch次数,也可以将误差值降低下来。

最终的Final Loss结果出来了,对于这个拟合任务来看,这个结果误差还是太大。
但是对于误差结果的解读,没有一个前篇一律的答案,需要具体问题具体分析,这并不是一句套话。误差值的好坏不能单凭数字大小判断,必须结合具体场景分析。例如对于误差0.77的结果:
- 如果是一个简单的猫狗分类任务,0.77 的 loss 可能还不够好,但在复杂的多类别任务中,这个值可能已经很不错了。
- 在一些任务中,最终的评价指标(如准确率、BLEU 等)比 loss 更重要,loss 低不一定代表效果好。
- 如果当前 loss 已经满足业务需求,就可以停止训练;如果目标精度更高,则需继续优化。
- 如果目标值的量级较大,例如预测房价,数值通常在几十万,那么 loss 为 0.77 说明误差已经很小,模型表现不错。但如果目标值是归一化后的概率,范围在 0 到 1 之间,那么 0.77 的 loss 显得偏大,说明预测误差还较高。
拉回正题,目前的误差还是36.9,可以通过可视化工具,看看误差的整体趋势,再来决定后续优化路径。
# 绘制损失曲线
plt.plot(range(1, num_epochs + 1), losses, label='Loss')
plt.xlabel('Epoch')
plt.ylabel('Loss')
plt.title('Loss During Training')
plt.legend()
plt.show

可以看到,整体上还是呈显著下降趋势,并没有出现误差的反弹或停滞不前,还有收敛的空间。
我们同时调整学习率到0.01,并将epoch增加到500轮,误差快速下降。

通过观察误差曲线,可以看到收敛逐渐趋于平稳,Loss值接近0值。

如果还想再看更小的误差,可以将学习率降低,提高轮次,不过目前的误差值已经足够的小了,符合要求。打印权重和偏置的值,权重和偏置正是 y = 2x + 1 的参数值。
# 输出训练后的权重和偏置
weights = model.get_weights()
print("Final weights:", weights[0])
print("Final bias:", weights[1])

将模型保存到本地,用于测试验证。
# 保存模型
torch.save(model.state_dict(), 'linear_model.pth')
print("Model saved successfully!")
测试验证
接下来对测试集上的验证效果,如果测试集误差也小,则符合条件,模型可用;如果测试集上的验证误差偏大,则可能是模型过拟合,就要解决过拟合情况。
# 加载数据
x, y = load_data('test.csv')
x = torch.tensor(x, dtype=torch.float32).view(-1, 1)
y = torch.tensor(y, dtype=torch.float32).view(-1, 1)
# 加载模型
model = LinearModel()
model.load_state_dict(torch.load("linear_model.pth", weights_only=True))
criterion = nn.MSELoss()
# 训练后评估模型
model.eval()
final_loss = criterion(model(x), y).item()
print(f"Test final loss: {final_loss}")
在测试集上进行验证,测试集给了10对数据点,存储在test.csv中,可以看到,每个样本的误差和总测试集误差都很小,符合预期,说明测试集偏差小,模型效果较好。

至此,线性方程 y=2x+1
的拟合就完成了。
总结
我们对陌生数据集进行了可视化探索,发现线性关系,构建了简单的线性模型结构,手动初始化权重、偏置后开始训练。通过调整学习率观察到对模型收敛效率的影响,同时观测损失曲线,辅助判断收敛是否触达最小值,并配合调整学习轮次epoch,逐步收敛到达到目标。可以看到,学习率、epoch是一个实验性较强的参数,需要在实验过程中实时观测并做相应调整。