Chap2 Neural Networks with PyTorch

文章目录

背景

接着该系列前面的博客:
Chap1:Neural Networks with NumPy(手搓神经网络理解原理)

Chap1-1 Numpy手搓神经网络---入门PyTorch

我们已经掌握了神经网络的基本模块结构、项目文件组织,

我们已经清楚了神经元在网络中的流动行为,

那么现在,就是趁热打铁、逐层递进,从纯数据分析Numpy进入深度学习工程化PyTorch的最好时机。

本篇博客,就是对Chap1的螺旋数据分类任务,正式使用PyTorch框架搭建1个正规的神经网络,让我们能够更加直观地分析Numpy和PyTorch搭建神经网络的异同点。

目标

前置pre

此处我们还是使用同Chap1中的模拟数据,也就是nnfs库中螺旋数据

python 复制代码
!pip install nnfs

然后导入一些必要的数据,nnfs库的一些细节我已经用libinspector解构过了,细节可以参考前面链接中的Chap1博客

python 复制代码
import numpy as np
import matplotlib.pyplot as plt
import nnfs
from nnfs.datasets import spiral_data

nnfs.init()

重要的点来了,本篇博客我们将要使用PyTorch

python 复制代码
import torch
import torch.nn as nn
import torch.optim as optim

这3行代码,想必是任何深度学习入门书中前几章常见的代码了,是Python中使用PyTorch(深度学习框架)时的核心导入语句,分别对应框架基础功能、神经网络构建、优化器工具:

  1. import torch
    导入PyTorch的核心模块,是使用框架的基础。该模块提供了多维张量(Tensor,类似NumPy数组但支持GPU加速)、张量的数学运算(如加减乘除、矩阵运算)、数据序列化(张量存储与读取)等核心功能,也是后续其他子模块的依赖基础。神经网络的参数本质就是张量,计算过程就是张量运算
  2. import torch.nn as nn
    导入PyTorch专门用于构建神经网络 的子模块nn(neural network的缩写)。该模块封装了神经网络的核心组件,比如:
    • 基础层(如全连接层nn.Linear、卷积层nn.Conv2d、循环层nn.RNN);
    • 激活函数(如ReLU、Sigmoid,通过nn.ReLUnn.Sigmoid调用);
    • 损失函数(如交叉熵损失nn.CrossEntropyLoss、均方误差损失nn.MSELoss);
    • 网络容器(如nn.Sequential,用于按顺序堆叠网络层),方便快速搭建完整神经网络结构。
  3. import torch.optim as optim
    导入PyTorch的优化器子模块 ,用于实现神经网络的参数更新(即"训练过程"中的梯度下降相关逻辑)。该模块提供了多种经典优化算法,比如:
    • 随机梯度下降(optim.SGD);
    • 自适应学习率优化器(如optim.Adamoptim.RMSprop);
      后续可通过该模块实例化优化器,传入网络参数和学习率等超参数,在训练循环中调用optimizer.step()完成参数更新。

简单当成import numpy/pandas,或者说sklearn的逻辑,

但是具体分析构建组件时需要sklearn的其他子模块,这些子模块之间是层级化的关系。

一些基础介绍:

torch模块能做什么(和numpy的比较)

python 复制代码
?torch
  1. 能做的1:多维张量 + 数学运算
    张量是 PyTorch 的基础数据结构(类比 NumPy 的数组,但支持 GPU 加速)。torch 模块提供了张量的创建(torch.tensor())、形状变换(torch.reshape())、数学运算(加减乘除、矩阵乘法等)的核心接口。
    这也是 torch.nntorch.optim 能正常工作的基础------神经网络的参数本质就是张量,计算过程就是张量运算。
  2. 能做的2:高效序列化工具
    序列化指的是将张量或模型等数据保存为文件,或从文件中加载的过程(我们前面在机器学习中pickle、json系列博客中提到过)。torch 提供了 torch.save()torch.load() 等接口,可用于保存训练好的模型参数、中间张量结果,方便后续复用或部署。
  3. 能做的3:CUDA 加速支持
    这是 PyTorch 用于高性能计算的关键特性。只要我们的设备有符合要求的 NVIDIA GPU,就可以通过 tensor.cuda()tensor.to('cuda') 将张量转移到 GPU 上运算,大幅提升神经网络的训练速度。

一些硬件、依赖库的检查与简要说明

重点是torch和对应编译的CUDA版本信息

python 复制代码
torch.__version__, torch.version.cuda

然后是其他的一些检查

python 复制代码
import torch
import torch.nn as nn
import torch.optim as optim

# ===================== 1. 检查 PyTorch 核心版本 =====================
print(f"PyTorch 版本: {torch.__version__}")
# 总之估计检查什么模块, import torch.xx as xx, 就 xx is not None
print(f"torch.nn 模块是否可用: {nn is not None}")
print(f"torch.optim 模块是否可用: {optim is not None}")

# ===================== 2. 检查 CUDA 相关信息 =====================
print("\n===== CUDA 配置检查 =====")
# 检查 CUDA 是否可用(驱动+GPU 支持)
print(f"CUDA 是否可用: {torch.cuda.is_available()}")
if torch.cuda.is_available():
    # CUDA 版本(PyTorch 编译时对应的 CUDA 版本)
    print(f"PyTorch 编译的 CUDA 版本: {torch.version.cuda}")
    # GPU 数量与名称
    gpu_count = torch.cuda.device_count()
    print(f"可用 GPU 数量: {gpu_count}")
    for i in range(gpu_count):
        print(f"GPU {i} 名称: {torch.cuda.get_device_name(i)}")
        print(f"GPU {i} 计算能力: {torch.cuda.get_device_capability(i)}")
    # 设置当前默认 GPU
    # 明确将编号为「0」的 GPU 设为当前程序的默认 GPU。
    # 后续代码中若未特别指定其他 GPU,所有需要调用 GPU 的计算(如模型加载、数据传输、梯度计算等)
    # 都会自动在这块 GPU 上执行
    torch.cuda.set_device(0)
    print(f"当前使用的 GPU: {torch.cuda.current_device()}")
else:
    print("警告: 未检测到可用 CUDA,将使用 CPU 训练(速度较慢)")

# ===================== 3. 检查 核心依赖库 版本 =====================
print("\n===== 核心依赖库检查 =====")
try:
    import numpy
    print(f"NumPy 版本: {numpy.__version__}")
except ImportError:
    print("NumPy 未安装(部分数据处理功能可能受限)")

try:
    import pillow
    print(f"Pillow 版本: {pillow.__version__}")
except ImportError:
    print("Pillow 未安装(图像数据加载功能受限)")

# 同理其他的一些核心依赖库也都可以检查

# ===================== 4. 检查 张量运算与 GPU 加速 =====================
print("\n===== 张量运算与 GPU 加速测试 =====")
# 创建测试张量
x = torch.randn(3, 3)
print(f"CPU 张量示例:\n{x}")

if torch.cuda.is_available():
    # 张量转移到 GPU
    x_gpu = x.cuda()
    print(f"GPU 张量是否在 GPU 上: {x_gpu.is_cuda}")
    # 测试 GPU 上的运算
    y_gpu = x_gpu @ x_gpu  # 矩阵乘法
    print(f"GPU 张量矩阵乘法结果:\n{y_gpu}")

# ===================== 5. 检查 自动微分功能 =====================
print("\n===== 自动微分功能检查 =====")
x = torch.tensor([1.0, 2.0, 3.0], requires_grad=True)
y = x ** 2
z = y.sum()
z.backward()
print(f"张量梯度计算结果: {x.grad}")
print("自动微分功能正常" if x.grad is not None else "自动微分功能异常")

输出信息如下

python 复制代码
PyTorch 版本: 2.9.0+cu126
torch.nn 模块是否可用: True
torch.optim 模块是否可用: True

===== CUDA 配置检查 =====
CUDA 是否可用: True
PyTorch 编译的 CUDA 版本: 12.6
可用 GPU 数量: 1
GPU 0 名称: Tesla T4
GPU 0 计算能力: (7, 5)
当前使用的 GPU: 0

===== 核心依赖库检查 =====
NumPy 版本: 2.0.2
Pillow 未安装(图像数据加载功能受限)

===== 张量运算与 GPU 加速测试 =====
CPU 张量示例:
tensor([[ 0.1899, -0.6731,  1.3258],
        [-1.1812,  0.1211,  0.0036],
        [ 0.7011, -0.5506, -1.7120]])
GPU 张量是否在 GPU 上: True
GPU 张量矩阵乘法结果:
tensor([[ 1.7607, -0.9393, -2.0204],
        [-0.3648,  0.8078, -1.5717],
        [-0.4167,  0.4041,  3.8584]], device='cuda:0')

===== 自动微分功能检查 =====
张量梯度计算结果: tensor([2., 4., 6.])
自动微分功能正常

总体来说,Torch版本和CUDA版本需要匹配对应(⚠️非常重要!

细节可以参考我之前的博客:如何为我们的GPU设备选择合适的CUDA版本和Torch版本?

总之,我们每次都可以先检查一下配置环境中的Torch版本和CUDA版本,

这里有一个模块torch.cuda,是PyTorch 中专门用于管理 GPU 相关操作的模块,只有当环境中存在可用 GPU 且 PyTorch 安装了 GPU 版本(如基于 CUDA 的版本)时,该模块的功能才能正常使用。

1,Defining model 定义模型

1,定义神经网络类

在Chap1我们用Numpy手搓的神经网络中,

我们足足用了7个Class来定义我们模型的组件

但是在使用PyTorch时,我们就不需要自己写那么多类了,我们只需要一个简简单单调用底层api的model框架类!

1个就够了!

python 复制代码
# custom NN inheriting from nn.Module
class MyModel(nn.Module):
  def __init__(self):
    super(MyModel, self).__init__()
    # first dense (fully connected) layer with 2 input features, 64 output neurons
    self.dense1 = nn.Linear(2, 64)
    # define activation function (ReLU) for first hidden layer
    self.relu1 = nn.ReLU()
    # # second desne layer with 64 input neurons, 3 output neurons
    self.dense2 = nn.Linear(64, 3)

  # forward pass
  # computes output of model given input data x
  def forward(self, x):
    # pass input x through first dense layer
    x = self.dense1(x)
    # apply ReLU activation function to output of first dense layer
    x = self.relu1(x)
    # pass output of ReLU activation function through second dense layer
    x = self.dense2(x)
    # return output of second dense layer
    return x

整体上都很好理解,就是一个简单的Python代码,

主要是下面这一行:

python 复制代码
super(MyModel, self).__init__()

父类初始化:必须调用!这行代码初始化了 PyTorch 的内部机制 (如将层注册到计算图、初始化参数管理系统等)。如果不写,模型根本跑不起来。

简单来说,我们自己定义的MyModel类继承了父类nn.Module,这里就是将MyModel的对象self,转化为类nn.Module的对象,然后用nn.Module的init初始化方法进行初始化,相当于MyModel有了父类初始化的那一套东西(可能写出来比较复杂)

参考:https://docs.pytorch.org/docs/stable/generated/torch.nn.Module.html#torch.nn.Module

  • 本质:nn.Sequential 把一组子模块包装成一个可调用的 nn.Module。self.network = nn.Sequential(*layers) 会把 layers 中的每个子模块注册为当前模块的子模块(parameters/buffers/children 被正确跟踪),并实现了按顺序把输入张量传递给每个子模块的逻辑。
  • 当你在 forward 中写 return self.network(x) 时,实际发生的是:
    1. 调用 nn.Module.call(处理 hooks、register/grad 等),然后执行 Sequential.forward。
    2. Sequential.forward 大致等价于:
      for module in self._modules.values():
      x = module(x)
      return x
      (即把 tensor 依次送进每个子模块,前一层的输出成为后一层输入)
    3. 所有子模块的参数、状态会被 .to(device)/.train()/.eval()/state_dict() 等递归操作影响。
  • 与手写 attribute + forward 的关系与区别:
    • 等价性:功能上等价------你可以把每一层注册为属性(self.dense1, self.relu1, ...)并在 forward 中逐步调用,得到完全相同的计算图和参数管理。
    • 优点(Sequential):代码更简洁、构建动态网络更方便、按列表顺序自动执行;适合"线性串联"模块。
    • 限制(手写/复杂场景):如果需要分支、跳跃连接、多个输入/输出或某些层需要额外参数,手写 forward 或使用 nn.ModuleList 更灵活。
    • 注册差别:把模块放进普通 Python list(self.layers = [...])不会自动注册;nn.Sequential/nn.ModuleList/赋给 self.attr 才会注册。
  • 注意事项:
    • 传入 activation=nn.ReLU() 时你把同一实例复用了------对无状态激活(ReLU)没问题,但对有状态层(BatchNorm、Dropout 带训练/推理行为除外)或需要独立实例的层,要每层创建新的实例(例如 activation_cls())。
    • 若层需要额外参数或不同签名,不能用纯 Sequential。

示例(等价写法):

python 复制代码
# python
seq = nn.Sequential(nn.Linear(10, 20), nn.ReLU(), nn.Linear(20, 5))
# 等价于
class M(nn.Module):
    def __init__(self):
        super().__init__()
        self.l1 = nn.Linear(10,20)
        self.a1 = nn.ReLU()
        self.l2 = nn.Linear(20,5)
    def forward(self, x):
        x = self.l1(x)
        x = self.a1(x)
        x = self.l2(x)
        return x

总结:nn.Sequential 只是把"逐层调用"的样板代码封装并确保子模块被正确注册与管理,传入 x 时按顺序把张量传递给每一层并返回最终输出。

官方文档的说法是:

  • 在 PyTorch 中构建模型通常通过继承 torch.nn.Module。
  • 必须在子类的 init 中调用父类构造器(super(...).init()),并且应在注册子模块(self.conv = ...)之前调用。
  • 推荐在 Python3 中直接使用 super().init()。

为什么要调用 super(...).init()?

  1. 调用 Module.init():初始化 nn.Module 的内部状态,包括:
    • _parameters、_buffers、_modules 等内部字典;
    • training 标志(train()/eval() 相关);
    • hook 管理结构等。
  2. 启用特殊的 setattr 行为:Module.setattr 会拦截属性赋值并自动把赋予的 nn.Module / nn.Parameter / Tensor 注册到对应的内部字典(_modules/_parameters/_buffers)。如果不先执行父类构造器,这些内部字典未初始化,属性赋值不会被正确注册,从而导致参数不在 model.parameters() 中、不参与 to()/cuda()/state_dict() 等操作。
  3. 在多重继承或复杂基类情形中,正确调用父类构造器能保证父类的内部初始化逻辑被执行。

至于写法上,super(MyModel, self).init() vs super().init()

  • 在 Python 3 中,推荐用 super().init()(更简洁且等价)。
  • super(MyModel, self).init() 是 Python2/3 通用写法(显式写法),在单继承下两者效果相同。

简而言之,始终在子类 init 的首行(或至少在分配子模块之前,搭建静态网络结构前)调用 super().init(),这样才能正确注册子模块与参数,确保模型在训练/保存/迁移设备等场景下表现正常。

2,创建多分类数据集

现在我们来创建用于训练的数据集,训练数据和训练任务同Chap1。

这个训练任务就是一个简单的多分类任务,用的螺旋数据我们在该系列博客的Chap1、Chap1-1中都提到过:

数据就是简单的Numpy中的多维数组,

但是我们现在不是在numpy中手搓网络,而是要用pytorch,所以我们得先把这个numpy中的多维数组数据转换为tensor张量,就用torch.tensor函数。

(PyTorch 模型只能处理张量(Tensor)格式数据,需将原始的 NumPy 数组(spiral_data 通常返回 NumPy 数组)转换为张量)

因为我们一般都是用Numpy用惯了,直接torch.tensor一键切换到torch,无缝衔接我们的数据科学

我们一般只需要注意输入数据以及dtype这两个参数即可,

然后因为是分类任务,feature就用float32,class用long

然后参考:https://docs.pytorch.org/docs/stable/generated/torch.Tensor.long.html

torch.long = torch.int64(64 位有符号整数),是分类任务标签的标准类型。

python 复制代码
# generate data for a classification task
X, y = spiral_data(samples=100, classes=3)

# convert dataset to pytorch tensors
# PyTorch 模型只能处理张量(Tensor)格式数据,需将原始的 NumPy 数组(spiral_data 通常返回 NumPy 数组)转换为张量

# 特征通常为浮点类型, 一般默认用float32
X = torch.tensor(X, dtype=torch.float32)
# 分类任务的标签需为整数型(表示类别索引), long(64位整数)是 PyTorch 中交叉熵损失函数(CrossEntropyLoss)要求的标签类型
# 参考https://docs.pytorch.org/docs/stable/generated/torch.nn.CrossEntropyLoss.html
# torch.long = torch.int64(64 位有符号整数),是分类任务标签的标准类型。
y = torch.tensor(y, dtype=torch.long)

# visualize dataset
plt.scatter(X[:, 0], X[:, 1], c=y, s=40, cmap='brg')

3,实例化模型、损失函数和优化器

python 复制代码
# 实例化model,指定loss和优化器
# Define the model
model = MyModel()

# Loss and optimizer
loss_function = nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters(), lr=0.05, weight_decay=5e-7)

