利用神经网络拟合一元二次方程

我们这次要拟合的关系是一个一元二次方程

目标

  • 理解参数初始化对于模型效果的重要影响。
  • 学习模型训练过程中,如何通过观察损失值相应的调整学习率、epoch大小。

数据构建

这次我们在准备数据的时候,就将数据进行划分好,总训练集100个,训练集90个,测试集10个。

# 生成准备y = x^2 + 2x + 1数据,共100个点,并按9:1的比例划分为训练集和测试集,并保存到CSV文件
​
import numpy as np
​
from sklearn.model_selection import train_test_split
​
# 生成输入数据
x = np.linspace(-10, 10, 100) # 在[-10, 10]范围内生成100个均匀分布的点
​
# 生成目标输出数据
y = x**2 + 2 * x + 1
​
# 划分为训练集和测试集
x_train, x_test, y_train, y_test = train_test_split(x, y, test_size=0.1, random_state=42)
​
# 保存训练集数据到CSV文件
train_data = np.column_stack((x_train, y_train))
​
np.savetxt('train.csv', train_data, delimiter=',', header='x,y', comments='')
​
# 保存测试集数据到CSV文件
test_data = np.column_stack((x_test, y_test))
​
np.savetxt('test.csv', test_data, delimiter=',', header='x,y', comments='')
​
print("数据保存成功!")

训练集准备好之后,得到train.csv和test.csv两个数据集,分布做训练和测试验证用。

现在开始构建神经网络,以拟合数据集中未知的关系。尽管我们清楚这个数据集是由一元二次方程生成的,但为了模拟实际情况,我们要有意地“忘掉”这一点。假设我们面对的是一个完全未知的数据集,不清楚其中的模式或潜在规律,这就要求我们不能直接进入模型构建环节,而是先对数据进行全面的探索和分析。

现在开始实验,此时我们面对的是(x,y)的数据点,但是并不知道他们的关系,目标是构建一个神经网络来拟合潜在关系。

数据探索与可视化

老规矩,先进行数据探索和数据可视化。

import matplotlib.pyplot as plt
​
# 读取data.csv文件到本地变量x和y中
import numpy as np
​
data = np.loadtxt('train.csv', delimiter=',', skiprows=1)
x = data[:, 0]
y = data[:, 1]
​
# 可视化数据
plt.scatter(x, y, color='green', label='data-exploit')
plt.xlabel('x')
plt.ylabel('y')
plt.title('data exploit')
plt.legend()
plt.show()

可以看到,(x,y)的关系并不是一个线性或近线性关系,因此用在构建模型的时候,不能直接用线性模型进行构建,而需要引入非线性部件,也就是隐含层和激活函数。

模型构造

# 定义模型方法,简单线性模型,无隐含层
def build_linear_model():
    model = keras.Sequential([
        layers.Dense(1, input_shape=(2,))  # 只有输出层
    ])
    return model

模型训练

为了能够清晰看到每轮epoch误差的走势,我们将误差进行可视化,按照理想的情况,误差应该是逐步降低,并且趋于一个较小的值。

在图中可以看到,轮次在1000左右,误差开始区域平稳,并没有下降了,而误差大小却还是900多,这也验证了在数据探索时看到的,这个数据集并不是一个简单的线性关系,线性模型无法很好的拟合,表现出来也是误差偏大

既然简单的线性模型误差偏大,那么就需要增加模型复杂度,增加隐藏层和激活函数,来拟合非线性关系

# 定义模型方法,包含隐含层,用pytorch的模型方法来定义
class LinearModel(nn.Module):
  def __init__(self):
    super(LinearModel, self).__init__()
    self.layer = nn.Sequential(
      nn.Linear(1, 16),
      nn.ReLU(),
      nn.Linear(16, 1)
    )
​
    def forward(self, x):
      return self.layer(x)
​
# 固定初始权重和偏置,例如权重为 0.5,偏置为 0.1
with torch.no_grad():
  model.layer[0].weight.fill_(0.5)
  model.layer[0].bias.fill_(0.1)
  model.layer[2].weight.fill_(0.5)
  model.layer[2].bias.fill_(0.1)

