【Python】推荐:一个完整的Pytorch深度学习项目代码结构及项目发布指南


很多Pytorch深度学习初学者在着手开发深度学习项目的时候,看了一些GitHub上开源的项目,感觉里边文件和文件夹很多不知道哪些是需要自己构建的文件,哪些是运行时生成的文件。为什么这些深度学习项目代码结构是这样或那样的?是否有一套比较通用的项目代码结构?本文就这个问题给出一些自己的建议。

总体上看

首先深度学习的代码结构是没有一个统一标准的。一方面,代码结构取决于开发者自身的编程观念和水平,有人会一路长函数写到底,有人会利用面向对象进行封装和复用。另一方面,不同规模的项目,本身需要的结构也是很不一样的。Prototype代码讲究简洁易懂,而平台级别的库讲究模块化和可维护性,这也是为什么很多人看懂了MNIST上的代码,却经常看不懂开源库的原因。

就常见深度学习project来说,大概可以分成以下三类结构:

  1. Prototype型:用最少的代码在toy dataset上实现一个模型
  2. 可扩展型:围绕某个任务/某类模型展开的一套代码,和一些可选的模块
  3. 平台库型:对一系列任务的一套统一代码。

Prototype型的代码,比如PyTorch官方tutorial里的CNN和RNN,以及Kipf的GCN。这类代码一般有效代码量在一两百行,主要分成三个部分:

  • 数据集读取和预处理
  • 模型定义
  • 模型训练、测试和保存代码

其中,训练和测试代码一般写在主程序里,也有的会封装成叫train或者test/inference之类的函数。模型定义部分一般是一个独立文件,叫model.py。数据集读取和预处理代码一般在data.py或者utils.py之类的文件里。

**Prototype型的代码的一大优点就是简单且好移植。**当想用另一个领域的技术时,比如做vision的用GNN,拿一个这样code过来,是最容易吃透并且整合到自己project里的。当然prototype型的缺点也不少,一是缺少可选组件不易于刷点,二是魔改多了容易代码冗长混乱。

可扩展型一般是在prototype基础上,将数据集、模型单独抽象出来,把训练代码重构成模型无关的。这类代码多见于长期做同一个任务的开发/研究组,一般在千行左右,常见的模式是:

  • 各种数据集
  • 各种神经网络层/loss/CUDA op
  • 各种backbone模型
  • 模型无关的训练、测试和保存代码
  • 主程序,解析命令行传递超参数

可扩展型代码是很多人做领域内的开发/研究的起点,比如折腾个新的神经网络层,或者部署到具体落地任务上。相比prototype型代码,这种代码更有魔改潜力,而且花一些时间的话也能吃透。

代码规模再往上翻个十倍,一般就是些平台库了。平台库的整体结构跟可扩展型并没有本质区别,但会多出很多细化的封装和测试模块,主要是因为大项目更需要考虑维护成本。有个经验定律是,一段混乱的代码的debug时间跟代码长度的平方成正比。所以平台库结构上的常见策略是把一段复杂代码拆成大量简单模块,把混乱的代码限制在每个模块内,同时用各种函数和类的抽象来避免重复代码。

另外,项目中的config文件主要是方便超参数搜索以及做ablation study用的。

Pytorch模型的常用组件

Network

创建一个Network类,继承torch.nn.Module,在构造函数(__init__)中用初始化成员变量为具体的网络层,在forward函数中使用成员变量搭建网络架构,模型的使用过程中pytorch会自动调用forword进行参数的前向传播,构建计算图。以下是一个简单的CNN图像分类模型:

class Network(nn.Module):
    def __init__(self):
        super(Network, self).__init__()
        # 灰度图像的channels=1即in_channels=1 输出为10个类别即out_features=10
        # parameter(形参)=argument(实参) 卷积核即卷积滤波器 out_channels=6即6个卷积核 输出6个feature-maps(特征映射)
        # 权重shape 6*1*5*5
        self.conv1 = nn.Conv2d(in_channels=1, out_channels=6, kernel_size=5)
        self.bn1 = nn.BatchNorm2d(6)  # 二维批归一化 输入size=6
        # 权重shape 12*1*5*5
        self.conv2 = nn.Conv2d(in_channels=6, out_channels=12, kernel_size=5)
        
        # 全连接层:fc or dense or linear out_features即特征(一阶张量)
        # 权重shape 120*192
        self.fc1 = nn.Linear(in_features=12*4*4, out_features=120)
        self.bn2 = nn.BatchNorm1d(120)  # 一维批归一化 输入size=120
        # 权重shape 60*120
        self.fc2 = nn.Linear(in_features=120, out_features=60)
        # 权重shape 10*60
        self.out = nn.Linear(in_features=60, out_features=10)
        
    def forward(self, t):
        # (1) input layer
        t = t
        # (2) hidden conv layer
        t = F.relu(self.conv1(t))  # (28-5+0)/1+1=24 输入为b(batch_size)*1*28*28 输出为b*6*24*24 relu后shape不变
        t = F.max_pool2d(t, kernel_size=2, stride=2)  # (24-2+0)/2+1=12 输出为b*6*12*12
        t = self.bn1(t)
        
        # (3) hidden conv layer
        t = F.relu(self.conv2(t))  # (12-5+0)/1+1=8 输出为b*12*8*8 relu后shape不变
        t = F.max_pool2d(t, kernel_size=2, stride=2)  # (8-2+0)/2+1=4 输出为b*12*4*4
        
        # (4) hidden linear layer
        t = F.relu(self.fc1(t.reshape(-1, 12*4*4)))  # t.reshape后为b*192 全连接层后输出为b*120 relu后shape不变
        t = self.bn2(t)
        # (5) hidden linear layer
        t = F.relu(self.fc2(t))  # 全连接层后输出为b*60 relu后shape不变
        
        # (6) output layer
        t = self.out(t)  # 全连接层后输出为b*10 relu后shape不变
        return t

Transforms

数据处理可以直接使用torchvision.transforms下的处理函数,包括均值,随机旋转,随机裁剪等等,也可以自己实现一些Pytorch中没有实现的处理函数,下面以一个分割网络的处理函数举例,可支持同时对传入的ImageGroundTruth进行处理,使用时直接按照顺序构造ProcessImgAndGt即可。

class ProcessImgAndGt(object):
    def __init__(self, transforms):
            self.transforms = transforms
    def __call__(self, img, label):
        for t in self.transforms:
            img, label = t(img, label)
        return img, label

class Resize(object):
    def __init__(self, height, width):
        self.height = height
        self.width = width
    def __call__(self, img, label):
        img = img.resize((self.width, self.height), Image.BILINEAR)
        label = label.resize((self.width, self.height), Image.NEAREST)
        return img, label

class Normalize(object):
    def __init__(self, mean, std):
        self.mean, self.std = mean, std
    def __call__(self, img, label):
        for i in range(3):
            img[:, :, i] -= float(self.mean[i])
        for i in range(3):
            img[:, :, i] /= float(self.std[i])
        return img, label

class ToTensor(object):
    def __init__(self):
        self.to_tensor = torchvision.transforms.ToTensor()
    def __call__(self, img, label):
        img, label = self.to_tensor(img), self.to_tensor(label).long()
        return img, label

# 按照顺序构造`ProcessImgAndGt`即可
transforms = ProcessImgAndGt([
    Resize(512, 512),
    Normalize([0.5, 0.5, 0.5], [0.1, 0.1, 0.1]),
    ToTensor()
])

Dataset

创建一个数据集类,继承torch.utils.data.Dataset,只需实现__init__构造函数,__getitem__迭代器遍历函数以及__len__函数。

  • __init__函数中读取传入的数据集路径下的指定数据文件。还是以一个分割网络的dataset流程为例,其他分类模型可以直接将GroundTruth替换为对应label即可,将拼接处理好的图片文件路径和GroundTruth文件路径作为元组存入一个类型为列表的成员变量file_list中;
  • __getitem__中根据传入的索引从file_list取出对应的元素,并通过Transforms进行处理;
  • __len__中返回len(self.file_list)即可