这个没什么好说的,就是调用API;

需要注意的是这里的model.parameters()

这实际上,model.parameters 是 MyModel 类中继承自 nn.Module 的一个方法,所以打印的时候会显示<bound method Module.parameters of MyModel(...)>,

这是一个方法对象,而不是参数列表。

如果我们要查看具体的参数:

或者是打印出来

python 复制代码
# 或者是
for name, p in model.named_parameters():
    print(name, p.shape, p.requires_grad)

可以对照在model构建时网络层结构参数

直观逻辑上,我们会觉得参数反了。

对于全连接层 nn.Linear(in_features=I, out_features=O),直观上会认为 "权重矩阵是 [I, O] 形状"------ 因为单个样本输入是 [I](I 个特征),要通过 "输入 × 权重矩阵" 得到 [O](O 个输出),数学上需要满足 [1, I] × [I, O] = [1, O](矩阵乘法要求 "前矩阵列数 = 后矩阵行数")。

这时候看到 PyTorch 输出的权重形状是 [O, I],就会觉得 "反了。

PyTorch中这样做是有深层原因的,主要是为了计算效率和底层实现的约定。

简单来说,如果权重的shape是[output feature,input feature]:

  1. 输出神经元的独立性:计算某一个输出神经元的值时,需要将该神经元对应的所有权重与输入向量做点积。
  2. 连续内存访问:如果权重矩阵的形状是 [O, I],也就是每一行对应一个输出神经元的所有权重。在内存中,行通常是连续存储的。
    • 当我们计算第 k 个输出时,CPU/GPU 可以快速读取第 k 行的连续内存块(包含所有相关的输入权重)。
    • 相比之下,如果形状是 [I, O],那么描述同一个输出神经元的权重在内存中是跳跃分布的(步长为 OO),这会导致缓存命中率(Cache Hit Rate)降低,拖慢计算速度。

2,Training 训练

依据我们前面Numpy手搓神经网络的算法逻辑,一个正常的训练循环(每一个epoch轮次中),应该是下面这样的:

  • 前向传播,也就是model接受输入数据X,然后经过内部层层计算(权重相乘、加偏置、激活函数等),然后得到输出结果outputs
  • 实例化loss损失函数,衡量model的预测outputs和真实标签label的区别
  • 反向传播与优化:我们每一轮epoch更新参数,每一轮batch批次需要当前批次的梯度来更新参数,而上一轮batch的原始梯度必须清零,但Adam会保留上一轮的动量信息,用于优化当前的更新方向,这里需要区分原始梯度和动量信息。

标准流程如下:

清空梯度和保留动量这两个概念这里很容易搞混,导致我们搞不清楚为什么每一轮epoch开始前都需要清空梯度信息。

还有一种想法会认为:不仅梯度(Gradient)决定方向,动量(Momentum)也决定方向。如果把梯度清零了,动量信息由于'依赖于梯度',难道不会也没了吗?

实际上是不会,因为这两个数值存储的位置不同,作用也不同。

好了,原理步骤就是上面这么简单,但是当我们开始传入数据的时候,问题来了:

我们的输入数据X传给model实例的哪个API接口?换句话说,哪个API接口认得,或者是能够合理处理我们的数据?

这是我们的model:

实例化后的:

我们可以看到,只有model的forward接口里有X,也就是只有forward接受输入数据。

但是!

我们实际的代码是下面这样的:

markdown 复制代码
# Train in loop
for epoch in range(10001):
    # Forward pass
    outputs = model(X)

    # Calculate the loss
    loss = loss_function(outputs, y)

    # Zero gradients, backward pass, and optimize
    optimizer.zero_grad()
    loss.backward()
    optimizer.step()

    # Calculate accuracy
    _, predicted = torch.max(outputs, 1)
    accuracy = (predicted == y).float().mean()

    # Print epoch, accuracy, loss, learning rate every 100 epochs
    if epoch % 100 == 0:
        print(f'Epoch: {epoch}, Accuracy: {accuracy.item():.3f}, Loss: {loss.item():.3f}, Learning Rate: {optimizer.param_groups[0]["lr"]}')

这里很奇怪是不是:

为什么不用forward方法,而是用model实例本身,

如果model实例本身也作为一个函数调用的话,依据python中面向对象编程的常识,我们是默认这个类实现了一个内部的call方法,然后实例作为函数调用本事是调用其call方法。

也就是说,model(X)其实是model.call(X),因为我们就是想要简单的前向传播数据,

这里面不用model.forward(X),而是直接用model,难道其call内部实现就是调用self.forward?

如果是这样的话,那不是多此一举吗,我直接调用forward方法不就好了?

现实情况确实是这样的:

那么问题来了,除非这个call调用不是简单的self.forward,不然我们没必要绕来绕去!

现实情况是:虽然 call 最终会运行 forward,但它做的远不止这些。这是为什么官方强烈推荐使用 model(X) 而不是 model.forward(X) 的根本原因。

nn.Module 的 call 方法伪代码逻辑大致如下:

markdown 复制代码
def __call__(self, input):
    # 1. 处理 Hooks (钩子)
    # 在 forward 执行之前,可能会有一些"前钩子"(Pre-hooks)需要运行
    for hook in self._forward_pre_hooks.values():
        hook(self, input)
    
    # 2. 调用真正的 forward 函数
    result = self.forward(input)
    
    # 3. 处理 Hooks (钩子)
    # 在 forward 执行之后,可能会有一些"后钩子"(Forward hooks)需要运行
    for hook in self._forward_hooks.values():
        hook_result = hook(self, input, result)
        # ...处理钩子的返回值等
        
    return result

简单来说就是call继承的远远不止是父类的自动调用forward方法这么简单,所以我们也不能简单的self.forward

简单总结: model(x)** 等同于调用 model.__call__(x) ,而 __call__ 内部会去调 model.forward(x) ,而且父类nn.Module的 **nn.Module.__call__** 内部做了一些复杂的幕后工作(比如处理 Hooks)**


上面这个截图其实就很清晰了,我们能够非常明确地看到,实例model的call,其实是绑定类MyModel的Module._wrapped_call_impl函数,也就是继承自父类nn.Module的call方法。

这两个call就是同一个函数对象。

另外进源码可以看到:

调用的call_impl比较长,简单说,这个函数的逻辑是默认情况下(未编译),模块走 _call_impl → 最终调用子类 forward

还有一个问题就是我们没有在call函数中显式声明x的形状,那为什么model(x)能够直接运行,我可以任意修改x的形状,它都不一定会报错,也就是它是如何知道x是什么样子的?

  • 动态图机制 (Dynamic Computational Graph): PyTorch 是动态图框架。它不需要像早期的 TensorFlow 那样先定义好"水管多粗"(Input Shape),然后再通水。
  • 运行时推断: 当我们把数据 x 真正喂进去的那一刻(运行时),PyTorch 内部的操作(比如 self.conv1(x))才会去检查 x 的形状。
    • 如果我们定义的卷积层 self.conv1 期望输入是 3 通道,而我们的 x 只有 1 通道,程序会在运行的那一瞬间报错
    • 只要我们的 x 形状符合我们在 __init__ 中定义的层(比如 Conv2d, Linear)的要求,数据就会顺畅流过。

前面的训练流程理解了之后,我们再来看这里的outputs是什么,

这里我们以训练完成的最后一个epoch为例,

最后一个epoch的输出,也就是最后一次epoch的预测结果,

这个张量是一个300行的logits向量,每一行都是对1个样本在该次预测中的分类结果,

然后这里推荐一个vscode插件:data wrangler,

简单来说,Data Wrangler 是一个简化数据清理和准备的无代码工具,它提供了一个交互式用户界面,允许我们查看和分析数据,显示列统计信息和可视化内容。

我们就先来查看一下这个输出向量的一些统计可视化,注意,这个是logits向量,不是softmax归一化之后的概率,所以不是概率行向量,但是我们可以简单依据每一行max的索引,判断是分类到0、1、2这3类中的哪一类

然后就是看每一行也就是每一个样本输出结果的max值,其对应的index也就是索引处是label

至于torch.max看着很像numpy.argmax,

第2个参数就是axis,沿着什么轴

我们可以看到返回值其实是有两个键值对的,

所以我们是可以直接解包出来的:

基本上最后一轮epoch预测返回的结果就是3类均分

然后一整个tensor,布尔逻辑张量转换为01数值张量,再取均值就是准确率

可以看到最后一个epoch训练下来,准确率已经能够达到97%了。

3,Performance 性能

这一块没什么好说的了,就是将训练好的model,用来在测试集上测试一下预测效果。

markdown 复制代码
# 准备网格数据
# create meshgrid of points covering the feature space
# 定义网格的精细度
h = 0.02 

# determine min and max values for x,y axes
# 计算坐标轴的绘图范围, 为了图好看, 在最大最小值基础上稍微外扩了一点±1
x_min, x_max = X[:, 0].min() - 1, X[:, 0].max() + 1 
y_min, y_max = X[:, 1].min() - 1, X[:, 1].max() + 1

# create meshgrid of points with spacing h
# 生成从min到max每隔0.02也就是前面的分辨率h 一个点的序列
# 把这两个序列生成交叉的网格矩阵xx和yy, 它们包含了绘图区域内每一个像素点的坐标
xx, yy = np.meshgrid(np.arange(x_min, x_max, h),
                     np.arange(y_min, y_max, h))

# convert meshgrid to torch tensor
# ravel把二维矩阵拉平成一维长条, 然后把这两个长条左右拼接起来, 变成1个形状为(N,2)的大数组
# 这就好比生成了成千上万个模拟数据点, 覆盖了整个画面区域
# 再将numpy数组转换为张量
meshgrid_points = torch.tensor(np.c_[xx.ravel(), yy.ravel()], dtype=torch.float32)

# pass meshgrid points through model
# 上下文管理器, 声明只是做推理(预测), 不需要计算梯度, 也不需要反向传播, 可以节省内存, 跑得快
with torch.no_grad():
  
  # 下面几行都是在手动执行model的前向传播, 其实可以直接调用model(meshgrid_points)
  # 但是没有这么做, 而是把model里的层拆开单独运行
  # 如果只是运行forward方法的话, 
  # 其实只能拿到logits, 然后后面的softmax还是需要自己手动计算, 因为model中已经被封装合并在loss里了, 其实这一步还是要拆开
  
  # forward pass through first dense
  z1 = model.dense1(meshgrid_points)
  # apply relu activation
  a1 = torch.relu(z1)
  # forward pass through second dense
  z2 = model.dense2(a1)
  
  # 然后是手写softmax函数并将输出logits转换为概率分布
  # compute softmax probabilities for each class
  exp_scores = torch.exp(z2 - torch.max(z2, axis=1, keepdim=True).values)
  probs = exp_scores / torch.sum(exp_scores, axis=1, keepdim=True)

# predictions
# determine predicted class for each point in meshgrid
# 在概率最高的维度上获取最大值的索引, 也就是model对每一个网格点预测的类别(我们的背景中是0、1、2)
_, predictions = torch.max(probs, axis=1)
# reshape predictions to match shape of meshgrid
# 将预测结果转回numpy
# 也就是将拉平的一维结果重新还原为和xx一样的二维网格形状
Z = predictions.numpy().reshape(xx.shape)

# plot decision boundary based on predictions
# 依据网格坐标(xx,yy)+对应高度值(类别z)画出等高线填充图
plt.contourf(xx, yy, Z, cmap='brg', alpha=0.8)

# plot original data on top of decision boundary
# 在背景上上原始点
plt.scatter(X[:, 0], X[:, 1], c=y, s=40, cmap='brg')
# plot limits set to match extent of meshgrid
plt.xlim(xx.min(), xx.max())
plt.ylim(yy.min(), yy.max())
plt.show()

然后这里需要纠正1个新手很容易犯的误区,就是我们在推理的时候为什么要禁用梯度计算,

也就是一般很多入门教程中都会出现这句代码

markdown 复制代码
torch.no_grad()

很多人会好奇,我前向传播的时候也没有调用backward相关的函数API啊,为什么还要担心梯度计算会占用内存的问题,如果不计算内存,那我为什么不直接调用forward方法,然后加速推理呢?

我们先来稍微讲一下torch.no_grad()的作用:

torch.no_grad() 的唯一核心目的:在其包裹的代码块内,禁用所有张量的梯度计算与梯度追踪

  • 被它包裹的操作,不会为张量生成 grad_fn(梯度函数),也不会反向传播计算梯度 grad
  • 不会修改原张量的 requires_grad 属性,只是「临时屏蔽梯度功能」。

然后核心使用场景如下:

场景1:模型推理/预测阶段(最常用)

模型训练完成后,做预测/验证/测试时,完全不需要计算梯度

  • torch.no_grad()包裹推理代码,能大幅节省显存/内存开销(梯度会占用大量显存);
  • 同时能显著提升推理速度,避免了无用的梯度计算开销。
场景2:固定部分网络参数(冻结权重)

训练时如果想「冻结某几层权重不更新」(比如预训练模型微调),把这部分层的前向传播代码包裹在torch.no_grad()内,这部分层的参数就不会计算梯度,反向传播时也不会更新权重。

然后就是语法上,主流写法:

写法1:with语句(上下文管理器,推荐)

最规范的写法,局部生效 ,仅with代码块内禁用梯度,出了代码块自动恢复梯度计算,无副作用,优先用这种:

python 复制代码
import torch
x = torch.randn(3,3, requires_grad=True)  # 张量开启梯度追踪

with torch.no_grad():
    y = x * 2  # 该操作无梯度追踪,y.requires_grad=False
    z = y.mean()

# 出了no_grad,梯度功能自动恢复
w = x + 1  # w.requires_grad=True
写法2:装饰器 @torch.no_grad()

针对整个函数生效,适合把「推理函数」整体标记为无梯度,代码更简洁:

python 复制代码
@torch.no_grad()
def predict(model, x):
    # 函数内所有操作都禁用梯度,专门用于推理
    return model(x)

# 调用时直接用,无需额外包裹
output = predict(my_model, test_data)

一些需要注意的细节:

  1. 只读不修改 :不会改变张量本身的 requires_grad 属性(比如原张量是True,包裹后还是True,只是临时失效);
  2. 反向传播无效no_grad内的操作,即使调用loss.backward(),也不会为相关参数计算梯度,参数权重不会更新;
  3. 仅对浮点张量有效 :PyTorch中只有浮点型张量(float32/float64) 能计算梯度,整型/布尔型张量本身无梯度,用不用no_grad无影响。