选择隐藏层中的单元(神经元)数量确实是一个重要的超参数,直接影响模型的表达能力和训练效果。没有固定的公式来选择最优的神经元数量,但可以考虑以下几方面来做出合理的选择:

  • 简单任务:通常从 8、16 或 32 开始测试。如果在这些较小的神经元数量上表现良好,就不需要再增加。
  • 非线性关系且复杂任务:可以尝试 32 或 64,但一般来说超过 64 会显得多余,尤其是在单层隐藏层的情况下。

逐步调试和验证

  • 选择一个小的神经元数量(如 8 或 16),观察模型的表现。
  • 更少的神经元数量意味着更少的参数,这会降低计算复杂度,提高训练速度,同时也能减少过拟合的风险。
  • 在神经元数量过多时,模型可能学到数据中的噪声和细节,导致过拟合。

因为在数据探索可视化步骤中已经看到了数据的非线性关系,因此我们从16个神经元数量开始

激活函数Relu、学习率0.001、轮次100开始训练,并观测成本的走势

可以看到Loss一直是下降趋势,并没有到达一个平缓值,也没出现抖动,我们继续增加训练轮次到500

成本在300 epoch后下降趋势变得平缓,无法继续收敛,此时的损失输出。

可以看到模型Loss已经到达瓶颈,在300多处无法再下降了,此时模型还是处于误差偏大的情况,应该增加模型复杂度,我们先考虑增加隐藏层单元数,试试效果,将16增加到32

self.layer = nn.Sequential(
  nn.Linear(1, 32),
  nn.ReLU(),
  nn.Linear(32, 1)
)

损失快速下降,并且未出现瓶颈,继续增加epoch大小到1000。

epoch增加到1000看到下降趋势仍然明显,并且毫无平缓趋势,表示还未到达最小值附近,轮次虽然增加了一倍,但损失值没有显著降低,收敛速度较慢,因此将学习率从0.001调整为0.01,加速收敛过程,看看效果。

整体变化不大,继续调整0.01到0.1,加速收敛过程,看看效果。

效果非常明显,epoch在200左右开始,损失曲线进入平滑,但是此时损失值还在300以上,虽然效果并不是很好,但是以上对于学习率的尝试,可以感受到对收敛速度的影响。

面对这个损失值趋于平缓,且值较大,会猜测是否是学习率过大,导致未能很好的收敛至最小值,或者是模型复杂度不够,无法表达出数据的复杂关系。

先来验证第一个猜想,降低学习率,并且增大epoch次数,让模型在较小的学习率上,足够轮次去学习,看看能收敛到什么效果。尝试多轮试验,在0.001的学习率,epoch为30000时,足够小的学习率和足够多的轮次,损失曲线走向平缓,但是可以看到,损失值还是在300以上,足以验证并非时学习率导致未能收敛至最小值。

数据详情

再来验证第二个猜想,增加模型复杂度,增加一个隐藏层。

self.layer = nn.Sequential(
    nn.Linear(1, 64),
    nn.ReLU(),
    nn.Linear(64, 1)
)

Loss值依然很大,单纯通过增加隐藏层单元数作用不大。

考虑继续增加模型复杂度,增加隐藏层数量,从一个隐藏层增加到两个隐藏层

self.layer = nn.Sequential(
    nn.Linear(1, 32),
    nn.ReLU(),
    nn.Linear(32, 16),
    nn.ReLU(),
    nn.Linear(16, 1)
)

损失值依然还是处于高水位。

我们通过改变学习率、神经单元数,对Loss值的改变都没什么作用,陷入沉思。

仔细思考我们的模型,会发现,模型的初始化参数时手动设置的。

# 固定初始权重和偏置,例如权重为 0.5,偏置为 0.1
with torch.no_grad():
    model.layer[0].weight.fill_(0.5)
    model.layer[0].bias.fill_(0.1)
    model.layer[2].weight.fill_(0.5)
    model.layer[2].bias.fill_(0.1)