class MyDataset(torch.utils.data.Dataset):
    def __init__(self, dataset_path, transforms):
        super(TrainDataset, self).__init()
        self.dataset_path = dataset_path
        self.transforms = transforms
        # 根据具体的业务逻辑读取全部数据路径作为加载数据的索引
        for dir in os.listdir(dataset_path):
            image_dir = os.path.join(dataset_path, dir)
            gt_path = image_dir + '/GT/'
            img_path = image_dir + '/Frame/'
            img_list = []
            for name in os.listdir(img_path):
                if name.endswith('.png'):
                    img_list.append(name)
            self.file_list.extend([(img_path + name, gt_path + name) for name in img_list])

    def __getitem__(self, idx):   
        img_path, label_path = self.file_list[idx]
        img = Image.open(img_path).convert('RGB')
        label = Image.open(label_path).convert('L')
        img, label = self.transforms(img, label)
        return img, label

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

Optimizer

选择优化器进行模型参数更新,要创建优化器必须给它一个可进行迭代优化的包含了全部参数的列表,然后可以指定针对这些参数的学习率(learning_rate),权重衰减(weight_decay),momentum等,例如:

optimizer = optim.Adam(model.parameters(), lr = 0.0001)

或者是可以指定针对哪些参数执行不一样的优化策略,根据不同层的name对不同层使用不同的优化策略。列表中的每一项都可以是一个dict,dict中params对应当前项的参数列表,可以对当前item指定学习率或者是衰减策略。例如,对base_params使用的1e-4的学习率,对finetune_params使用1e-3的学习率,对两者一起使用1e-4的权重衰减:

base_params = [params for name, params in model.named_parameters() if ("xxx" in name)]
finetune_params = [params for name, params in model.named_parameters() if ("yyy" in name)]
optimizer = optim.Adam([
    {"params": base_params},
    {"params": finetune_params, "lr": 1e-3}
], lr=1e-4, weight_decay=1e-4);

Run

基础组件都写好了,剩下的就是组成一个完整的模型结构。过程如下:

  1. 实例化模型对象,并将其加载到GPU中
  2. 根据需要构建数据预处理对象,将数据传入数据集对象中,在进行读取数据时的进行数据处理
  3. 构建训练和测试的数据集对象,并将其传入torch.utils.data.DataLoader,指定batch_size(训练或测试是每次读取多少条数据)、shuffle(读取数据时是否打乱)、num_workers(开启多少线程进行数据加载,为0时(不推荐)用主线程在训练模型时进行数据加载)等参数
  4. 使用torch.optim.Adam构建优化器对象,这里根据不同层的name对不同层使用不同的优化策略
  5. 假设训练20个epoch,并且每5个epoch在测试集上跑一遍,这里只计算了损失,对于其他评价指标直接计算即可
  6. 根据条件对指定epoch的模型进行保存

其中需要注意的5个函数:

  • optimizer.zero_grad() # pytorch会积累梯度,在优化每个batch的权重的梯度之前将之前计算出的每个权重的梯度置0
  • loss.backward() # 在最后一个张量上调用反向传播方法,在计算图中计算权重的梯度
  • optimizer.step() # 使用预先设置的学习率等参数根据当前梯度对权重进行更新
  • model.train() # 保证BN层能够继续计算数据的均值和方差并进行更新,保证dropout层会按照设定的参数设置保留激活单元的概率(保留概率=p)
  • model.eval() # BN层会停止计算均值和方差,直接使用训练时的参数,dropout层利用了训练好的全部网络连接,不随机舍弃激活单元

代码如下所示:

model = Network().cuda()
# 构建数据预处理
transforms = ProcessImgAndGt([
    Resize(512, 512),
    Normalize([0.5, 0.5, 0.5], [0.1, 0.1, 0.1]),
    ToTensor()
])
# 构建Dataset
train_dataset = MyDataset(train_dataset_path, transforms)
# DataLoader
train_loader = torch.utils.data.DataLoader(dataset=train_dataset,
                                                   batch_size=12,
                                                   shuffle=True,
                                                   num_workers=4,
                                                   pin_memory=False)
# TestDataset
test_dataset = MyDataset(test_dataset_path, transforms)
test_loader = torch.utils.data.DataLoader(dataset=test_dataset,
                                                  batch_size=4,
                                                  shuffle=True,
                                                  num_workers=2,
                                                  pin_memory=False)

# optimizer需要传入全部需要更新的参数名称,这里是对不用的参数执行不同的更新策略 
base_params = [params for name, params in model.named_parameters() if ("xxx" in name)]
finetune_params = [params for name, params in model.named_parameters() if ("yyy" in name)]
optimizer = torch.optim.Adam([
    {"params": base_params, "lr": 1e-3, ...},
    {"params": finetune_params, "lr": 1e-4, ...}
])