与 torch.set_grad_enabled(False) 的区别,很多人会混淆这两个功能,核心差异只有「作用范围」:

  • torch.no_grad() → 「局部生效」:只作用于包裹的代码块/装饰的函数,不影响全局;
  • torch.set_grad_enabled(False) → 「全局生效」:执行后,整个程序的梯度计算都被禁用,需要手动调用torch.set_grad_enabled(True)恢复。

结论:优先用 torch.no_grad(),因为无全局副作用,代码更安全,不会忘记恢复梯度导致训练失败。

torch.no_grad() 是PyTorch的无梯度上下文管理器,核心是「禁用梯度计算」,主要用在模型推理提速省显存,语法支持with语句和装饰器,局部生效、无副作用,是PyTorch开发的高频API。

回到我们的问题,误区在哪里呢?

误区在于,forward前向传播时,其实是默认会计算梯度的!

还记得我们前面输出model参数的时候吗,模型的层(比如说我们这里的dense1、dense2)的参数张量,默认都是requires_grad=True,

正常来说,梯度的计算,和我们是否只调用model.forward无关,和:

  • model的层的参数张量相关,默认都是requires_grad=True
  • 我们输入的张量,如果是float浮点型,即使没有手动设置requires_grad=True,但是经过带梯度的层计算后,输出的张量都会被梯度追踪(track)

4,总结一下我们的简易PyTorch代码模板

我们把前面的代码粗略地整合在一起,大概如下

python 复制代码
import numpy as np
import matplotlib.pyplot as plt
import nnfs
from nnfs.datasets import spiral_data

nnfs.init()

import torch
import torch.nn as nn
import torch.optim as optim

# custom NN inheriting from nn.Module
class MyModel(nn.Module):
    def __init__(self):
        # 在子类初始化中调用父类构造器
        super(MyModel, self).__init__()
        # 而且必须在注册子模块,也就是搭建网络结构前调用

        # first dense (fully connected) layer with 2 input features, 64 output neurons
        self.dense1 = nn.Linear(2, 64)
        # define activation function (ReLU) for first hidden layer
        self.relu1 = nn.ReLU()
        # # second dense layer with 64 input neurons, 3 output neurons
        self.dense2 = nn.Linear(64, 3)

    # forward pass
    # computes output of model given input data x
    def forward(self, x):
        # pass input x through first dense layer
        x = self.dense1(x)
        # apply ReLU activation function to output of first dense layer
        x = self.relu1(x)
        # pass output of ReLU activation function through second dense layer
        x = self.dense2(x)
        # return output of second dense layer
        return x


# generate data for a classification task
X, y = spiral_data(samples=100, classes=3)

# convert dataset to pytorch tensors
# PyTorch 模型只能处理张量(Tensor)格式数据,需将原始的 NumPy 数组(spiral_data 通常返回 NumPy 数组)转换为张量

# 特征通常为浮点类型, 一般默认用float32
X = torch.tensor(X, dtype=torch.float32)
# 分类任务的标签需为整数型(表示类别索引), long(64位整数)是 PyTorch 中交叉熵损失函数(CrossEntropyLoss)要求的标签类型
# 参考https://docs.pytorch.org/docs/stable/generated/torch.nn.CrossEntropyLoss.html
# torch.long = torch.int64(64 位有符号整数),是分类任务标签的标准类型。
y = torch.tensor(y, dtype=torch.long)


# 实例化model,指定loss和优化器
# Define the model
model = MyModel()

# Loss and optimizer
loss_function = nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters(), lr=0.05, weight_decay=5e-7)


# Train in loop
for epoch in range(10001):
    # Forward pass
    # 前向传播数据, 本质call是调用子类model的forward方法
    outputs = model(X)

    # Calculate the loss
    # 计算loss
    loss = loss_function(outputs, y)

    # Zero gradients, backward pass, and optimize
    # 先梯度清零, 防止不同batch的参数更新建议不一致
    optimizer.zero_grad()
    # 对于当前loss反向传播, 计算对参数的梯度(动量和当前梯度)
    loss.backward()
    # 利用动量和当前梯度更新参数
    optimizer.step()

    # Calculate accuracy
    # 只要索引值, 作为label
    _, predicted = torch.max(outputs, 1)  
    accuracy = (predicted == y).float().mean()

    # Print epoch, accuracy, loss, learning rate every 100 epochs
    if epoch % 100 == 0:
        print(f'Epoch: {epoch}, Accuracy: {accuracy.item():.3f}, Loss: {loss.item():.3f}, Learning Rate: {optimizer.param_groups[0]["lr"]}')

现在我们要做的,和前面Chap1、Chap1-1一样,

就是从这些简略的手写版神经网络中,尽可能地抽象出、整理出比较规范正式的代码逻辑结构,

同样地我们按照Chap1中定义的习惯来

python 复制代码
project_root/
│
├── data/                   # 存放原始数据和处理后的数据
│   ├── raw/
│   └── processed/
│
├── configs/                # 配置文件 (超参数、路径)
│   └── config.yaml
│
├── src/                    # 核心源代码
│   ├── __init__.py
│   ├── dataset.py          # 1. 数据处理 (Data Loader)
│   ├── model.py            # 2. 模型定义 (Network Architecture)
│   ├── trainer.py          # 3. 训练逻辑 (Training Loop)
│   ├── utils.py            # 4. 辅助函数 (Metrics, Logging)
│   └── predict.py          # 5. 推理/预测逻辑
│
├── main.py                 # 项目入口 (整合所有模块)
├── requirements.txt        # 依赖库
└── README.md

1,配置文件(configs/config.yaml)

如果是原始的简略版神经网络,我们各种自定义的参数和超参数会散落在代码各种,修改起来非常麻烦,需要到处去找。

但是现在,正式一点地做法,我们能够将所有的超参数都全部提取到YAML文件中。

这样我们修改实验设置就无需改动代码,甚至可以编写脚本批量生成不同的config文件进行超参数搜索,

比如说在经典机器学习中,配合optuna进行超参数优化,就可以在yaml文件抽象层上对超参数进行处理。

一般超参数设置,也主要是data、model、trainer这3大块核心配置。

比如说我这里的超参数设置,也就是data中模拟数据生成的一些参数,model也就是架构中对layer层的一些维度设置等,trainer中对于训练、优化器的一些参数设置

bash 复制代码
# 超参数配置,在源代码中通过yaml库加载
# 可根据需要修改这些参数以调整模型训练行为, 不需要在代码中硬编码
data:
  samples: 100
  classes: 3
  
model:
  input_dim: 2
  hidden_dims: [64]  # 可以通过列表配置多个隐藏层
  output_dim: 3
  dropout_rate: 0.0

trainer:
  epochs: 10001
  learning_rate: 0.05
  weight_decay: 5e-7
  print_every: 100

2,数据流水线(src/dataset.py)

我们前面的代码中,对于输入数据的处理非常简单,就是生成一个模拟数据,然后将生成的X,y转入内存变量,手动转换为tensor。

但是正式的数据处理中,我们一般会用PyTorch的DatasetDataLoader

1,先回顾numpy手搓数据的"原始操作"

用numpy做神经网络时,我们可能会写这样的代码:

python 复制代码
import numpy as np

# 1. 全量读数据(痛点1:数据大了装不下内存)
X = np.load("all_data_x.npy")  # 假设100万条数据,几GB,直接占满内存
y = np.load("all_data_y.npy")

# 2. 手动Shuffle(痛点2:每次epoch都要自己写打乱逻辑)
shuffle_idx = np.random.permutation(len(X))
X_shuffle = X[shuffle_idx]
y_shuffle = y[shuffle_idx]

# 3. 手动切Batch(痛点3:循环计算索引,容易写错)
batch_size = 32
for i in range(0, len(X), batch_size):
    # 手动算当前batch的起止索引
    X_batch = X_shuffle[i:i+batch_size]
    y_batch = y_shuffle[i:i+batch_size]
    # 然后喂给模型

核心痛点

  • 数据全量加载:几TB的工业数据(如ImageNet)根本装不下内存;
  • 手动操作繁琐:切batch、shuffle要自己写逻辑,容易出错;
  • 没有加速:读数据是"单线程",等数据的时间比模型计算还长。

DatasetDataLoader就是为解决这些痛点而生的------前者负责"按需读数据",后者负责"智能装Batch"。

2,Dataset:"按需取一条数据"的工具

我们可以把Dataset理解成"数据货架":货架上不放所有商品(数据),只记着每个商品的位置(路径/索引),当我们要第i个商品时,它才去仓库拿出来、简单处理(如洗干净shuffle、切好batch)再给我们。

1. Dataset的核心作用
  • 懒加载(Lazy Loading):不一次性读所有数据,只在需要某一条时才读取(解决内存爆炸问题);
  • 统一接口:不管是图片、文本、CSV,都用同样的方式取数据(避免每种数据写一套读逻辑)。
2. 怎么用Dataset?3步搞定

PyTorch要求:自定义Dataset必须继承torch.utils.data.Dataset,并重写3个方法:

方法名 作用
__init__ 初始化:存数据路径、预处理函数(如图片Resize、归一化),不读具体数据
__getitem__ 核心:根据索引idx,读取1条数据并处理成Tensor(返回x, y
__len__ 返回数据总条数(让DataLoader知道"有多少数据要处理")
3. 举个例子

假设我们有个data.csv,每行是1个样本,列是(特征1, 特征2, 标签),比如:

plain 复制代码
0.5,1.2,0
1.3,0.8,1
2.1,0.3,0
...

我们就可以继承Torch的Dataset类,自定义Dataset代码如下:

python 复制代码
import torch
# 首先要导入torch的Dataset
from torch.utils.data import Dataset
import pandas as pd

# 导入之后可以继承
# 自定义Dataset,继承自torch的Dataset
class MyCSVDataSet(Dataset):
    def __init__(self, csv_path, transform=None):
        """
        初始化:只做"准备工作",不读具体数据
        :param csv_path: 数据文件路径
        :param transform: 可选的预处理函数(如归一化)
        """
        # 读CSV的"表头+路径",不读具体数据(相当于记货架位置)
        self.df = pd.read_csv(csv_path)  
        self.transform = transform  # 存预处理函数

    def __getitem__(self, idx):
        """
        核心:按索引取1条数据,处理后返回
        :param idx: 数据索引(比如第0条、第1条)
        :return: 处理好的x(特征,Tensor)、y(标签,Tensor)
        """
        # 1. 按索引取1行数据(终于读具体数据了!)
        row = self.df.iloc[idx]
        
        # 2. 拆分特征和标签(根据我们的CSV格式调整)
        x = row[["feature1", "feature2"]].values  # numpy数组
        y = row["label"]
        
        # 3. 预处理(比如转Tensor、归一化)
        if self.transform:
            x = self.transform(x)
        # 转成PyTorch的Tensor(numpy手搓时要手动转,这里统一处理)
        x = torch.tensor(x, dtype=torch.float32)
        y = torch.tensor(y, dtype=torch.long)
        
        return x, y  # 返回1条数据的(x,y)

    def __len__(self):
        """返回数据总条数(货架上有多少商品)"""
        return len(self.df)
类比numpy:

numpy是"把整个超市(仓库)的商品全堆在货架上"(全量读内存),而Dataset是"货架墙上贴个清单,要第idx个商品时,才去仓库拿"(按需读取)。

3,DataLoader:"智能装Batch"的工具

有了Dataset(货架),还需要DataLoader(快递员)------它帮我们按"批量"把货架上的商品打包,还能打乱顺序、多个人一起搬(多进程加速),不用我们自己动手。

1. DataLoader的核心作用

解决numpy手搓的3个痛点:

  • 自动切Batch :不用自己算i:i+batch_size,直接给我们批量数据;
  • 自动Shuffle:每个epoch(一轮训练)自动打乱数据顺序,防止模型过拟合;
  • 多进程加速 :用num_workers参数开启多进程读数据,避免"模型等数据";
  • 自动处理最后一批:如果数据总数不是batch_size的整数倍,最后一批自动处理(不用我们补零或丢弃)。
2. 怎么用DataLoader?

一步搞定,把自定义的Dataset实例传进去,再指定几个关键参数即可:

python 复制代码
from torch.utils.data import DataLoader

# 1. 先创建Dataset实例(准备好货架)
# 可选:定义预处理函数(比如归一化)
def normalize(x):
    return (x - x.mean()) / x.std()

dataset = MyCSVDataSet(
    csv_path="data.csv",  # 我们的数据路径
    transform=normalize   # 预处理
)

# 2. 创建DataLoader(安排快递员)
dataloader = DataLoader(
    dataset=dataset,       # 要打包的货架
    batch_size=32,         # 每次打包32条数据(batch大小)
    shuffle=True,          # 每个epoch打乱顺序(训练时开,测试时关)
    num_workers=2,         # 2个进程一起读数据(加速,根据CPU核心数调整)
    drop_last=False        # 最后一批不够32条时,是否丢弃(一般False)
)
3. 怎么用DataLoader训练?

训练时直接用for循环迭代dataloader,每次得到一个batchxy,直接喂给模型:

python 复制代码
# 假设我们有个简单模型
model = torch.nn.Linear(2, 2)  # 输入2维,输出2维(分类)
loss_fn = torch.nn.CrossEntropyLoss()
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)

# 训练10个epoch(遍历10次所有数据)
for epoch in range(10):
    model.train()
    total_loss = 0.0
    
    # 迭代dataloader,每次得到一个batch
    for batch_x, batch_y in dataloader:
        # 1. 前向传播
        outputs = model(batch_x)
        # 2. 算损失
        loss = loss_fn(outputs, batch_y)
        # 3. 反向传播+更新参数
        optimizer.zero_grad()
        loss.backward()
        optimizer.step()
        # 累加损失
        total_loss += loss.item() * batch_x.size(0)  # 乘batch大小,避免小batch权重低
    
    # 打印每个epoch的平均损失
    avg_loss = total_loss / len(dataset)
    print(f"Epoch {epoch+1}, Avg Loss: {avg_loss:.4f}")

这里的for循环,要深入理解的话,需要查看下面对Dataset和DataLoader的关系小节,

简单来说,dataloader能被for循环,本质处理是:我们每循环一次,它就自动完成调用Dataset的getitem拿数据------》拼batch------》返回(batch_x,batch_y)的全流程,最终把批量数据喂到我们的model里。

把这个<font style="color:rgb(31, 35, 41);background-color:rgba(0, 0, 0, 0);">for</font>循环过程,对应到我们熟悉的场景里:

代码逻辑 现实场景类比
<font style="color:rgb(31, 35, 41);background-color:rgba(0, 0, 0, 0);">for epoch in range(10)</font> 我们要求打包员(DataLoader)把仓库里的货(全量数据)完整配送 10 轮(10 个 epoch)
<font style="color:rgb(31, 35, 41);background-color:rgba(0, 0, 0, 0);">for batch_x, batch_y in dataloader</font> 每一轮配送(1/10)中,打包员一次送 1 个包裹(batch) ,直到把所有货送完;我们每次接一个包裹,拆出里面的<font style="color:rgb(31, 35, 41);background-color:rgba(0, 0, 0, 0);">x</font>(货物)和<font style="color:rgb(31, 35, 41);background-color:rgba(0, 0, 0, 0);">y</font>(快递单)
<font style="color:rgb(31, 35, 41);background-color:rgba(0, 0, 0, 0);">model(batch_x)</font> 我们把包裹里的货物(批量数据)交给工厂(模型)加工
类比numpy:

numpy手搓时要自己写for i in range(0, len(X), batch_size),而DataLoader直接帮我们把batch喂到嘴边,循环里只需要关注"模型计算",不用管数据怎么来的。

4,Dataset和DataLoader的关系总结

组件 核心职责 类比生活场景 numpy手搓的对应操作
Dataset 单条数据的"读取+预处理" 超市货架(按需取货) 手动用np.load读单条数据
DataLoader 批量数据的"整合+加速" 快递员(批量打包) 手动切batch、shuffle、循环

简单说:Dataset管"一条数据怎么来",DataLoader管"多条数据怎么成批来"