仔细思考,会发现手动初始化参数会有一个问题,每层所有的神经单元权重和更新都是一样的,只是一个单元的复制,因此无论单元数是8,还是64,本质都只是1,模型的表达能力大大退化,无法展示出复杂的表达能力。

恍然大悟,通过随机初始权重来替代手动初始化,用最初最简单的模型结构训练,看看效果

# 使用随机初始化的方法,替代 np.full 的固定值
self.layer = nn.Sequential(
    nn.Linear(1, 64),
    nn.ReLU(),
    nn.Linear(64, 1)
)
# 注释掉手动设置参数的逻辑
# with torch.no_grad():
#     model.layer[0].weight.fill_(0.5)
#     model.layer[0].bias.fill_(0.1)
#     model.layer[2].weight.fill_(0.5)
#     model.layer[2].bias.fill_(0.1)

Final Loss效果十分明显,证实我们的猜想是正确的。我们通过自动初始化,即如He初始化或Xavier初始化

之前设置的偏重和权重数是随意设置的,偏重0.5、0.1,现在不自己设置,改为由自动生成,看看效果

很神奇的一幕出现了,Loss居然一下子到达了从323多下降到0.24,效果惊人。

正常的梯度值应该是:

某一层的平均梯度可能从最初的较大值(比如 0.1 - 1)逐渐减小到 0.01 - 0.1 左右,随着训练的进行波动幅度也逐渐缩小。

注意一个情况,即每次运行可能得到的Final Loss值会不一样,原因是模型中存在几个随机给变量,分别是:Adam动量、随机初始化权重、偏重。这些都是影响模型效果的变量,面对随机变量,只要设定一个目标值,达到目标值之后就可以将模型保存。

面对当前目标,我们认为final loss在1以下就是可以接受的范围,这里没有一个定论,而是要与你实际的场景相结合,当目前final loss = 0.2,由于我损失函数用的是mean_squared_error,0.2开根号= 0.45,即预测误差会在大约0.45范围,这个是我可以接受的范围。

测试验证

接下来就是在测试环境进行模型验证:

# 测试集验证
import torch
from nonlinear_train_pt import NonLinearModel
import numpy as np
import torch.nn as nn

# 从data.csv文件中读取数据
def load_data(file_path):
    data = np.loadtxt(file_path, delimiter=',', skiprows=1)
    x = data[:, 0]
    y = data[:, 1]
    return x, y

if __name__ == "__main__":
    # 加载数据
    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 = NonLinearModel()
    model.load_state_dict(torch.load("nonlinear_model.pth", weights_only=True))
    criterion = nn.MSELoss()
    # 训练后评估模型
    model.eval()
    final_loss = criterion(model(x), y).item()
    print(f"Test final loss: {final_loss}")

Final Loss一下退后了好几个小数点,效果十分显著。

在测试集上进行验证,注意,模型是在标准后的数据上进行训练得到的,因此测试验证时,也需要利用训练数据的标准化参数进行处理。

运行之后得到结果发现,在标准化后的数据上效果很好,但是在真实数据集上去衡量,效果却远达不到。从下图可以看到,利用标准化数据进行预测,Loss在0.00138、而在真实值上预测,却在1.22,差了很多

总结

整体思路(从简单到复杂,更简单的模型部署、推理成本更低):

数据探索可视化 -> 非线性模型 -> 2层 -> 调整轮次、学习率 ->效果不佳 -> 自动初始化 -> 效果变好 -> 继续优化,数据标准化 ->标准化测试效果好,但真实值效果不好 -> 不用标准化,增加模型层数到3层 ->效果达标。

Read more

痛风带来的思考