for epoch in range(20):
    model.train()
    epoch_loss = 0
    for batch in trian_loader:
        images. gts = batch[0].cuda(), batch[1].cuda()
        preds = model(iamges)
        loss = F.cross_entropy(preds, gts)
        optimizer.zero_grad()    # pytorch会积累梯度,在优化每个batch的权重的梯度之前将之前计算出的每个权重的梯度置0
        loss.backward()          # 在最后一个张量上调用反向传播方法,在计算图中计算权重的梯度 
        optimizer.step()         # 使用预先设置的学习率等参数根据当前梯度对权重进行更新
        epoch_loss += loss * trian_loader.batch_size
        # 计算其他标准
    loss = epoch_loss / len(train_loader.dataset)
    # .......
    # 每隔几个epoch在测试集上跑一下
    if epoch % 5 == 0:
        model.eval()
        test_epoch_loss = 0
        for test_batch in test_loader:
            test_images. test_gts = test_batch[0].cuda(), test_batch[1].cuda()
            test_preds = model(test_iamges)
            loss = F.cross_entropy(test_preds, test_gts)
            test_epoch_loss += loss * test_loader.batch_size
            # 计算其他标准
        test_loss = test_epoch_loss / (len(test_loader.dataset))
    # .......
    # 根据条件对指定epoch的模型进行保存 将模型序列化到磁盘的pickle包
    if 精度最高:
        torch.save(model.stat_dict(), f'{model_path}_{time_index}.pth')

Test

实际使用时需要将训练好的模型上在输入数据上运行,这里以视频图像测试集的数据为例,实际情况下只需要初始化模型之后将视频流中的图像帧作为模型的输入即可。
这里的torch.no_grad(),即停止autograd模块的工作,不计算和储存梯度,一般用在已经训练好的模型上跑测试集时使用,因为测试集是不需要计算梯度更不会更新梯度。使用该函数后可以加速计算时间,节约GPU的显存。

test_dataset = MyDataset(test_dataset_path, transforms)
test_loader = torch.utils.data.DataLoader(dataset=test_dataset,
                                                       batch_size=1,
                                                       shuffle=False,
                                                       num_workers=2)
model = Network().cuda()
# 对磁盘上的pickle文件进行解包 将gpu训练的模型加载到cpu上
model.load_stat_dict(torch.load(model_path, map_location=torch.device('cpu')));
mocel.eval()

with torch.no_grad():
    for batch in test_loader:
        test_images. test_gts = test_batch[0].cuda(), test_batch[1].cuda()
        test_preds = model(test_iamges)
        # 保存模型输出的图片

回到项目结构上

一个常用的项目结果如下:

--project_name/
----data/:数据
----checkpoints/:保存训练好的模型
----logs/:日志
----model_hub/:预训练模型权重
--------chinese-bert-wwm-ext/----utils/:辅助模块,可以是日志、评价指标计算等等
--------utils.py
--------metrics.py
----models/:模型
--------model.py
----configs/:配置文件
--------config.py
----datasets/:加载数据
--------data_loader.py
----main.py:主程序,包含训练、验证、测试和预测

如果项目较为简单,可以是这样的:

--project_name/
----data/:数据
----checkpoints/:保存训练好的模型
----logs/:日志
----model_hub/:预训练模型权重
--------chinese-bert-wwm-ext/----utils/:辅助模块,可以是日志、评价指标计算等等
--------utils.py
--------metrics.py
----model.py
----config.py
----data_loader.py
----main.py:主程序,包含训练、验证、测试和预测

有了基本的项目结构,一般情况下,这个项目是需要被其他人调用的,那么对于每个目录下需要有一个__init__.py文件,如下所示:

project_name/
----youname:# 这里可以是任意你的名字
--------__init__.py
--------algorithm/
------------__init__.py
--------data/
--------gadget/
------------__init__.py
--------utils/
------------__init__.py
----examples/# 这里调用youname里面的模块
----requirements.txt:# 需要的python依赖
----readme.md:# 项目说明
----test.py:# 用于测试youname里面的功能

主要的核心就是__init__.py里面。对于youname下面的__init__.py,需要将其下面的包都给引入进去,比如:(需要注意的是要从项目名开始)