我们还是用仓库、货架的类比来理解,

我们简单在代码层面上举例:

python 复制代码
# 1. 仓库:硬盘里的 data.csv 文件(原始数据)
# 2. 创建货架:初始化Dataset,只存仓库地址+清单,不拿任何数据
dataset = MyCSVDataSet(csv_path="data.csv") 

# 货架的能力:喊一声 dataset[0],货架自己去仓库拿第0条数据,返回单条(x,y)
single_x, single_y = dataset[0]  # 只占1条数据的内存 ✔️

# 3. 创建打包员:把货架传进去,打包员只盯着货架干活
dataloader = DataLoader(dataset, batch_size=32, shuffle=True)

# 打包员的能力:迭代dataloader,每次返回打好包的32条数据
for batch_x, batch_y in dataloader:
    model(batch_x)  # 直接喂模型 ✔️

所以前面看了 Dataset 的说明文档,结合这里的类比,我们就大概清除为什么我们继承torch的Dataset需要重写init、getitem、len这3 个方法,以后哪位这 3 个方法就是「智能货架的 3 个必备功能」:

最后总结就是:

python 复制代码
import torch
from torch.utils.data import Dataset, DataLoader
?DataLoader

总而言之,我们可以粗略认为DataLoader就是通过调用Dataset暴露的核心API(也就是我们需要重写的getitem和len)来完成所有工作,也就是说它全程不碰原始数据,所有和获取数据相关的操作,都依赖Dataset提供的接口。

我们可以通过一个简单的示例,通过打印日志,来查看DataLoader如何调用Dataset的API:

python 复制代码
import torch
from torch.utils.data import Dataset, DataLoader

# 自定义Dataset,加日志看调用过程
class MyDataset(Dataset):
    def __init__(self):
        self.data = [1,2,3,4,5]  # 模拟仓库数据
    
    def __getitem__(self, idx):
        print(f"Dataset的__getitem__被调用了!idx={idx}")  # 打印调用日志
        return self.data[idx]
    
    def __len__(self):
        print(f"Dataset的__len__被调用了!")  # 打印调用日志
        return len(self.data)

# 创建Dataset和DataLoader
dataset = MyDataset()
dataloader = DataLoader(dataset, batch_size=2, shuffle=False)

# 迭代DataLoader,看调用过程
print("开始迭代DataLoader:")
for batch in dataloader:
    print(f"DataLoader返回的batch:{batch}\n")

我们通过一个真实的实例,可以看到:

5,快速上手

把上面的代码整合起来,对于一般的任务,我们只需要改3个地方:

  1. MyCSVDataSet里的特征/标签列名(根据我们的CSV调整);
  2. csv_path(我们的数据路径);
  3. 模型结构(根据我们的任务调整)。

完整可运行代码:

python 复制代码
import torch
import pandas as pd
from torch.utils.data import Dataset, DataLoader
from torch.nn import Linear, CrossEntropyLoss
from torch.optim import Adam

# ---------------------- 1. 自定义Dataset ----------------------
class MyCSVDataSet(Dataset):
    def __init__(self, csv_path, transform=None):
        self.df = pd.read_csv(csv_path)
        self.transform = transform

    def __getitem__(self, idx):
        # 【改这里】根据我们的CSV列名调整!
        x = self.df.iloc[idx][["feature1", "feature2"]].values  # 特征列
        y = self.df.iloc[idx]["label"]  # 标签列
        
        if self.transform:
            x = self.transform(x)
        x = torch.tensor(x, dtype=torch.float32)
        y = torch.tensor(y, dtype=torch.long)
        return x, y

    def __len__(self):
        return len(self.df)

# ---------------------- 2. 预处理函数 ----------------------
def normalize(x):
    """简单归一化,可根据需求改"""
    return (x - x.mean()) / (x.std() + 1e-8)  # 加1e-8避免除零

# ---------------------- 3. 创建Dataset和DataLoader ----------------------
# 【改这里】我们的数据路径!
dataset = MyCSVDataSet(
    csv_path="data.csv",
    transform=normalize
)

dataloader = DataLoader(
    dataset=dataset,
    batch_size=32,    # 根据显存调整(显存小就设16)
    shuffle=True,     # 训练时True,测试时False
    num_workers=2,    # CPU核心数的1/2~1/4(避免占用过多资源)
    drop_last=False
)

# ---------------------- 4. 简单训练示例 ----------------------
# 【改这里】根据我们的任务调整模型!(这里是2分类示例)
model = Linear(in_features=2, out_features=2)  # 输入2维,输出2类
loss_fn = CrossEntropyLoss()
optimizer = Adam(model.parameters(), lr=1e-3)

# 训练5个epoch
for epoch in range(5):
    model.train()
    total_loss = 0.0
    for batch_x, batch_y in dataloader:
        # 前向传播
        pred = model(batch_x)
        # 计算损失
        loss = loss_fn(pred, batch_y)
        # 反向传播+更新
        optimizer.zero_grad()
        loss.backward()
        optimizer.step()
        # 累加损失
        total_loss += loss.item() * batch_x.shape[0]
    
    # 打印进度
    avg_loss = total_loss / len(dataset)
    print(f"Epoch {epoch+1:2d} | Avg Loss: {avg_loss:.4f}")

print("训练结束!")

小样本数据debug看shape上手,

  1. 先小数据测试 :先用100条数据做测试,确保Dataset__getitem__返回正确的xy形状(比如x.shape应该是(特征数,)y是标量);
  2. 查看Batch形状 :在for batch_x, batch_y in dataloader里加print(batch_x.shape, batch_y.shape),确认batch_x(batch_size, 特征数)batch_y(batch_size,)

简单来说,我们不再像是numpy手搓神经网络那样,将内存中的变量X,y全部手动转变为Tensor,全量读入。

我们需要正式地自己写一个Dataset类,也就是需要继承torch的Dataset类,然后需要我们自己实现getitem接口,因为实际训练model时数据量很大,一般会有几TB,是无法一次性读入内存的,Dataset类就允许我们用到哪条读哪条(lazy loading)。

接着就是核心组件DataLoader,我们是前面简易版pytorch和手搓numpy,都是将数据全量传给model。

正式pytorch操作中,我们是直接调用torch的DataLoader函数,或者再封装一层(本身也是调用DataLoader),因为它能够自动帮我们处理batch切分(把大数据切成小块)、shuffle(每个Epoch打乱顺序防止过拟合)、multiprocessing(多进程加速读取)。

比如说,我这里就对DataLoader再进行了一层封装,调用

整体需要实现的组件其实很少,

重写一个dataset类,我这里是螺旋数据集,主要是重写一些比较重要的私有函数。

然后就是DataLoader的封装调用

python 复制代码
# src/dataset.py
import torch
import numpy as np
import nnfs
from nnfs.datasets import spiral_data
from torch.utils.data import Dataset, DataLoader

# nnfs.init() # 通常在主程序入口调用

class SpiralDataset(Dataset):
    """
    Description
    ---------
    自定义 Dataset 类:负责从数据源读取并封装单个样本;
    对比:
      - 简易版:直接用 X, y 两个大张量塞进内存
      - 工业版:必须继承 torch.utils.data.Dataset, 实现 __len__ 和 __getitem__
        对于大数据集, __getitem__ 只在需要时加载单个文件,节省内存。
    """
    def __init__(self, samples=100, classes=3):
        """
        Description
        ---------
        构造函数:初始化数据集
        
        Args
        ---------
        samples : int
            每个类别的样本数
        classes : int
            类别数
        
        Returns
        ---------
        None
        """
         
        # 实际项目中,这里通常接收文件路径 list,而不是直接生成数据
        X, y = spiral_data(samples=samples, classes=classes)
        self.X = torch.tensor(X, dtype=torch.float32)
        self.y = torch.tensor(y, dtype=torch.long)
        
    def __len__(self):
        return len(self.X)
    
    def __getitem__(self, idx):
        # 实际项目中,这里可能包含数据增强 (Augmentation)
        return self.X[idx], self.y[idx]

def get_dataloader(samples, classes, batch_size=32, shuffle=True):
    """
    Description
    ---------
    工厂函数:返回 DataLoader
    DataLoader 负责: Batch 切分、Shuffle 打乱、多进程加载
    
    Args
    ---------
    samples : int
        每个类别的样本数
    classes : int
        类别数
    batch_size : int or None
        批量大小; None 表示全量梯度下降
    shuffle : bool
        是否打乱数据顺序
    
    Returns
    ---------
    DataLoader
        PyTorch DataLoader 对象
    """
    dataset = SpiralDataset(samples, classes)
    # 如果 batch_size 为 None (全量梯度下降),则 batch_size = len(dataset)
    if batch_size is None:
        batch_size = len(dataset)
        
    return DataLoader(dataset, batch_size=batch_size, shuffle=shuffle)

3,模型定义(src/model.py):最核心的变化

之前的博客Chap1中我们是自己手写了底层全连接每一层函数dense_layer,

然后我们再自己手动调用其api进行网络架构组建;

然后前面简易pytorch版我们是直接调用torch封装好的layer API,

我们这里同样还是调用layer的API,

同样的,我们只定义model结构和前向传播forward方法,绝不写训练逻辑。

1个简单的模板逻辑:

我们实际封装的代码示例:

python 复制代码
# src/model.py
import torch
import torch.nn as nn

class UniversalMLP(nn.Module):
    """
    Description
    ------------
    通用多层感知机 (MLP) 模板
    对比:
      - 简易版:硬编码 self.dense1, self.dense2, 层数定死
      - 工业版:接收参数列表 (hidden_dims),用 nn.Sequential 动态搭建
    
    不变的地方: 只写 __init__ 定义层, forward 定义流向, Backward 自动完成自动微分
    """
    def __init__(self, input_dim, hidden_dims, output_dim, activation=nn.ReLU, dropout_rate=0.0):
        """ 
        Description
        ------------
        初始化多层感知机, 动态构建隐藏层
        
        Args
        -----
        input_dim : int
            输入特征维度
        hidden_dims : list of int
            隐藏层维度列表, 每个元素代表一层的神经元数量
        output_dim : int
            输出维度 (类别数)
        activation : nn.Module
            激活函数模块, 默认为 ReLU
        dropout_rate : float
            Dropout 比例, 默认为 0.0 (不使用 Dropout)
        
        """
        
        # 调用父类构造函数
        super(UniversalMLP, self).__init__()
        
        layers = []
        prev_dim = input_dim

        # 动态构建隐藏层
        for h_dim in hidden_dims:
            layers.append(nn.Linear(prev_dim, h_dim))
            
            # 正常在 Linear 和 Activation 之间加 BatchNorm
            # layers.append(nn.BatchNorm1d(h_dim)) 
            
            # 添加激活函数和 Dropout
            # 不要把同一个 activation 实例重复使用, 对于有状态的激活函数会出问题, 应为每层创建独立的实例
            layers.append(activation())
            if dropout_rate > 0:
                layers.append(nn.Dropout(dropout_rate))
                
            # 更新前一层维度    
            prev_dim = h_dim
            
        # 输出层 (不加激活,因为 CrossEntropyLoss 包含 Softmax)
        layers.append(nn.Linear(prev_dim, output_dim))
        
        # 将列表转为 Sequential 容器
        # 需要将其注册为 Module 的子模块 
        # 以便参数能被正确识别和更新
        self.network = nn.Sequential(*layers)
        
    def forward(self, x):
        return self.network(x)

一些需要注意的细节:

1,Sequential函数的调用:为什么要把 layers 列表转换为 nn.Sequential?
  • 注册子模块和参数:直接把模块放到普通 Python 列表不会将它们注册为 nn.Module 的子模块。用 nn.Sequential 包装后,子模块及其参数会被注册,model.parameters() 和优化器能识别它们。
  • 定义前向执行顺序:nn.Sequential 会按顺序应用其子模块,调用 self.network(x) 会依次执行 Linear、激活、Dropout 等,无需自写 forward 循环。
  • 支持模块生命周期操作:model.to(device)、model.eval()/train()、model.state_dict()、torch.save/load 等在子模块被注册后都能正常工作。
  • 可读性与维护性更好:网络结构清晰、紧凑,便于扩展和调试。
  • 备选项与差异:
    • nn.ModuleList 会注册模块但不实现顺序前向,需要在 forward 中手动迭代并调用;
    • 纯 Python 列表既不注册模块也不定义前向,参数对优化器不可见;
    • nn.ModuleDict 适合命名子模块,但同样不实现顺序前向行为。

事实上,pytorch官网中也比较了nn.Sequential和nn.ModuleDict,

https://docs.pytorch.org/docs/stable/generated/torch.nn.Sequential.html

2,forward函数中为什么只需要写一行,我在调用self.network时发生了什么?
  • 本质:nn.Sequential 把一组子模块包装成一个可调用的 nn.Module。self.network = nn.Sequential(*layers) 会把 layers 中的每个子模块注册为当前模块的子模块(parameters/buffers/children 被正确跟踪),并实现了按顺序把输入张量传递给每个子模块的逻辑。
  • 当我们在 forward 中写 self.network(x) 时,实际发生的是:
    1. 调用 nn.Module.call(处理 hooks、register/grad 等),然后执行 Sequential.forward。
    2. Sequential.forward 大致等价于:
      for module in self._modules.values():
      x = module(x)
      return x
      (即把 tensor 依次送进每个子模块,前一层的输出成为后一层输入)
    3. 所有子模块的参数、状态会被 .to(device)/.train()/.eval()/state_dict() 等递归操作影响。
  • 与手写 attribute + forward 的关系:

我们前面的简易版pytorch代码,基本上都是在forward函数中手动按照顺序调用每一个全连接层:

python 复制代码
# custom NN inheriting from nn.Module
class MyModel(nn.Module):
    def __init__(self):
        # 在子类初始化中调用父类构造器
        super(MyModel, self).__init__()
        # 而且必须在注册子模块,也就是搭建网络结构前调用

        # first dense (fully connected) layer with 2 input features, 64 output neurons
        self.dense1 = nn.Linear(2, 64)
        # define activation function (ReLU) for first hidden layer
        self.relu1 = nn.ReLU()
        # # second dense layer with 64 input neurons, 3 output neurons
        self.dense2 = nn.Linear(64, 3)

    # forward pass
    # computes output of model given input data x
    def forward(self, x):
        # pass input x through first dense layer
        x = self.dense1(x)
        # apply ReLU activation function to output of first dense layer
        x = self.relu1(x)
        # pass output of ReLU activation function through second dense layer
        x = self.dense2(x)
        # return output of second dense layer
        return x
复制代码
- 等价性:功能上等价------我们可以把每一层注册为属性(self.dense1, self.relu1, ...)并在 forward 中逐步调用,得到完全相同的计算图和参数管理。
- 优点(Sequential):代码更简洁、构建动态网络更方便、按列表顺序自动执行;适合"线性串联"模块。
- 限制(手写/复杂场景):如果需要分支、跳跃连接、多个输入/输出或某些层需要额外参数,手写 forward 或使用 nn.ModuleList 更灵活。
- 注册差别:把模块放进普通 Python list(self.layers = [...])不会自动注册;nn.Sequential/nn.ModuleList/赋给 self.attr 才会注册。

示例(等价写法):

python 复制代码
# python
seq = nn.Sequential(nn.Linear(10, 20), nn.ReLU(), nn.Linear(20, 5))
# 等价于
class M(nn.Module):
    def __init__(self):
        super().__init__()
        self.l1 = nn.Linear(10,20)
        self.a1 = nn.ReLU()
        self.l2 = nn.Linear(20,5)
    def forward(self, x):
        x = self.l1(x)
        x = self.a1(x)
        x = self.l2(x)
        return x

总而言之,nn.Sequential 只是把"逐层调用"的样板代码封装并确保子模块被正确注册与管理,传入 x 时按顺序把张量传递给每一层并返回最终输出。

4,训练逻辑(src/trainer.py)

前面讲过,我们的训练路逻辑并没有写在model定义中,