昨晚一罐冰啤酒下去,睡觉时就感觉脚踝隐隐发作,果然早上起床直接下不来地。跟崴脚的感觉十分相似,无法行走,只能坐在一起上滑动,公司上班也去不了了,呆呆得躺在家里,下午疼痛感加剧,整个心思都在左脚的疼痛上,没有其他任何多余的精力去关注其他事情,而此刻的阳台,乃最美人间四月天,春日的微风吹拂着阳台的花儿,温暖的阳光抛洒下来,一切都如此惬意,而我却无心欣赏。 人在健康时,生活中有好多问题,但人在不健康时,生活中只剩一个问题。 我对这句话的理解更深刻了。人是健忘的,在疫情期间、在手术期间,这种感悟其实已经很深刻了,但是病情好转之后,人还是会被日常的琐碎、工作的烦扰搅乱心绪,没有专注的去享受生活本身的美好。 幸福的秘密在于,去享受我们所拥有的,而不是顽固的去追求所没有拥有的。阳光、草木、微风,都是幸福的玩意儿,应尽情的享受。 再等两天,脚完全恢复好了,身体健健康康后,我要以更轻盈的姿态去生活,不纠结他人的看法,不执着别人的认可,关注自己的能力,享受拥有的生后。 还有一个反省,针对咖啡、酒、烟,

By 李浩

设计模式之命令模式

命令模式将“请求”封装成对象,以便使用不同的请求、队列或者日志来参数化其他对象,命令模式也支持可撤销的操作。 来解析这个定义: 1. “将一个请求封装为一个对象”,请求原本是一个方法,现在要封装成一个对象,说明要新增类来完成。 2. “可以用不同的请求对客户进行参数化”,说明是将命令对象作为参数进行传递。 3. “队列”说明需要维护命令多个命令的列表队列。 4. “撤销”说明有命令对象有undo撤销方法。 命令模式在设计模式中,算是一个比较不好理解的模式,很重要的原因是不清楚设计意图,不清楚不用这个模式前有何问题,这个模式带了哪些好处,能解决什么问题。 上一篇状态模式中,看到了状态模式抽离的是状态(属性),向上提成状态对象。有了这个基础,再来理解命令模式就相对简单了。命令模式抽离的是行为(方法),向上提成命令对象。 两者都通过“对象化”来解耦和扩展系统,但解决的问题不同: * 状态模式:处理对象内部状态驱动的行为变化。 * 命令模式:处理行为请求的封装与调度。 💡智能家居遥控器,假设我们有一个智能家居遥控器,可以控制 灯(Light) 和

By 李浩

设计模式之状态模式

状态模式允许对象在内部状态改变时改变它的行为,对象看起来好像修改了它的类。 来解析这个定义: 1. “内部状态”表明对象内部有一个属性来表示状态。 2. “内部状态改变时改变它的行为,对象看起来好像修改了它的类”说明状态改变后对象的行为发生了非常大的变化,不像是同一类的行为。 从目前的分析中似乎无法推导出状态模式的类图结构。 从实际的例子出来,来看看状态模式是如何演进而来。 💡我们有一个文档审批系统,文档有以下状态和转换: 1. 草稿(Draft) → 提交 → 待审批(PendingReview) 2. 待审批 → 批准 → 已发布(Published) 3. 待审批 → 拒绝 → 草稿 4. 已发布 → 撤回 → 草稿 从直觉出发,会使用条件语句实现需求逻辑。 public class Document { private String state = "DRAFT"; // 初始状态为草稿 public void submit(

By 李浩

设计模式之代理模式

代理模式为另一个对象提供一个替身或占位符以控制对这个对象的访问。 解析这个定义: 1. “替身”表明在客户端看来,代理类与被代理类是同一类别,对客户端来说看上去没什么区别,依然能够满足诉求。如此可以看出代理类与被代理类来自同一个超类。 2. “控制对这个对象的访问”,能够控制访问,说明前提是能够访问,才能在访问之前做这个限制,即代理持有对真实对象的引用(或能创建它)。 当然可能会质疑,用继承的方式不是也能完成目标吗,用代理类去继承被代理类,然后重写方法,加入控制逻辑。但这违背了"组合优于继承"原则,代理类与被代理类强耦合。 💡我们要开发一个图片查看器,需求如下: 1. 图片加载开销大(从磁盘或网络加载耗时),希望首次显示时才加载(延迟加载)。 2. 某些图片需要权限校验,只有授权用户才能查看。 3. 客户端代码应统一接口,无需关心是直接加载图片还是通过代理。 // 1. 抽象接口(Subject) interface Image { void display(); } // 2. 真实对象(RealSubject)

By 李浩