from youname.utils.logger import set_logger  

logging = set_logger(level="INFO", log_dir_name=".youname_logs")

from youname.utils import *
from youname.algorithm import *
from youname.data import *
from youname.gadgat import *

对于每一个包下面的__init__.py,也需要将其下的包和模块给引入,比如algorithm下面有一个seg包,那么可以这么导入:

from . import seg

然后,我们要的目标是将youname拷贝到任意地方,在其同目录下就可以import使用了。比如test.py里面:

import youname as yn

text = "美国长江大桥"
seg_text = yn.seg.seg_jieba(text)
print(seg_text)

import time
# cur_time = yn.timeParser.cur_time
# print(cur_time)
cur_time = yn.timeParser(time.time())
print(cur_time)

text = "美国长江大桥"
seg_text = yn.seg.seg2.seg_jieba2(text)
print(seg_text)

注意:如果我们在youname/__init__.py里面直接一次性导入了多级包下的函数,那么可以直接使用,否则要按照层级进行导入再使用。当然,也可以将其上传到pypi,这样就可以pip安装和使用了。

项目发布指南

怎么让它可以被别人使用呢?第一种就是上述所说的直接拷贝项目,第二种就是上传到pypi让别人可以pip安装使用。目录结构:

project_name\
----youname\
----LICENSE  # 许可类型
----MANIFEST.in
----README.MD
----setup.py
----test.py
  1. 首先可以在https://test.pypi.org/(用于测试)和https://pypi.org/(正式环境)上分别注册一个账号(可以相同),然后分别ADD API TOKEN。
  2. 进入到project_name下,执行python3 setup.py sdist bdist_wheel
  3. 对于windows用户来说,在C:\Users\Administrator;对于Linux系统,在~/下新建一个.pypirc,里面写入:
[distutils]
index-servers=testpypi  # 这个和下面的testpypi对应
 
[testpypi]
username=__token__
password=自己添加的token
  1. 安装twine
python3 -m pip install --user --upgrade twine
  1. 上传已经打好的包到testpypi:
python3 -m twine upload --repository testpypi dist/*
  1. 完成后会返回给你一个地址,那就是我们已经上传好的。这里的一个已上传好的示例:youname · TestPyPI 。可以按照里面的指令进行安装了:pip install -i https://test.pypi.org/simple/youname
    当然,这里上传的是testpypi,想上传到pypi只需要修改所有testpypi为pypi即可(先要确认上面是否有相同的名字,否则会报错:The user ‘xxxxxx’ isn’t allowed to upload to project ‘youname’)。
  2. 再来看看核心部分:setup.py,我们需要关注的几个点:
#!/usr/bin/env python
# -*- coding: utf-8 -*-

# Note: To use the 'upload' functionality of this file, you must:
#   $ pipenv install twine --dev

import io
import os
import sys
from shutil import rmtree

from setuptools import find_packages, setup, Command

# Package meta-data.
NAME = 'youname'
DESCRIPTION = '测试上传包到pypi'
URL = 'https://github.com/zhangsan
EMAIL = 'xxxx@qq.com'
AUTHOR = 'zhangsan'
REQUIRES_PYTHON = '>=3.6.0'
VERSION = '0.0.1'  # 版本号很重要
# What packages are required for this module to be executed?
REQUIRED = [
    # 'requests', 'maya', 'records',
]

# What packages are optional?
EXTRAS = {
    # 'fancy feature': ['django'],
}

packages=find_packages(), # 会找到youname下面所有的包,也就是包含__init__.py的
py_modules=['test'],  # 补充不属于任何包的python文件,不用带.py
# package_data={'spam': ['data.txt']},  # 一些静态文件

基本上修改这些位置就好了,可以当做模板。

相关资料

  1. 知乎问答:https://www.zhihu.com/question/406133826
  2. 知乎旺旺小小超的回答:https://www.zhihu.com/question/406133826/answer/2389936678
  3. 知乎小朱的回答:https://www.zhihu.com/question/406133826/answer/1925821995
  4. 知乎西西嘛呦:https://www.zhihu.com/question/406133826/answer/2898344659
  5. python_common_code_collection
  6. Pytorch实验代码的亿些小细节
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

镰刀韭菜

看在我不断努力的份上,支持我吧

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值