我们的训练逻辑最好是放在单独的一个trainer.py文件中。

对于前面简易版的pytorch代码,如下:

python 复制代码
# Loss and optimizer
loss_function = nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters(), lr=0.05, weight_decay=5e-7)


# Train in loop
for epoch in range(10001):
    # Forward pass
    # 前向传播数据, 本质call是调用子类model的forward方法
    outputs = model(X)

    # Calculate the loss
    # 计算loss
    loss = loss_function(outputs, y)

    # Zero gradients, backward pass, and optimize
    # 先梯度清零, 防止不同batch的参数更新建议不一致
    optimizer.zero_grad()
    # 对于当前loss反向传播, 计算对参数的梯度(动量和当前梯度)
    loss.backward()
    # 利用动量和当前梯度更新参数
    optimizer.step()

    # Calculate accuracy
    # 只要索引值, 作为label
    _, predicted = torch.max(outputs, 1)  
    accuracy = (predicted == y).float().mean()

    # Print epoch, accuracy, loss, learning rate every 100 epochs
    if epoch % 100 == 0:
        print(f'Epoch: {epoch}, Accuracy: {accuracy.item():.3f}, Loss: {loss.item():.3f}, Learning Rate: {optimizer.param_groups[0]["lr"]}')

可以看到,我们的损失函数(metric)、优化器都散落在外面,

然后我们手动裸写了for循环,非常不便于管理。

正常的写trainer类的话,我们应该输入model实例、optimizer优化器、criterion损失函数等。

然后接下来就是标准的训练步(5步法):

python 复制代码
# 零、前、损、反、更

# 1, 梯度清零
optimizer.zero_grad()

# 2, 前向计算输出
output = model(x)

# 3, 计算loss
loss = criterion(out,y)

# 4, 反向传播梯度
loss.backward()

# 5, 更新参数
optimizer.step()

另外,因为从trainer这个类开始,我们的model架构其实就已经不是纸上谈兵了,所以需要兼顾现实的硬件设备之类。

比如说使用CPU还是用GPU,也就是Device管理,需要能够自动处理.to(device),也就是支持代码在CPU和GPU之间无缝切换。

要实现的核心部分代码就是单轮epoch训练:

python 复制代码
    def train_epoch(self, dataloader):
        """
        Description
        -----------
        训练单个 Epoch (遍历所有 Batch)

        Args
        -----
        dataloader : torch.utils.data.DataLoader
            训练数据的 DataLoader
        
        Returns
        -------
        avg_loss : float
            平均损失
        avg_acc : float
            平均准确率
        """
        self.model.train() # 开启训练模式 (启用 Dropout/BatchNorm)
        total_loss = 0
        correct = 0
        total = 0
        
        for X_batch, y_batch in dataloader:
            # 1. 搬运数据到 GPU/CPU
            X_batch, y_batch = X_batch.to(self.device), y_batch.to(self.device)
            
            # 2. 梯度清零
            self.optimizer.zero_grad()
            
            # 3. 前向传播
            outputs = self.model(X_batch)
            
            # 4. 计算损失
            loss = self.criterion(outputs, y_batch)
            
            # 5. 反向传播
            loss.backward()
            
            # 6. 更新参数
            self.optimizer.step()
            
            # 统计
            total_loss += loss.item()
            _, predicted = torch.argmax(outputs, dim=1)
            total += y_batch.size(0)
            correct += (predicted == y_batch).sum().item()
            
        avg_loss = total_loss / len(dataloader)
        avg_acc = correct / total
        return avg_loss, avg_acc

一些需要注意的地方:

  • to(device):我们需要将整个model(包含所有parameters和buffers)递归地移动到指定设备(cpu或cuda上),.to(device)会返回同一个模块的引用,参数会被实际移动到目标设备,从此模型的forward/参数都在该device上。这样后续传入同设备的输入可以避免设备不匹配错误,同时.to(device)会影响 state_dict()/保存/加载行为。 除了模型,我们也需要将训练数据,也就是x_batch、y_batch这两个张量移动到trainer指定的设备(self.device),以便与model在同一设备上做前向/反向传播。
  • optimizer.param_groups 是包含若干参数组的列表(每组是 dict),常见情况只有一个组;['lr'] 是该组当前使用的学习率。

总得来说,trainer训练器主要是封装"模型 + 优化器 + 损失 + 训练循环"的细节:数据搬运(device)、前向、损失、反向、参数更新、统计与日志。

  • 为什么要单独写 trainer/train_epoch和fit
    • 去重:把每次训练的通用流程抽象,避免到处复制相同的 for-loop。
    • 统一设备/参数管理:统一调用 model.to(device)、optimizer 管理、state_dict 保存/加载。
    • 可扩展性:方便加入学习率调度、checkpoint、early stopping、日志、metric 记录、混合精度、分布式训练等。
    • 可测试/复现:集中配置(seed、optimizer 参数、scheduler)更便于单元测试和重现实验。
    • 易用性:上层只需要准备 dataloader 和超参即可调用 fit,适合实验与流水线。
  • 训练器的典型职责(可扩展)
    • 数据搬运(CPU/GPU)、batch loop、前向/反向/step。
    • 统计与指标(loss/acc)、日志打印、可视化(TensorBoard)。
    • Checkpoint(保存/恢复模型与优化器状态)。
    • LR scheduler、gradient clipping、mixed precision(AMP)。
    • Callback 机制(早停、评估、保存策略)。
    • 支持分布式训练/多卡(DataParallel/DistributedDataParallel)。

总之常见做法就是将(单步/单epoch的训练逻辑)和(多epoch的调度/管理逻辑)分离:

  • train_step/train_batch:一批数据(1个batch)的前向------》loss------》backward------》step,这无疑是最细粒度
  • train_epoch:遍历dataloader,调用train_batch,收集指标,这是单epoch的逻辑
  • validate/test:独立的评估函数(不反向传播)
  • 上面写的fit,或者更一般会写成trainer.run函数之类,其实就是epoch的外层循环,按epoch调用train_epoch/validate,处理checkpoint、lr scheduler、early stopping、日志log、回调callback等等等

当然我们实际中并不是简单指定1个epoch总数,然后遍历完所有的epoch再停止。

不只靠"固定 epoch 数"训练,而是使用更常用的停止/控制机制:

  • 验证集早停(Early Stopping):监控 validation loss/metric,若在 patience 个 epoch 内无改善则停止(最常用)。
  • 学习率调度器(LR Scheduler):ReduceLROnPlateau 等根据 val loss 降低 lr,配合早停更稳。
  • 最大步数 / 时间预算:按 max_steps、max_hours 或 GPU 预算停止(生产环境常用)。
  • 指标收敛判定:loss 变化小于阈值或梯度范数 < eps 时停止。
  • 检查点与回滚:保存最优模型(based on val),训练可在恢复点继续或回滚。
  • 自动调参/搜索停止:基于超参调度器(如 Optuna)自动终止不良试验。

那么,一般如何知道多少 epoch?

  • 用验证曲线(loss/metric vs epoch)观察是否过拟合或未收敛;通常先给一个上限,用早停自动结束。
  • 开始实验可用较大上限 + 早停(patience 5~10 常见),结合 ReduceLROnPlateau。
  • 小数据/快速收敛任务 epoch 少,复杂任务/大数据可能几百或按步数控制。

总的来说:工程中通常同时使用验证集 + ReduceLROnPlateau + EarlyStopping + checkpoint(保存最优模型)来自动管理何时停止训练。

另外对于trainer,一般真实项目通常会有训练封装,自写 Trainer 或使用成熟库(PyTorch Lightning 的 Trainer、HuggingFace Trainer等)。自定义 Trainer 便于满足特定需求,框架化 Trainer 则省时且功能完备。

此处举1个例子:

用的就是lightning框架里的成熟的trainer函数

python 复制代码
from lightning import Callback, LightningDataModule, LightningModule, Trainer

看了比较多的计算生物学结构方面的深度学习项目model,见到比较多的项目配置都是:PyTorch Lightning 的训练流程与 Hydra 的配置管理结合。

目的其实都是一样的,为了实现训练参数的灵活配置、实验复现和参数搜索。核心是通过 Hydra 管理模型、数据、训练器、回调等所有配置项,避免硬编码参数。

参考https://github.com/ashleve/lightning-hydra-template

这个是我个人常用的样板:

  1. PyTorch Lightning 负责封装训练逻辑
    • 把 PyTorch 繁琐的训练循环(前向传播、梯度更新、设备分发等)抽象成 <font style="color:rgb(255, 255, 255);background-color:rgb(51, 51, 51);">LightningModule</font>
    • 把数据加载流程抽象成 <font style="color:rgb(255, 255, 255);background-color:rgb(51, 51, 51);">LightningDataModule</font>
    • <font style="color:rgb(255, 255, 255);background-color:rgb(51, 51, 51);">Trainer</font> 统一调度训练生命周期,用 <font style="color:rgb(255, 255, 255);background-color:rgb(51, 51, 51);">Callback</font> 插入自定义钩子逻辑
    • 开发者只需关注模型算法和数据处理的核心代码
  2. Hydra 负责管理配置参数
    • 把所有可配置项(模型超参、数据路径、训练器参数、回调参数)分层写在 YAML 文件中
    • 支持配置组合和命令行覆盖,无需修改代码即可调整实验参数
    • 自动生成实验日志目录,方便复现结果

我们这部分的代码模板如下

5,

python 复制代码
# src/trainer.py
import torch
import torch.nn as nn
import torch.optim as optim

class Trainer:
    """
    Description
    -----------
    训练器 (Trainer) 逻辑, model实际训练流程封装
    对比:
      - 简易版:裸写 for 循环,代码很难复用,换个模型又要重写
      - 工业版:封装成类。管理 model, optimizer, criterion
        支持 'mini-batch' 循环 (loader),支持 device 切换 (GPU)
    """
    def __init__(self, model, optimizer=None, criterion=None, device='cpu'):
        """
        Description
        -----------
        初始化训练器

        Args
        -----
        model : torch.nn.Module
            待训练的神经网络模型
        optimizer : torch.optim.Optimizer, optional
            优化器,默认 Adam
        criterion : torch.nn.Module, optional
            损失函数,默认 交叉熵损失
        device : str, optional
            设备,'cpu' 或 'cuda',默认 'cpu'
        
        Returns
        -------
        None
        
        """
        self.model = model.to(device)
        self.device = device
        # 默认使用交叉熵损失 (分类任务标准)
        self.criterion = criterion if criterion else nn.CrossEntropyLoss()
        # 默认使用 Adam
        self.optimizer = optimizer if optimizer else optim.Adam(model.parameters(), lr=0.001)
        # 训练历史记录
        self.history = {'loss': [], 'acc': []}

    def train_epoch(self, dataloader):
        """
        Description
        -----------
        训练单个 Epoch (遍历所有 Batch)

        Args
        -----
        dataloader : torch.utils.data.DataLoader
            训练数据的 DataLoader
        
        Returns
        -------
        avg_loss : float
            平均损失
        avg_acc : float
            平均准确率
        """
        self.model.train() # 开启训练模式 (启用 Dropout/BatchNorm)
        total_loss = 0
        correct = 0
        total = 0
        
        for X_batch, y_batch in dataloader:
            # 1. 搬运数据到 GPU/CPU
            X_batch, y_batch = X_batch.to(self.device), y_batch.to(self.device)
            
            # 2. 梯度清零
            self.optimizer.zero_grad()
            
            # 3. 前向传播
            outputs = self.model(X_batch)
            
            # 4. 计算损失
            loss = self.criterion(outputs, y_batch)
            
            # 5. 反向传播
            loss.backward()
            
            # 6. 更新参数
            self.optimizer.step()
            
            # 统计
            total_loss += loss.item()
            _, predicted = torch.argmax(outputs, dim=1)
            total += y_batch.size(0)
            correct += (predicted == y_batch).sum().item()
            
        avg_loss = total_loss / len(dataloader)
        avg_acc = correct / total
        return avg_loss, avg_acc

    def fit(self, dataloader, epochs, print_every=10):
        """ 
        Description
        -----------
        全流程训练入口, 前面只是单 epoch 训练, 这里是多 epoch 循环

        Args
        -----
        dataloader : torch.utils.data.DataLoader
            训练数据的 DataLoader
        epochs : int
            训练轮数
        print_every : int, optional
            每隔多少轮打印一次日志, 默认 10

        Returns
        -------
        history : dict
            训练历史记录,包含 'loss' 和 'acc' 列表
        
        """
        for epoch in range(epochs):
            loss, acc = self.train_epoch(dataloader)
            
            self.history['loss'].append(loss)
            self.history['acc'].append(acc)
            
            if (epoch) % print_every == 0:
                # 获取当前学习率用于打印
                current_lr = self.optimizer.param_groups[0]['lr']
                print(f'Epoch: {epoch}, Accuracy: {acc:.3f}, Loss: {loss:.3f}, LR: {current_lr}')
                
        return self.history

5,推理逻辑(src/predict.py)

我们前面的简易版pytorch中其实并没有额外准备推理模块,我们只是在另外的测试数据上进行了性能演示

python 复制代码
# create meshgrid of points covering the feature space
h = 0.02
# determine min and max values for x,y axes
x_min, x_max = X[:, 0].min() - 1, X[:, 0].max() + 1
y_min, y_max = X[:, 1].min() - 1, X[:, 1].max() + 1

# create meshgrid of points with spacing h
xx, yy = np.meshgrid(np.arange(x_min, x_max, h),
                     np.arange(y_min, y_max, h))

# convert meshgrid to torch tensor
meshgrid_points = torch.tensor(np.c_[xx.ravel(), yy.ravel()], dtype=torch.float32)

# pass meshgrid points through model
with torch.no_grad():
  # forward pass through first dense
  z1 = model.dense1(meshgrid_points)
  # apply relu activation
  a1 = torch.relu(z1)
  # forward pass through second dense
  z2 = model.dense2(a1)
  # compute softmax probabilities for each class
  exp_scores = torch.exp(z2 - torch.max(z2, axis=1, keepdim=True).values)
  probs = exp_scores / torch.sum(exp_scores, axis=1, keepdim=True)

# predictions
# determine predicted class for each point in meshgrid
_, predictions = torch.max(probs, axis=1)
# reshape predictions to match shape of meshgrid
Z = predictions.numpy().reshape(xx.shape)

# plot decision boundary based on predictions
plt.contourf(xx, yy, Z, cmap='brg', alpha=0.8)

# plot original data on top of decision boundary
plt.scatter(X[:, 0], X[:, 1], c=y, s=40, cmap='brg')
# plot limits set to match extent of meshgrid
plt.xlim(xx.min(), xx.max())
plt.ylim(yy.min(), yy.max())
plt.show()

也就是我们没有独立的推理模块,只是在训练过程中顺便直接 model(X) 得到预测结果、简单地打印accuracy。

以下是我们的改良模板

python 复制代码
# src/predict.py
import torch
import numpy as np

def predict(model, X, device='cpu'):
    """
    Description
    -----------
    推理逻辑, 在测试/预测阶段使用

    对比:
      - 简易版:直接 model(X) 得到预测结果
      - 工业版:
        1. model.eval() 关闭 Dropout/BatchNorm 随机性
        2. torch.no_grad() 关闭梯度计算引擎,节省显存并加速
        3. 处理 device (GPU -> CPU) 和 tensor -> numpy 转换

    Args
    ----
    model : torch.nn.Module
        训练好的模型
    X : np.ndarray
        原始数据, 输入特征数据,形状为 (num_samples, num_features), 即 batch 数据
    device : str
        运行设备,'cpu' 或 'cuda'
    """
    
    # 将model切换到评估模式, 关闭 Dropout 和 BatchNorm 的随机性, 确保推理结果稳定
    model.eval() # 切换评估模式
    # 将输入数据转换为 tensor 并移动到指定设备, 测试数据
    X_tensor = torch.as_tensor(X, dtype=torch.float32).to(device)
    # 或X_tensor = torch.tensor(X, dtype=torch.float32).to(device)
    
    # 推理阶段不需要计算梯度, 测试/预测阶段使用
    # 关闭 autograd 追踪,节省显存并加速前向推理(不需要梯度)
    with torch.no_grad(): # 上下文管理器,禁止梯度计算
        logits = model(X_tensor)
        # 如果需要概率,手动加 Softmax (因为模型输出是 logits)
        probs = torch.softmax(logits, dim=1)
        # 获取最大概率的类别索引
        predictions = torch.argmax(probs, dim=1)
        
    # 将结果从 GPU 移动到 CPU 并转换为 numpy 数组返回, 方便后续与sklearn等后续cpu上处理库兼容
    return predictions.cpu().numpy()

简化之后就是

python 复制代码
model.eval()
X_tensor = torch.as_tensor(X, dtype=torch.float32).to(device)
with torch.no_grad():
    logits = model(X_tensor)
    probs = torch.softmax(logits, dim=1)
    preds = torch.argmax(probs, dim=1)

主要需要注意的地方有2个:

1,model.eval():将model切到评估模式,也就是相当于设置每个子模块的training=False。

主要影响的层:Dropout停用(不随机丢弃),BatchNorm使用running_mean/running_var(不更新统计,也不使用当前batch的均值方差,因为训练集的数据分布和测试集的数据分布不一致,归一化时的均值、方差参数偏差会比较大,会影响model推理)。

一般就是推理/验证阶段必须调用,保证行为确定且与训练统计分离。

不影响下面提到的梯度计算开关,也就是不会关闭autograd,不会改变参数的requires_grad。

2,torch.no_grad():上下文管理器,临时关闭autograd的梯度跟踪与计算题构建。

一般就是为了在推理/验证阶段节省显存、加速前向、避免不必要的计算图,在no_grad内对张量的操作不会记录到计算图,不能用于backgrad。

不影响前面model的train/eval模式,不会切换BatchNorm/Dropout,也不改变参数的requires_grad标志,只是临时不追踪运算。

6,辅助脚本(src/utils.py)

其实除了上面的model架构、训练逻辑、推理逻辑之外,其余的一些函数、脚本、数据处理技巧,我们都可以放到辅助脚本文件中,

此处以前面的简易版pytorch代码为例,我们就可以将加载配置文件、监控训练过程(比如说绘制训练过程中的损失以及正确率等指标)等在这里进行设置。

我们此处简单的示例:

python 复制代码
# src/utils.py
import yaml
import matplotlib.pyplot as plt

def load_config(config_path):
    """
    Description
    -----------
    加载 YAML 配置文件

    Args
    -----
    config_path : str
        配置文件路径
    
    """
    with open(config_path, 'r') as f:
        return yaml.safe_load(f)

def plot_history(history):
    """
    Description
    -----------
    绘制训练过程中的损失和准确率曲线

    Args
    -----
    history : dict
        训练历史记录,包含 'loss' 和 'acc' 两个列表
    """
    plt.figure(figsize=(10, 4))
    plt.subplot(1, 2, 1)
    plt.plot(history['loss'], label='Loss')
    plt.title('Loss Curve')
    plt.legend()
    
    plt.subplot(1, 2, 2)
    plt.plot(history['acc'], label='Accuracy', color='orange')
    plt.title('Accuracy Curve')
    plt.legend()
    plt.show() 
    # plt.savefig('training_result.png')

7,主板(main.py

前面定义了各种class类和模块,现在我们需要按照实际训练model的步骤以及逻辑,像搭积木一样,将所有的文件以及流程都串起来。

比如说,读取config文件,然后实例化Dataset、Model、Trainer,再将它们串起来。

简单来说就像是项目的入口,只要别人一看我们的代码中的main.py就能够知道数据怎么流向模型。

为了更像工程化的深度学习项目靠拢,我们这里对前面搭建的几个基本模块进行一些增量修改,但是主要功能、主要逻辑以及主要流程以及职责是不变的,这个是我们过渡到pytorch工业级深度学习项目所需要关注的。

数据输入处理方面:

python 复制代码
# src/dataset.py
import torch
import numpy as np
import nnfs
from nnfs.datasets import spiral_data
from torch.utils.data import Dataset, DataLoader, random_split

# nnfs.init() # 通常在主程序入口调用

class SpiralDataset(Dataset):
    """
    Description
    ---------
    自定义 Dataset 类:负责从数据源读取并封装单个样本;
    必须继承 torch.utils.data.Dataset, 实现 __len__ 和 __getitem__,
    对于大数据集, __getitem__ 只在需要时加载单个文件 (Lazy Loading),节省内存
    """
    def __init__(self, samples=100, classes=3):
        """
        Description
        ---------
        构造函数:初始化数据集
        
        Args
        ---------
        samples : int
            每个类别的样本数
        classes : int
            类别数
        
        Returns
        ---------
        None
        """
         
        # 实际项目中,这里通常接收文件路径 list,而不是直接生成数据
        X, y = spiral_data(samples=samples, classes=classes)
        self.X = torch.tensor(X, dtype=torch.float32)
        self.y = torch.tensor(y, dtype=torch.long)
        
    def __len__(self):
        return len(self.X)
    
    def __getitem__(self, idx):
        # 实际项目中,这里可能包含实时的数据增强 (Augmentation)
        # 例如: torchvision.transforms
        return self.X[idx], self.y[idx]

def get_dataloader(samples, classes, batch_size=32, shuffle=True, val_split=0.0):
    """
    Description
    ---------
    构建并返回 DataLoader, DataLoader负责batch批量加载数据/切分/shuffle打乱/多进程预取等
    
    Args
    ---------
    samples : int
        每个类别的样本数
    classes : int
        类别数
    batch_size : int or None
        批量大小; None 表示全量梯度下降
    shuffle : bool
        是否打乱数据顺序
    val_split : float
        验证集比例 (0.0 ~ 1.0)
    
    Returns
    ---------
    DataLoader or (DataLoader, DataLoader)
        Train DataLoader, [Val DataLoader]


    Notes
    ---------
    - 1. 不只返回一个 loader, 通常需要 Train/Val/Test split, 返回多个dataloader
    - 2. 使用 num_workers 进行多进程预取: 待补充
    - 3. 使用 pin_memory 加速 Host-to-Device 传输 (如果用 GPU): 待补充
    """
    full_dataset = SpiralDataset(samples, classes)
    
    # 全量梯度下降情况
    if batch_size is None:
        batch_size = len(full_dataset)
        
    if val_split > 0:
        val_size = int(len(full_dataset) * val_split)
        train_size = len(full_dataset) - val_size
        train_dataset, val_dataset = random_split(full_dataset, [train_size, val_size])
        
        train_loader = DataLoader(train_dataset, batch_size=batch_size, shuffle=shuffle)
        # 验证集通常不shuffle
        val_loader = DataLoader(val_dataset, batch_size=batch_size, shuffle=False)
        return train_loader, val_loader
    else:
        # 或者不显示给出shuffle参数, 在具体示例时再分别调整修改
        return DataLoader(full_dataset, batch_size=batch_size, shuffle=shuffle)

model架构方面

python 复制代码
# src/model.py
import torch
import torch.nn as nn

class UniversalMLP(nn.Module):
    """
    Description
    ------------
    通用多层感知机 (MLP) 模板, 适用于分类/回归任务, 可用于进一步扩展(适用于tabular数据等)
    

    Notes
    ------------
    - 1. 不变的地方: 只写init定义层, forward定义流向, backward自动完成自动微分处理
    - 2. 支持字符串指定激活函数, 也就是添加多种激活函数选择
      - 增加权重初始化 (_init_weights)
    """
    def __init__(self, input_dim, hidden_dims, output_dim, activation='relu', dropout_rate=0.0):
        """ 
        Description
        ------------
        初始化多层感知机, 动态构建隐藏层
        
        Args
        -----
        input_dim : int
            输入特征维度
        hidden_dims : list of int
            隐藏层维度列表, 每个元素代表一层的神经元数量
        output_dim : int
            输出维度 (类别数)
        activation : str or nn.Module
            激活函数模块名称('relu', 'tanh', 'sigmoid', 'leaky_relu') 或 nn.Module 类, 默认使用 ReLU
        dropout_rate : float
            Dropout 比例, 默认为 0.0 (不使用 Dropout)
        
        Returns
        -------
        None

        """
        
        # 调用父类构造函数
        super(UniversalMLP, self).__init__()
        
        layers = []
        prev_dim = input_dim
        
        # 激活函数选择:支持字符串或类
        def get_activation(act):
            if isinstance(act, str):
                act = act.lower()
                if act == 'relu': return nn.ReLU()
                if act == 'tanh': return nn.Tanh()
                if act == 'sigmoid': return nn.Sigmoid()
                if act == 'leaky_relu': return nn.LeakyReLU()
                raise ValueError(f"Unsupported activation string: {act}")
            # 检查是否是元类(类的类), 或者是否继承自nn.Module
            elif isinstance(act, type) and issubclass(act, nn.Module):
                return act() # 实例化
            else:
                return act # 假设已经是实例,注意深拷贝问题,但在 Sequential 中主要关注每层是否独立

        # 动态构建隐藏层
        for h_dim in hidden_dims:
            layers.append(nn.Linear(prev_dim, h_dim))
            # BatchNorm 通常放在 Activation 之前 (ResNet v1) 或之后 (ResNet v2),这里演示放在前面, 也就是Linear和Activation之间
            # layers.append(nn.BatchNorm1d(h_dim)) 
            
            # 添加激活函数和Dropout
            # 确保每次都生成一个新的 Activation 实例, 为每一层创建独立的实例, 不要把同一个activation实例重复使用在多层
            layers.append(get_activation(activation))
            if dropout_rate > 0:
                layers.append(nn.Dropout(dropout_rate))
                
            # 更新前一层维度    
            prev_dim = h_dim
            
        # 输出层 (不加激活,因为 CrossEntropyLoss 包含 Softmax)
        layers.append(nn.Linear(prev_dim, output_dim))
        
        # 将列表转为 Sequential 容器
        # 将各模块注册为 Module 的子模块, 以便参数能被正确识别和更新
        self.network = nn.Sequential(*layers)
        
        # 显示调用初始化
        # 对全连接线性层权重进行初始化, 默认初始化可能导致训练不稳定/梯度消失/爆炸
        # 我们只有全连接层, 所以这里只处理 nn.Linear; 我们只用 ReLU 激活函数, 所以选择 Kaiming 初始化
        self.network.apply(self._init_weights)
        
    def _init_weights(self, m):
        """
        Description
        -----------
        权重初始化方法, Kaiming / Xavier 初始化,比默认初始化收敛更快

        Args
        ----
        m : nn.Module
            模块实例, 通常是 nn.Linear, nn.Conv2d 等

        Notes
        -----
        - 1. 初始化没做好容易梯度消失/爆炸,导致训练失败
        - 2. Kaiming 初始化适合 ReLU 激活函数, Xavier 初始化适合 Sigmoid/Tanh 激活函数
        - 3. 因为我们整个网络只包含全连接层, 所以这里只处理 nn.Linear, 忽略其他类型模块; 然后因为我们使用的是ReLU激活函数,
        所以我们选择 Kaiming 初始化方法
        """
        # 只初始化全连接线性层
        if isinstance(m, nn.Linear):
            # Kaiming He 初始化 (适合 ReLU)
            # Kaiming正态分布初始化, 针对ReLU类激活函数设计的权重初始化方法, 避免深层网络训练时出现梯度消失/爆炸问题
            # fan_out 保证反向传播时梯度方差的量级稳定(输出维度决定初始化范围)
            nn.init.kaiming_normal_(m.weight, mode='fan_out', nonlinearity='relu')
            if m.bias is not None:
                # 对全连接层偏置初始化为0
                nn.init.constant_(m.bias, 0)
        
    def forward(self, x):
        """
        Description
        -----------
        前向传播逻辑

        Args
        ----
        x : torch.Tensor
            输入特征张量, 形状为 (batch_size, input_dim), 即 (批大小, 输入特征维度)
        
        Returns
        -------
        torch.Tensor
            输出张量, 形状为 (batch_size, output_dim), 即 (批大小, 输出维度/类别数)
        """
        return self.network(x)

训练逻辑方面

python 复制代码
# src/trainer.py
import torch
import torch.nn as nn
import torch.optim as optim
import logging
import os

# 获取 logger
logger = logging.getLogger(__name__)

class EarlyStopping:
    """
    Description
    -----------
    早停 (Early Stopping) 逻辑封装, 当验证集损失在 patience 个 epoch 内没有降低时,停止训练

    """
    def __init__(self, patience=5, min_delta=0, path='best_model.pth'):
        """  
        Description
        -----------
        初始化早停参数

        Args
        -----
        patience : int
            容忍的最大不提升 epoch 数 (轮数, 即多少个 epoch 内验证集损失没有降低则停止训练)
        min_delta : float
            最小提升幅度 (即验证集损失必须降低至少 min_delta 才算提升)
        path : str
            最佳模型保存路径
        
        Returns
        -------
        None
        
        """
        self.patience = patience
        self.min_delta = min_delta
        self.path = path
        # 没有提升的 epoch 计数器
        self.counter = 0
        # 最佳损失初始化为 None
        self.best_loss = None
        # 是否触发早停
        self.early_stop = False

    def __call__(self, val_loss, trainer, epoch=None):
        if self.best_loss is None:
            self.best_loss = val_loss
            # 首次记录,保存为最佳模型
            trainer.save_checkpoint(self.path, is_best=True, epoch=epoch)
        elif val_loss > self.best_loss - self.min_delta:
            self.counter += 1
            logger.info(f"EarlyStopping counter: {self.counter} out of {self.patience}")
            if self.counter >= self.patience:
                self.early_stop = True
        else:
            self.best_loss = val_loss
            # 发现更优模型,保存
            trainer.save_checkpoint(self.path, is_best=True, epoch=epoch)
            self.counter = 0

class Trainer:
    """
    Description
    -----------
    训练器 (Trainer) 逻辑封装, 包含训练循环、评估、保存模型等功能, 也就是model实际训练流程封装
    
    Notes
    -----------
    - 1. 改进的地方:
      - 使用 logging 替代 print, 方便日志管理, 相比于print, logging 可以灵活配置输出格式和级别, 
      可以将日志文件同时输出到控制台+文件, 便于调试和记录训练过程
      - 增加 save_checkpoint 和 load_checkpoint
      - 支持 验证集评估 (evaluate)
      - 支持 早停 (Early Stopping) 逻辑
    """
    def __init__(self, model, optimizer=None, criterion=None, device='cpu', save_dir='checkpoints'):
        """  
        Description
        -----------
        初始化训练器

        Args
        -----
        model : torch.nn.Module
            待训练的模型
        optimizer : torch.optim.Optimizer, optional
            优化器实例, 如果为 None 则使用 Adam 优化器
        criterion : torch.nn.Module, optional
            损失函数实例, 如果为 None 则使用 CrossEntropyLoss, 即默认为分类任务使用的交叉熵损失函数
        device : str, optional
            运行设备, 'cpu' 或 'cuda'
        save_dir : str, optional
            模型检查点保存目录, 检查点checkpoint数据, 包含模型权重和训练过程中的上下文信息, 目的是让训练可以诶断点续训或复用中间状态

        Returns
        -------
        None    
        """
        
        self.model = model.to(device)
        self.device = device
        # 默认使用交叉熵损失 (分类任务标准)
        self.criterion = criterion if criterion else nn.CrossEntropyLoss()
        # 默认使用 Adam
        self.optimizer = optimizer if optimizer else optim.Adam(model.parameters(), lr=0.001)
        # 初始化历史记录,确保即使 resume 也能接续
        self.history = {'loss': [], 'acc': [], 'val_loss': [], 'val_acc': []}
        self.save_dir = save_dir
        
        # 确保保存目录存在
        os.makedirs(self.save_dir, exist_ok=True)

    def train_epoch(self, dataloader):
        """
        Description
        -----------
        训练单个 Epoch (遍历所有 Batch)

        Args
        -----
        dataloader : torch.utils.data.DataLoader
            训练数据的 DataLoader
        
        Returns
        -------
        avg_loss : float
            平均损失
        avg_acc : float
            平均准确率
        """
        # 开启训练模式 (启用 Dropout/BatchNorm), 注意与后面的model.eval()区分, 后者是评估模式
        self.model.train() 
        total_loss = 0
        correct = 0
        total = 0
        
        for X_batch, y_batch in dataloader:
            # 1. 搬运数据到 GPU/CPU
            X_batch, y_batch = X_batch.to(self.device), y_batch.to(self.device)
            
            # 2. 梯度清零
            self.optimizer.zero_grad()
            
            # 3. 前向传播
            outputs = self.model(X_batch)
            
            # 4. 计算损失
            loss = self.criterion(outputs, y_batch)
            
            # 5. 反向传播
            loss.backward()
            
            # 6. 更新参数
            self.optimizer.step()
            
            # 统计
            total_loss += loss.item()
            predicted = torch.argmax(outputs, dim=1)
            # 或者如下, 返回(value, index)
            # _, predicted = torch.max(outputs.data, 1)
            total += y_batch.size(0)
            correct += (predicted == y_batch).sum().item()
            
        avg_loss = total_loss / len(dataloader) if len(dataloader) > 0 else 0
        avg_acc = correct / total if total > 0 else 0
        return avg_loss, avg_acc

    @torch.no_grad()
    def evaluate(self, dataloader):
        """
        Description
        -----------
        验证集评估函数, 使用 @torch.no_grad() 装饰器自动关闭梯度计算,节省显存
        """
        self.model.eval()
        total_loss = 0
        correct = 0
        total = 0
        
        for X_batch, y_batch in dataloader:
            X_batch, y_batch = X_batch.to(self.device), y_batch.to(self.device)
            outputs = self.model(X_batch)
            loss = self.criterion(outputs, y_batch)
            total_loss += loss.item()
            predicted = torch.argmax(outputs, dim=1)
            total += y_batch.size(0)
            correct += (predicted == y_batch).sum().item()
            
        avg_loss = total_loss / len(dataloader) if len(dataloader) > 0 else 0
        avg_acc = correct / total if total > 0 else 0
        return avg_loss, avg_acc

    def save_checkpoint(self, path, epoch=None, is_best=False):
        """
        Description
        -----------
        保存model检查点, 包含模型权重和优化器状态等信息, 以便于断点续训或复用模型

        Args
        -----
            path: 检查点文件保存路径 (如果是相对路径,则相对于 self.save_dir)
            epoch: 当前轮数 (用于断点续传)
            is_best: 是否是最佳模型标记
        """
        # 如果 path 是文件名,则拼接 save_dir
        if not os.path.isabs(path) and os.path.dirname(path) == '':
            path = os.path.join(self.save_dir, path)
            
        state = {
            'epoch': epoch,
            'model_state_dict': self.model.state_dict(),
            'optimizer_state_dict': self.optimizer.state_dict(),
            'history': self.history
        }
        torch.save(state, path)
        logger.info(f"Checkpoint saved to {path}" + (f" (Epoch {epoch})" if epoch else ""))

    def load_checkpoint(self, path):
        """
        Description
        -----------
        加载模型检查点, 恢复模型权重和优化器状态

        Args
        ----
        path : str
            检查点文件路径

                
        Returns
        -------
            start_epoch: 恢复后的起始 epoch (下一轮从 start_epoch 开始)
        """
        if not os.path.exists(path):
            logger.warning(f"Checkpoint file not found: {path} - Starting from scratch.")
            return 1
            
        logger.info(f"Loading checkpoint from {path}...")
        checkpoint = torch.load(path, map_location=self.device)
        
        self.model.load_state_dict(checkpoint['model_state_dict'])
        if self.optimizer and 'optimizer_state_dict' in checkpoint:
            self.optimizer.load_state_dict(checkpoint['optimizer_state_dict'])
            
        # 恢复 history
        if 'history' in checkpoint:
            self.history = checkpoint['history']
            
        # 获取保存时的 epoch,下一轮从 epoch + 1 开始
        ckpt_epoch = checkpoint.get('epoch')
        if ckpt_epoch is None:
            ckpt_epoch = 0 # 防御性编程:如果是 None,则假设从头开始(或这是一个纯权重文件)
        start_epoch = ckpt_epoch + 1
        return start_epoch

    def fit(self, dataloader, epochs, val_dataloader=None, print_every=10, patience=None, resume_from=None):
        """   
        Description
        -----------
        训练模型主函数, 包含多个 Epoch 的训练循环, 可选验证集评估

        Args
        ----
        dataloader : torch.utils.data.DataLoader
            训练数据的 DataLoader
        epochs : int
            训练轮数
        val_dataloader : torch.utils.data.DataLoader, optional
            验证数据的 DataLoader, 如果提供则在每个 epoch 训练结束后同时进行评估
        print_every : int
            每隔多少个 epoch 打印一次日志信息
        patience : int, optional
            早停 Patience (需要 val_dataloader)
        resume_from : str, optional
            检查点路径,用于恢复训练
        
        Returns
        -------
        history : dict
            训练历史记录,包含 'loss' 和 'acc' (以及验证集的 'val_loss' 和 'val_acc' 如果提供了验证集)
        
        Notes
        -----
        - 1. model最后保存的几个检查点:
            - last_checkpoint.pth: 最新的检查点,用于断点续训
            - best_model.pth: 验证集上表现最好的模型 (如果提供了验证集)
            - final_model.pth: 训练结束时的最终模型 (用于归档)
        如果需要使用训练好的model来进行预测, 可以加载 best_model.pth (如果有验证集) 或 final_model.pth
        """
        start_epoch = 1
        # 1. 断点续传逻辑
        
        if resume_from:
            start_epoch = self.load_checkpoint(resume_from)
            
        if start_epoch > epochs:
            logger.info(f"Training already completed (Current Epoch {start_epoch-1} >= Target {epochs}).")
            return self.history

        logger.info(f"Start training on {self.device} from epoch {start_epoch} to {epochs}")
        
        best_acc = 0.0
        # 尝试从历史中恢复 best_acc,避免逻辑中断
        if self.history.get('val_acc'):
             best_acc = max(self.history['val_acc'])

        # 初始化早停 (注意传入完整路径)
        early_stopping_path = os.path.join(self.save_dir, 'best_model.pth')
        early_stopping = EarlyStopping(patience=patience, path=early_stopping_path) if patience else None
        
        for epoch in range(start_epoch, epochs + 1):
            loss, acc = self.train_epoch(dataloader)
            self.history['loss'].append(loss)
            self.history['acc'].append(acc)
            
            val_msg = ""
            if val_dataloader:
                val_loss, val_acc = self.evaluate(val_dataloader)
                self.history['val_loss'].append(val_loss)
                self.history['val_acc'].append(val_acc)
                val_msg = f" | Val Loss: {val_loss:.4f} | Val Acc: {val_acc:.4f}"
                
                # 早停逻辑 (监控 Val Loss)
                if early_stopping:
                    early_stopping(val_loss, self, epoch=epoch)
                    if early_stopping.early_stop:
                        logger.info("Early stopping triggered")
                        break
                else:
                     # 如果没有 early stopping,手动保存 val acc 最高的
                    if val_acc > best_acc:
                        best_acc = val_acc
                        self.save_checkpoint('best_model.pth', epoch=epoch, is_best=True)

            # 定期保存 "最新" 的检查点 (覆盖式,用于断点续传)
            self.save_checkpoint('last_checkpoint.pth', epoch=epoch, is_best=False)

            if epoch % print_every == 0:
                current_lr = self.optimizer.param_groups[0]['lr']
                logger.info(f"Epoch {epoch}/{epochs} | Loss: {loss:.4f} | Acc: {acc:.4f} | LR: {current_lr}{val_msg}")
                
        # 训练结束保存最后一个模型 (可以作为归档)
        self.save_checkpoint('final_model.pth', epoch=epochs, is_best=False)
        return self.history 

辅助脚本utils

python 复制代码
# src/utils.py
import os
import yaml
import random
import logging
import numpy as np
import matplotlib.pyplot as plt
import torch

def setup_logging(log_file='training.log'):
    """
    Description
    -----------
    配置 logging, 让实验过程中的关键信息(如训练进度、评估结果等)同时记录到控制台和文件中,
    方便实时查看和后续回朔查看
    - 同时输出到控制台(Console)和文件(File)
    - 格式: [时间] [级别] 消息
    
    Args
    -----
    log_file : str
        日志文件路径
    """
    logging.basicConfig(
        level=logging.INFO, # 日志级别, 只显示 INFO 及以上级别的日志
        format='[%(asctime)s] [%(levelname)s] %(message)s', # 日志格式
        handlers=[
            logging.StreamHandler(), # 控制台输出
            logging.FileHandler(log_file) # 文件输出
        ]
    )

def seed_everything(seed=2026):
    """
    Description
    -----------
    固定所有随机种子,保证实验可复现 (Reproducibility)
    
    Args
    -----
    seed : int
        随机种子
    """
    # 固定python的random库的随机性(如shuffle等)
    random.seed(seed)
    # 固定python哈希随机性(如dict的遍历顺序)
    os.environ['PYTHONHASHSEED'] = str(seed)
    # 固定numpy库的随机性(如np.random.shuffle等, 随机数生成等)
    np.random.seed(seed)
    # 固定pytorch核心随机性(cpu/单卡场景下的model参数初始化/dropout等)
    torch.manual_seed(seed)
    # 固定pytorch单张gpu的随机性(确保单卡随机操作一致)
    torch.cuda.manual_seed(seed)
    # 固定pytorch多张gpu的随机性(确保多卡随机操作一致)
    torch.cuda.manual_seed_all(seed)
    # 固定 cuDNN 库(GPU 加速库)的算法确定性(避免非确定性算法导致结果波动)
    # 可能会稍微降低性能,但保证确定性(cuDNN算法选择)
    torch.backends.cudnn.deterministic = True
    # 关闭 cuDNN 算法自动调优(避免动态选算法带来的随机性)
    torch.backends.cudnn.benchmark = False

def load_config(config_path, defaults=None):
    """
    Description
    -----------
    加载 YAML 配置文件并与 defaults 递归合并
    
    Args
    -----
    config_path : str
        配置文件路径
    defaults : dict, optional
        默认配置字典,用于补全 config 中缺失的项
    
    Returns
    -------
    dict
        合并后的配置字典
    """
    if not os.path.exists(config_path):
        raise FileNotFoundError(f"Config file not found: {config_path}")
        
    with open(config_path, 'r') as f:
        config = yaml.safe_load(f) or {}

    # 递归合并输入的配置字典和默认的配置字典
    # 仅作为补全使用,config 中已有的键值不被覆盖
    # 仅作为示例,实际项目中可根据需要调整合并逻辑
    def recursive_merge(default_dict, new_dict):
        if not isinstance(default_dict, dict) or not isinstance(new_dict, dict):
            return new_dict
        result = default_dict.copy()
        for k, v in new_dict.items():
            if k in result and isinstance(result[k], dict) and isinstance(v, dict):
                result[k] = recursive_merge(result[k], v)
            else:
                result[k] = v
        return result
        
    if defaults:
        return recursive_merge(defaults, config)
                        
    return config

def plot_history(history, save_path=None, show=True):
    """
    Description
    -----------
    绘制训练过程中的损失和准确率曲线 (包含训练集和验证集)

    Args
    -----
    history : dict
        训练历史记录,包含 'loss', 'acc', 'val_loss', 'val_acc'
    save_path : str, optional
        图片保存路径,如果为 None 则不保存
    show : bool
        是否调用 plt.show() 显示图片 (在无头服务器上设为 False)
    """
    # 辅助转换函数
    def to_cpu_list(data):
        return [x if isinstance(x, (int, float)) else x.item() for x in data]

    loss = to_cpu_list(history.get('loss', []))
    val_loss = to_cpu_list(history.get('val_loss', []))
    acc = to_cpu_list(history.get('acc', []))
    val_acc = to_cpu_list(history.get('val_acc', []))

    plt.figure(figsize=(12, 5))
    
    # 绘制 Loss
    plt.subplot(1, 2, 1)
    if loss: plt.plot(loss, label='Train Loss')
    if val_loss: plt.plot(val_loss, label='Val Loss', linestyle='--')
    plt.title('Loss Curve')
    plt.xlabel('Epochs')
    plt.legend()
    
    # 绘制 Accuracy
    plt.subplot(1, 2, 2)
    if acc: plt.plot(acc, label='Train Acc', color='orange')
    if val_acc: plt.plot(val_acc, label='Val Acc', color='red', linestyle='--')
    plt.title('Accuracy Curve')
    plt.xlabel('Epochs')
    plt.legend()
    
    if save_path:
        # 自动创建父目录
        os.makedirs(os.path.dirname(save_path), exist_ok=True)
        plt.savefig(save_path)
        print(f"Figure saved to {save_path}")
        
    if show:
        plt.show()
    plt.close() # 释放资源

然后现在我们需要依据上面的内容修改一下我们的配置文件

也就是我们的yaml文件

python 复制代码
# 超参数配置,在源代码中通过yaml库加载
# 可根据需要修改这些参数以调整模型训练行为, 不需要在代码中硬编码
data:
  samples: 500
  classes: 3
  batch_size: 64  # None 在 yaml 中表示 null,最好显式指定数值
  
model:
  input_dim: 2
  hidden_dims: [64, 128, 32]  # 可以通过列表配置多个隐藏层, 动态调整层深
  output_dim: 3
  activation: 'relu' # 新增:支持字符串配置
  dropout_rate: 0.0

training:
  epochs: 10000
  learning_rate: 2e-3
  weight_decay: 5e-4 
  print_every: 10
  seed: 2026           # 复现性
  save_dir: 'checkpoints' # 模型保存路径

整体的文件组织结构和前面的基本一致:

然后就是我们主要的入口文件main

python 复制代码
# main.py
import argparse
import datetime
import logging
import torch
import torch.optim as optim
import nnfs # 这个只是为了生成示例数据导入的一个特殊的库, 实际项目中不一定需要
from src.utils import load_config, plot_history, setup_logging, seed_everything
from src.dataset import get_dataloader
from src.model import UniversalMLP # 这里导入我们的model定义
from src.trainer import Trainer
# from src.predict import predict

def parse_args():
    parser = argparse.ArgumentParser(description="A very simple PyTorch Neural Network Project")
    parser.add_argument('--config', type=str, default='configs/config.yaml', help='Path to config file')
    return parser.parse_args()

def main():
    args = parse_args()

    # 1. 基础设施初始化
    # '2026-01-23_16-17-38'
    setup_logging(log_file=f'training_{datetime.datetime.now().strftime("%Y-%m-%d_%H-%M-%S")}.log')
    logger = logging.getLogger(__name__)
    logger.info("Project initialized.")
    
    # 加载配置
    cfg = load_config(args.config)
    seed_everything(cfg['training'].get('seed', 2026))

    # 初始化 nnfs (仅用于生成示例数据), 这个库在实际项目中不一定需要
    nnfs.init()
    
    # 自动选择设备 (GPU 优先)
    device = "cuda" if torch.cuda.is_available() else "cpu"
    logger.info(f"Using device: {device}")

    # 2. 准备数据
    # 工业级:通常会有 Train/Val Split
    # 注意:这里我们演示 batch_size 的使用. 如果在 yaml 里 batch_size 设为 null,则代码 logic 需要处理
    batch_size = int(cfg['data'].get('batch_size', None))
    
    # 获取训练集和验证集 DataLoader
    # 解释: 这里的划分是在 Dataset 层面进行的,与 batch_size 无关。
    # 我们先将总数据随机划分为 训练集 和 验证集,然后分别封装成 DataLoader。
    # 这样可以在每个 Epoch 结束时用验证集评估模型性能,监控过拟合.
    # 训练集和验证集就划分1次, 后续每一次epoch就是拿这个训练集一直在shuffle取batch训练, 然后每个batch拿固定的这个验证集评估
    train_loader, val_loader = get_dataloader(
        samples=int(cfg['data']['samples']), 
        classes=int(cfg['data']['classes']),
        batch_size=batch_size,
        shuffle=True,
        val_split=0.2  # 划分 20% 作为验证集
    )

    # 3. 构建模型 (动态结构)
    model = UniversalMLP(
        input_dim=int(cfg['model']['input_dim']),
        hidden_dims=cfg['model']['hidden_dims'], 
        output_dim=int(cfg['model']['output_dim']),
        activation=cfg['model'].get('activation', 'relu'),
        dropout_rate=float(cfg['model']['dropout_rate'])
    )
    logger.info(f"Model structure:\n{model}")
    
    # 4. 定义优化器
    # weight_decay (权重衰减): 即 L2 正则化项.
    # 作用: 限制权重数值的大小,防止模型过拟合. 值越大,惩罚越强
    optimizer = optim.Adam(
        model.parameters(), 
        lr=float(cfg['training']['learning_rate']), 
        weight_decay=float(cfg['training']['weight_decay'])
    )
    
    # 5. 初始化训练器并开始训练
    trainer = Trainer(
        model, 
        optimizer, 
        device=device,
        save_dir=cfg['training'].get('save_dir', 'checkpoints')
    )
    
    logger.info("Start Training...")
    history = trainer.fit(
        train_loader, 
        epochs=int(cfg['training']['epochs']), 
        val_dataloader=val_loader, # 传入验证集
        print_every=int(cfg['training']['print_every'])
    )
    
    # 6. 可视化并保存结果
    plot_history(history, save_path=f"{cfg['training'].get('save_dir', 'checkpoints')}/training_curve.png", show=False)
    logger.info("Training Finished.")

if __name__ == "__main__":
    main()

我们此处写成一个命令行脚本,只需要通过命令行传入配置文件参数即可

python 复制代码
python main.py  --config configs/config.yaml

这里我们还是用生成的模拟螺旋数据,模拟3类,每一类共500个样本点,一共是1500个样本,每个样本我们提供二维的坐标,以及一个类别的label,

然后做一个多分类任务,实际训练过程中我们将完整的数据划分8:2出训练集:验证集(此处一次划分永远不变,然后训练集拿来下一步的全局epoch训练),

然后训练集中我们再划分batch,然后每一个epoch都要shuffle一次,然后每一个epoch前向传播计算loss------》求梯度------》反向传播梯度------》更新参数,直到完成一个epoch,

然后每一个batch的loss或者是预测的准确率我们可以累加起来,在一个epoch中输出一个训练集的平均loss以及平均准确率等指标;

然后注意这里我们再每一个epoch训练之后,同时还用这个epoch训练完毕的模型对验证集进行了测试,包括输出loss和准确率,主要是我们判断model好坏,或者是想实现早停的机制,我们就必须得在这里通过验证集来记录我们model到底训练得好还是不好,当然这里验证集只是评估以及保存最优模型用,当然没有参与到梯度计算、参数更新,也就是没有影响到下一个epoch的训练集的训练。

我截取部分训练日志如下

python 复制代码
[2026-01-23 15:58:19,692] [INFO] Project initialized.
[2026-01-23 15:58:19,756] [INFO] Using device: cuda
[2026-01-23 15:58:19,758] [INFO] Model structure:
UniversalMLP(
  (network): Sequential(
    (0): Linear(in_features=2, out_features=64, bias=True)
    (1): ReLU()
    (2): Linear(in_features=64, out_features=128, bias=True)
    (3): ReLU()
    (4): Linear(in_features=128, out_features=32, bias=True)
    (5): ReLU()
    (6): Linear(in_features=32, out_features=3, bias=True)
  )
)
[2026-01-23 15:58:20,198] [INFO] Start Training...
[2026-01-23 15:58:20,198] [INFO] Start training on cuda from epoch 1 to 10000
[2026-01-23 15:58:20,399] [INFO] Checkpoint saved to checkpoints/best_model.pth (Epoch 1)
[2026-01-23 15:58:20,435] [INFO] Checkpoint saved to checkpoints/last_checkpoint.pth (Epoch 1)
[2026-01-23 15:58:20,479] [INFO] Checkpoint saved to checkpoints/best_model.pth (Epoch 2)
[2026-01-23 15:58:20,584] [INFO] Checkpoint saved to checkpoints/last_checkpoint.pth (Epoch 2)
[2026-01-23 15:58:20,605] [INFO] Checkpoint saved to checkpoints/best_model.pth (Epoch 3)
[2026-01-23 15:58:20,610] [INFO] Checkpoint saved to checkpoints/last_checkpoint.pth (Epoch 3)
[2026-01-23 15:58:20,634] [INFO] Checkpoint saved to checkpoints/last_checkpoint.pth (Epoch 4)
[2026-01-23 15:58:20,675] [INFO] Checkpoint saved to checkpoints/last_checkpoint.pth (Epoch 5)
[2026-01-23 15:58:20,715] [INFO] Checkpoint saved to checkpoints/last_checkpoint.pth (Epoch 6)
[2026-01-23 15:58:20,742] [INFO] Checkpoint saved to checkpoints/last_checkpoint.pth (Epoch 7)
[2026-01-23 15:58:20,769] [INFO] Checkpoint saved to checkpoints/best_model.pth (Epoch 8)
[2026-01-23 15:58:20,776] [INFO] Checkpoint saved to checkpoints/last_checkpoint.pth (Epoch 8)
[2026-01-23 15:58:20,800] [INFO] Checkpoint saved to checkpoints/best_model.pth (Epoch 9)
[2026-01-23 15:58:20,817] [INFO] Checkpoint saved to checkpoints/last_checkpoint.pth (Epoch 9)
[2026-01-23 15:58:20,840] [INFO] Checkpoint saved to checkpoints/best_model.pth (Epoch 10)
[2026-01-23 15:58:20,861] [INFO] Checkpoint saved to checkpoints/last_checkpoint.pth (Epoch 10)
[2026-01-23 15:58:20,861] [INFO] Epoch 10/10000 | Loss: 0.8080 | Acc: 0.6992 | LR: 0.002 | Val Loss: 0.7696 | Val Acc: 0.7233
[2026-01-23 15:58:20,889] [INFO] Checkpoint saved to checkpoints/best_model.pth (Epoch 11)

中间这一块就是我们写的简陋的多层感知机框架细节:

也就4层,

然后我们的训练过程监控如下,实际训练过程中并没有发生早停,所以1w个epoch都跑完了;

从训练效果上看,还行,符合我们的预期,总之我们可以不用支持向量机、二次判别分析等经典机器学习技巧,

只需要暴力叠起来层数,然后一股脑放进去训练(至少对于我们本章入门任务而言足够了)

8,完全独立地加载模型进行推理(Inference)

推理是我们模型工程化不得不品的一环,也是我们模型训练的最终目的。

此处我们不依赖predict部分代码,而是如果我们想要在独立的测试脚本或者是随便一个notebook单元中进行测试的话,我们一般需要按照下面三步走:

  • 实例化model架构(必须和训练时的参数一模一样)
  • 加载权重文件(主要是处理state_dict,当然测试的话我们用前面训练中保存下来的最好的model的权重文件)
  • 进入评估模式(eval,主要是batchnorm和dropout问题)
python 复制代码
import torch
import sys

# ---------------------------------------------------------
# 1. 确保能导入 src 模块
# 就是需要将我们模型构建所依赖的各种脚本文件都加入到python路径中, 
# 或者可以通过python路径去相对访问, 那一般就是sys.path.append
# ---------------------------------------------------------
current_dir # 假设current_dir是我们model所在的文件目录
if current_dir not in sys.path:
    sys.path.append(current_dir)

from src.model import UniversalMLP  # 导入模型定义

# ---------------------------------------------------------
# 2. 实例化模型架构
# ⚠️ 关键点:这里的参数必须与我们 config.yaml 中训练时的参数完全一致!
# ---------------------------------------------------------
# 假设我们在 config.yaml 里是这样配置的 (也就是前面训练时的实际情况):
model_params = {
    "input_dim": 2,          # 输入特征维度
    "hidden_dims": [64, 128, 32], # 隐藏层结构
    "output_dim": 3,         # 输出类别数
    "activation": 'relu'     # 激活函数
}

# 初始化一个"空壳"模型
model = UniversalMLP(**model_params)

# ---------------------------------------------------------
# 3. 加载权重 (Best Weights)
# ---------------------------------------------------------
checkpoint_path = 'checkpoints/best_model.pth'  # 最好权重的路径
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")

if os.path.exists(checkpoint_path):
    print(f"Loading weights from {checkpoint_path}...")
    
    # 加载 checkpoint 字典
    checkpoint = torch.load(checkpoint_path, map_location=device)
    
    # ⚠️ 注意: 我们的 Trainer 保存的是一个包含多个信息的字典
    # 通常 key 是 'model_state_dict' 或 'state_dict'
    if 'model_state_dict' in checkpoint:
        model.load_state_dict(checkpoint['model_state_dict'])
    else:
        # 如果直接保存的是 state_dict (不常见但有可能)
        model.load_state_dict(checkpoint)
        
    print("✅ Model loaded successfully!")
else:
    print(f"❌ Error: Checkpoint not found at {checkpoint_path}")

# ---------------------------------------------------------
# 4. 预测 (Inference)
# ---------------------------------------------------------
model.to(device)
model.eval() # ⚠️ 极其重要: 关闭 Dropout 和 BatchNrom 的训练行为

# 构造一个虚假数据样本用来测试 (batch_size=1, features=2)
dummy_input = torch.tensor([[0.5, -0.5]], dtype=torch.float32).to(device)
# 或者是使用nnfs再生成一个模拟数据

with torch.no_grad(): # ⚠️ 极其重要: 不计算梯度,节省显存并加速
    logits = model(dummy_input)
    probs = torch.softmax(logits, dim=1)
    predicted_class = torch.argmax(probs, dim=1).item()

print(f"\n🔮 Prediction Results:")
print(f"Logits: {logits.cpu().numpy()}")
print(f"Probabilities: {probs.cpu().numpy()}")
print(f"Predicted Class: {predicted_class}")

比如说我们这里的这个模拟测试数据,

这里我们可以看一下权重文件,

可以发现,保存的是第111个epoch的网络参数,可以说我们一共1w个epoch,但是在前1%左右的位置处,我们的model已经收敛到在验证集上不错了,甚至后面99%左右的训练model都没有让验证集上loss降下来过。

总而言之,权重文件pth中的model_state_dict是我们需要的

我们这里再试一下,如果是使用模拟的批次处理数据会怎么样,到底准确率如何

python 复制代码
import torch
import sys
import os
import nnfs
from nnfs.datasets import spiral_data
import numpy as np

# ---------------------------------------------------------
# 1. 确保能导入 src 模块
# 就是需要将我们模型构建所依赖的各种脚本文件都加入到python路径中, 
# 或者可以通过python路径去相对访问, 那一般就是sys.path.append
# ---------------------------------------------------------
current_dir = "/mnt/sdb/zht/MLP_1" # 假设current_dir是我们model所在的文件目录
if current_dir not in sys.path:
    sys.path.append(current_dir)

from src.model import UniversalMLP  # 导入模型定义

# ---------------------------------------------------------
# 2. 实例化模型架构
# ⚠️ 关键点:这里的参数必须与我们 config.yaml 中训练时的参数完全一致!
# ---------------------------------------------------------
# 假设我们在 config.yaml 里是这样配置的 (也就是前面训练时的实际情况):
model_params = {
    "input_dim": 2,          # 输入特征维度
    "hidden_dims": [64, 128, 32], # 隐藏层结构
    "output_dim": 3,         # 输出类别数
    "activation": 'relu'     # 激活函数
}

# 初始化一个"空壳"模型
model = UniversalMLP(**model_params)

# ---------------------------------------------------------
# 3. 加载权重 (Best Weights)
# ---------------------------------------------------------
checkpoint_path = '/mnt/sdb/zht/MLP_1/checkpoints/best_model.pth'  # 最好权重的路径
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")

if os.path.exists(checkpoint_path):
    print(f"Loading weights from {checkpoint_path}...")
    
    # 加载 checkpoint 字典
    checkpoint = torch.load(checkpoint_path, map_location=device)
    
    # ⚠️ 注意: 我们的 Trainer 保存的是一个包含多个信息的字典
    # 通常 key 是 'model_state_dict' 或 'state_dict'
    if 'model_state_dict' in checkpoint:
        model.load_state_dict(checkpoint['model_state_dict'])
        
    print("✅ Model loaded successfully!")
else:
    print(f"❌ Error: Checkpoint not found at {checkpoint_path}")

# ---------------------------------------------------------
# 4. 预测 (Inference)
# ---------------------------------------------------------
model.to(device)
model.eval() # ⚠️ 极其重要: 关闭 Dropout 和 BatchNrom 的训练行为

# 构造一个虚假数据样本用来测试 
x_test, y_test = spiral_data(samples=100, classes=3)
x_test = torch.tensor(x_test, dtype=torch.float32).to(device)
y_test = torch.tensor(y_test, dtype=torch.long).to(device)

with torch.no_grad(): # ⚠️ 极其重要: 不计算梯度,节省显存并加速
    logits = model(x_test)
    probs = torch.softmax(logits, dim=1)
    # 单个样本预测可以用.items()
    predicted_classes = torch.argmax(probs, dim=1)
    # 多个样本预测, 其实就是批量输出, 必须得先转换为numpy数组
    logits_np = logits.cpu().numpy()
    probs_np = logits.cpu().numpy()
    predicted_classes_np = predicted_classes.cpu().numpy()
    # 前面是先将y_test转换为tensor再迁移到gpu上, 现在反过来先从gpu迁移到cpu上再从tensor转换为numpy的ndarray
    y_test_np = y_test.cpu().numpy()

print(f"\n🔮 Prediction Results:")
print(f"Logits: {logits_np}")
print(f"Probabilities: {probs_np}")
print(f"Predicted Class: {predicted_classes_np}")
print(f"accuracy {np.mean(predicted_classes_np == y_test_np)}")

勉勉强强还行

5,总结

其实这个系列出到这里,已经算是从python纯数据分析能够接轨上pytorch生态以及神经网络了。

接下来的路其实就很好走了,

学好pytorch语法,然后直接去找感兴趣的架构(先学理论,再找模板),按照我们这里从numpy到pytorch的铺桥搭路,其实对于找到的模板,大部分应该都能够比较轻松地去理解了(前提是理论部分已经学完了)

相关推荐
开开心心就好2 小时前
卸载工具清理残留,检测垃圾颜色标识状态
linux·运维·服务器·python·安全·tornado·1024程序员节
码农汉子2 小时前
零基础入门】Open-AutoGLM 完全指南:Mac 本地部署 AI 手机助理(原理+部署+优化)
人工智能·macos·智能手机
小舞O_o2 小时前
gitlab文件上传
linux·服务器·git·python·目标检测·机器学习·gitlab
linmoo19862 小时前
Langchain4j 系列之三十一 - Observability之入门
人工智能·langchain·observability·langchain4j
yubo05092 小时前
Python 包、模块、导入规则
开发语言·python
小飞大王6662 小时前
使用nodejs接入ai服务并使用sse技术处理流式输出实现打字机效果
前端·javascript·人工智能
模型时代2 小时前
F5推出AI安全防护平台扩展新产品
人工智能
币之互联万物2 小时前
消费品营销战略咨询公司怎么选?哪家靠谱?
大数据·人工智能
lm down2 小时前
一键部署 HeartMuLa,支持 Mac 和 Windows
人工智能·音视频