1. 来自「起跑线长居者」的碎碎念

说起来,我对NLP和机器学习的浅层了解从很早以前就开始,此前跟着学校的课程也试着用Python代码实现过基于HMM的分词模型,但是对于机器学习的系统把握却从未开始。形容我目前的状态,用「起跑线长居者」一词确是有些贴切。

相信一些小伙伴也正处于我这样的状态:我们可能对梯度下降的过程有着图像化的领悟,也能够不费力地和其他领域的朋友科普简单的多层神经网络是怎么从一堆参数的相乘相加而展开成有辨别力的模型的……

可是点到为止的了解并无法令我们驾驭机器学习这一越发有力的工具。在科研内卷的今日,我们无需提及各种NLP模型的爆炸式跃进,连语音学领域偏文科方向的论文编辑都已经开始对基于神经网络模型的理论验证展现出越发强的兴趣。因此,深入掌握机器学习的目标可没法再拖延着去实现了!

我的女友大人曾经教导我说:「怎么学都不如在实际的项目中学来得快。」

虽然,以我当前的水准,必然没法通过实习工作去学习。但是写连续性的博文也不失为一种项目式的实践。那么,今天就开启「ML起跑线」第一篇博文的写作吧,就把它当作对于自身学习过程以及其间所获得新知、所产生反思的一种记录。

我会从李宏毅老师2022年所授课程「机器学习」[1] 的作业写起,从对 sample code 的理解写起。

记录之余,也希望我的博文可以通过网络为可能同是「起跑线长居者」的你提供学习的线索。也希望比我有更多相关领域积淀的朋友可以指出我博文中出现的各类问题,不甚感谢!

Jetzt geht es los!

2. 示例代码的学习笔记

2.0. 环境准备

作为一个对实体空间以及虚拟空间的有序程度都有一些固执的人,在开始刷题机器学习的 Homework 之前,我会先在磁盘中新建一个专门用于存储该系列作业文件的目录,后续为每一次的作业建立 hw[序号] 目录作为工作目录。

按照简单项目的一些惯例,我们通常在工作目录中添加 data 目录以存放用于导入或处理的数据文件。

图1 ./data/ 目录中的数据集文件

不同于原始 sample code 中通过代码从 Google Drive 下载数据集,考虑到网络限制,我选择先将数据集下载至 data 目录(如图 1)。

本次的数据集为 csv 数据文件,可以使用 Python 内置的 csv 包对其进行读写操作。

在开始代码实现前,我们可以实现用工作簿软件打开这两份数据集,对其进行观察。结合作业讲解影片中的介绍,这两份数据集结构如下: 

图2 训练集和测试集结构

 训练集包含 2699 条(行)数据,每条数据有 118 个(列)属性,包括:

  • 1 列数据 id
  • 37 列联邦州的 one-hot 编码点(即将每个联邦州作为一个属性维度,每条数据所属的联邦州数值为 1,其他联邦州数值为 0,这种做法常用在处理非数值型的数据标签)
  • 5 个连续的日期 × 每日的 18 个公共卫生相关特征(例如医疗运转、社交限制、心理健康等),其中,每天的最后一个公共卫生特征为当日阳性率。

而测试集包含 1078 条数据,每条数据所含属性与训练集基本一致,唯一缺少的是 5 个连续日期中最后一天的阳性率(如图 2)。

本次作业所需要完成的任务,就是训练一个可以根据一条数据的前 117 个属性的全部或部分,来预测最后一条属性数值的模型。代入具体情境,即一个可以根据任意联邦州连续 5 天的公共卫生数据(不包含最后一天的阳性率)来预测最后一天阳性率的模型。

2.1. 导入所需 Python 包

首先,我们需要导入一些必要的 Python 包。我列举了其中值得注意的包或模块:

  • Dataset 和 DataLoader 是将训练、验证和测试数据集导入模型的必备模块。通俗来讲,我们通过 Dataset 把数据集读取到程序中,然后通过 DataLoader 按照训练的批次大小(batch size)将 Dataset 中的数据有序地送入模型进行训练或测试。
  • random_split 是 torch 自带的随机分割数据集的顺手工具,可以用于将数据按比例随机分割为训练集和验证集。
  • SummaryWriter 是一个可视化模型训练效果的程序包,相比常用的 pyplot 绘图包增加了交互性,是另一个深度学习框架 Tensorflow 的组成部分,现在也为 PyTorch 所支持。不过部分 PyTorch 版本在引入该可视化包前需要手动安装它(如下)。
    pip install tensorboard
# 导入数值运算包
import math
import numpy as np

# 导入文件读写包
import pandas as pd
import os
import csv

# 导入进度条功能包
from tqdm import tqdm

# 导入PyTorch包及所需的附属模块
import torch
import torch.nn as nn   # torch.nn是构建神经网络(neural network, nn)的常用模块。
from torch.utils.data import Dataset, DataLoader, random_split

# 导入学习曲线绘制模块
from torch.utils.tensorboard import SummaryWriter

2.2. 定义一些工具

此处定义了一些工具类函数,包括:

  • same_seed() 函数为神经网络的训练提供一致的随机种子,确保训练结果的可复现性。它的输入 seed 是一个整数,我们可以在 2.7. 中的config里设置它。
  • train_valid_split() 函数可以根据我们给定的验证集比例(valid_ratio)将原始的训练集随机划分为训练集和验证集,以供训练过程使用。它需要 3 个输入参数:未分割的训练集(data_set),验证集比例(valid_ratio)和随机种子数(seed)。加入人工设置的随机种子数的目的也是为了使得分割方式在每一次训练的尝试中保持一致,使模型的训练结果有更强的可比性。
  • predict() 函数即是模型测试所用函数。我们需要对其输入测试集(test_loader),训练好的模型(model)和跑模型的设备(device)。其输出值为我们训练好的模型对测试集的预测结果。
def same_seed(seed):
    '''Fixes random number generator seeds for reproducibility'''
    # 使用确定性算法(deterministic algorithms),以确保相同的input,parameters和环境可以输出相同的output,使得训练结果可以复现。
    torch.backends.cudnn.deterministics=True
    # 由于使用GPU进行训练时,cuDNN会自动选择最高效的算法,导致训练结果难以复现,因此需要关闭benchmark模式。
    torch.backends.cudnn.benchmark=False
    np.random.seed(seed)   # 根据输入的seed设置固定的numpy seed。
    torch.manual_seed(seed)   # 根据输入的seed值在torch中设置固定的起始参数。
    if torch.cuda.is_available():
        torch.cuda.manual_seed_all(seed)

def train_valid_split(data_set, valid_ratio, seed):
    '''Split provided training data into training set and validation set'''
    valid_set_size = int(valid_ratio * len(data_set))
    train_set_size = len(data_set) - valid_set_size
    train_set, valid_set = random_split(data_set, 
                                        [train_set_size, valid_set_size],
                                        generator=torch.Generator().manual_seed(seed))
    return np.array(train_set), np.array(valid_set)

def predict(test_loader, model, device):
    model.eval()   # Set your model to evaluation mode.
    preds = []
    for x in tqdm(test_loader):   # tqmd可作为for循环迭代器使用,同时也提供进度条服务。
        x = x.to(device)
        with torch.no_grad():
            pred = model(x)
            preds.append(pred.detach().cpu())   # detach()函数从原tensor中剥离出一个新的相等tensor,并将新tensor放入cpu。
    preds = torch.cat(preds, dim=0).numpy()   # 将preds列表拼接成tensor,再转化为np array。
    return preds

2.3. 编写Dataset类

class COVID19Dataset(Dataset):
    '''
    x: Features.
    y: Targets, if none, no prediction.
    '''
    def __init__(self, x, y=None):
        if y is None:
            self.y = y
        else:
            self.y = torch.FloatTensor(y)
        self.x = torch.FloatTensor(x)
    
    def __getitem__(self, idx):
        if self.y is None:
            return self.x[idx]
        else:
            return self.x[idx], self.y[idx]
    
    def __len__(self):
        return len(self.x)

2.4. 搭建神经网络结构

2.4.1. 示例代码里的网络结构

示例代码定义了三层的全连接神经网络,使用ReLU函数作为线性层之间的激活函数。

这个模型结构非常简单,可能无法足够拟合我们的训练数据,使用这个模型结构训练出的模型Loss值大约会在2.1左右,只能勉强够上此次作业的及格线。

class MyModel(nn.Module):
    def __init__(self, input_dim):
        super(MyModel, self).__init__()
        # TODO: modify model's structure, be aware of dimensions.
        self.layers = nn.Sequential(
            nn.Linear(input_dim, 16),
            nn.ReLU(),
            nn.Linear(16, 8),
            nn.ReLU(),
            nn.Linear(8, 1)
        )   # 设置模型层次
        
    def forward(self, x):
        x = self.layers(x)   # 令数据x输入模型各层次,得到输出x。
        x = x.squeeze(1)
        return x

通过观察这个模型的结构,我发现其中可能存在的问题:训练数据的维度有117个,即使是我后期尝试修改的训练数据也有100个左右的维度,但是这个模型的第一层直接把100多个维度聚合成16个维度,跨度实在有一些大。因此,我尝试将这个模型的增加层数,并且使得第一层的维度增大,令整个网络的维度递减变得更加平缓。

2.4.2. 我尝试修改的网络结构

class MyModel(nn.Module):
    def __init__(self, input_dim):
        super(MyModel, self).__init__()
        # TODO: modify model's structure, be aware of dimensions.
        self.layers = nn.Sequential(
            nn.Linear(input_dim, 32),
            nn.ReLU(),
            nn.Linear(32, 16),
            nn.ReLU(),
            nn.Linear(16, 8),
            nn.ReLU(),
            nn.Linear(8, 1)
        )   # 设置模型层次
        
    def forward(self, x):
        x = self.layers(x)   # 令数据x输入模型各层次,得到输出x。
        x = x.squeeze(1)
        return x

2.5. 数据特征选取

2.5.1. 示例代码中的特征选取

示例代码默认选取每一条数据的全部117个特征维度。

def select_feat(train_data, valid_data, test_data, select_all=True):
    '''Selects useful features to perform regression'''
    y_train, y_valid = train_data[:,-1], valid_data[:,-1]   # 此处train_data和valid_data为未分离特征值和标签的数据。
    raw_x_train, raw_x_valid, raw_x_test = train_data[:,:-1], valid_data[:,:-1], test_data   # 标签y取最后一列,特征x取前面的所有列。
    
    if select_all:   # 当选取所有特征作为训练数据时。
        feat_idx = list(range(raw_x_train.shape[1]))   # raw_x_train.shape=[条目数, 特征数],取特征数的维度数作为特征总数。
    else:   # 当选取部分特征(用户自定义)作为训练数据时。
        feat_idx = [0, 1, 2, 3, 4, 5]   # TODO: Select suitable feature colums.
    return raw_x_train[:,feat_idx], raw_x_valid[:,feat_idx], raw_x_test[:,feat_idx], y_train, y_valid

2.5.2. 我尝试更改的特征选取

思考之下,我认为特征维度中第一个维度「数据id」和待预测值毫无关联,应当剔除。而37维的联邦州编码应当保留,因为阳性率大概率是存在地区性的差异的,应当保留代表所在地区的数据维度。至于每日的公共卫生数据中,我认为与心理相关的似乎和阳性率关系不大,遂尝试将其剔除。

修改后的特征选取代码如下。需要注意的是,如果选择了部分特征,则需要在 2.7. conifg 中,将 select_all 键的值改为 False。

def select_feat(train_data, valid_data, test_data, select_all=True):
    '''Selects useful features to perform regression'''
    y_train, y_valid = train_data[:,-1], valid_data[:,-1]   # 此处train_data和valid_data为未分离特征值和标签的数据。
    raw_x_train, raw_x_valid, raw_x_test = train_data[:,:-1], valid_data[:,:-1], test_data   # 标签y取最后一列,特征x取前面的所有列。
    
    if select_all:   # 当选取所有特征作为训练数据时。
        feat_idx = list(range(raw_x_train.shape[1]))   # raw_x_train.shape=[条目数, 特征数],取特征数的维度数作为特征总数。
    else:   # 当选取部分特征(用户自定义)作为训练数据时。
        # TODO: Select suitable feature colums.
        feat_idx = list(range(1,37))
        for i in range(5):
            feat_idx += list(range(37+i*16, 37+i*16+13))
        
    return raw_x_train[:,feat_idx], raw_x_valid[:,feat_idx], raw_x_test[:,feat_idx], y_train, y_valid

2.6. 预设训练过程

首先,我们需要定义训练过程的两个必备工具:损失函数(loss function)和优化器(optimizer)。

李宏毅老师在2021年的课程中推导过上述两个工具,初学可能会有些些烧脑。但是在实际的代码实现中,我们不需要太担心上述的数学推导,因为PyTorch已经把上述两个工具进行了很好的封装。
因此,我们只需要学会如何把PyTorch中的损失函数和优化器迎接进我们的训练过程即可。以下是对损失函数和优化器的一些补充说明。

2.6.1. 损失函数

本次作业指定了损失函数为 nn.MSELoss(),其参数reduction有三种选项['none', 'mean', 'sum']:

  • 若选择'none',则会以tensor的形式输出y矩阵各维度的MSE;
  • 若选择'mean',则会以tensor(1)的形式输出各维度MSE的平均值;
  • 若选择'sum',则会以tensor(1)的形式输出各维度MSE的总和 [4]。

2.6.2. 优化器

示例代码使用SGD算法作为模型参数的优化器。

在PyTorch中,需要声明一个变量(例如此处等号前的「optimizer」)来固定我们从torch.optim模块中选择的优化器。只有提前用一个变量将优化器迎接到我们的模型中,它才可以记录我们模型中的参数,以及记录其优化的结果。

此处,我们给优化器设置了三个参数,即参与的优化的参数、学习率(lr)和动量(momentum)。

目前我还不太能完全理解「动量」这个概念。对其粗浅的解释是:一个可以根据此前参数优化的趋势,为后续优化方向提供参考的助力。

其他部分的说明可以参考代码中的注释。这一部分,我修改了原始代码中的进度条展现方式,从原先每一个epoch生成一个进度条更改为全程只用一个进度条展现训练进度,以便查看。

def trainer(train_loader, valid_loader, model, config, device):
    criterion = nn.MSELoss(reduction='mean')   # Define your loss function, do not modify this.
    # Define your optimization algorithm.
    # TODO: Please check https://pytorch.org/docs/stable/optim.html to get more available algorithms.
    # TODO: L2 regularization (optiminzer(weight decay...) or implement by yourself).
    optimizer = torch.optim.SGD(model.parameters(), lr=config['learning_rate'], momentum=0.9)
   
    
    writer = SummaryWriter()   # Writer of tensorboard.
    
    if not os.path.isdir('./models'):   # 此处利用os.path.isdir()函数判断模型存储路径是否存在,以避免os.mksir()函数出错。
        os.mkdir('./models')   # Create directory of saving models.
    
    # 此处定义模型训练的总轮数(n_epochs)以及一系列用于计数的变量(best_loss, step, early_stop_count)。
    n_epochs, best_loss, step, early_stop_count = config['n_epochs'], math.inf, 0, 0
    # best_loss = math.inf 返回浮点数正无穷(+∞)

    train_pbar = tqdm(range(n_epochs), position=0, leave=True)
    
    for epoch in train_pbar:
        model.train() # Set your model to train mode.
        loss_record = []
        
        # tqdm is a package to visualize your training progress.
        # train_pbar = tqdm(train_loader, position=0, leave=True)
        # position=0可以防止多行进度条的情况(?说实话,还是不够清楚理解)。
        
        for x, y in train_loader:
            optimizer.zero_grad()   # Start gradient to zero.
            x, y = x.to(device), y.to(device)   # data.to()函数将数据移至指定设备(CPU/GPU)。
            pred = model(x)
            loss = criterion(pred, y) 
            loss.backward()   # Compute gradient (backpropagation).
            optimizer.step()   # Update parameters.
            step += 1
            loss_record.append(loss.detach().item())
            # 使用PyTorch时,特别需要清楚每个变量的数据类型并在需要时进行变量的拷贝和转换(尤其是在遇到tensor数据类型时)。
            

        
        mean_train_loss = sum(loss_record)/len(loss_record)
        writer.add_scalar('Loss/train', mean_train_loss, step)
        
        model.eval()   # Set your model to evaluation mode.
        loss_record = []
        for x, y in valid_loader:
            x, y = x.to(device), y.to(device)
            with torch.no_grad():   
            # 注意,我们只在train模式下才会计算梯度,在validation和test模式下都需要通过torch.no_grad()把torch调整到非梯度模式。
                pred = model(x)
                loss = criterion(pred, y)
            
            loss_record.append(loss.detach().item())
        
        mean_valid_loss = sum(loss_record)/len(loss_record)
        # print(f'Epoch [{epoch+1}/{n_epochs}]: Train_loss: {mean_train_loss:.4f}, Valid loss: {mean_valid_loss:.4f}')
        writer.add_scalar('Loss/valid', mean_valid_loss, step)
        
        if mean_valid_loss < best_loss:   # 将最佳loss值更新到best_loss变量中。
            best_loss = mean_valid_loss
            torch.save(model.state_dict(), config['save_path'])   # 保存当前步骤的最佳model。
            # print('Saving model with loss {:.3f}...'.format(best_loss))
            early_stop_count = 0
        else:
            early_stop_count += 1   # 记录模型未能优化的次数,为模型收敛中断训练提供参考。
        
        # Display current epoch number and loss on tqdm progress bar.
        train_pbar.set_description(f'Epoch [{epoch+1}/{n_epochs}]')
        train_pbar.set_postfix({'Best loss' : '{0:1.5f}'.format(best_loss)})
        
        if early_stop_count >= config['early_stop']:
            print('\nModel is not improving, so we halt the training session.')
            return None

2.7. 填写配置与超参数

device = 'cuda' if torch.cuda.is_available() else 'cpu'
config = {
    'seed': 5201314,   # Your seed number, you can pick your lucky number. :)
    'select_all': True,   # Whether to use all features.
    'valid_ratio': 0.2,   # validation_size = train_size * valid_ratio
    'n_epochs': 3000,   # Number of epochs.
    'batch_size': 256,
    'learning_rate': 1e-5,
    'early_stop': 400,   # if model has not improved for this many consecutive epochs, stop training.
    'save_path': './models/model.ckpt'   # Your model will be saved here.
}

2.8. 编写DataLoader

DataLoader设置中有一项内存设置 pin_memory,可以参照 [3] 对其进行了解。

# Set seed for reproducibility
same_seed(config['seed'])   # same_seed()函数指定numpy和torch的seed


# train_data size: 2699 x 118 (id + 37 states + 16 features x 5 days)
# test_data size: 1078 x 117 (without last day's positive rate)
# DataFrame.values变量返回DataFrame的numpy_array数据(去除表头)
# btw. 若只需要获取表头,则可使用DataFrame.columns变量
train_data, test_data = pd.read_csv('./data/covid.train.csv').values, pd.read_csv('./data/covid.test.csv').values
train_data, valid_data = train_valid_split(train_data, config['valid_ratio'], config['seed'])

# Print out the data size.
print(f""""train_data size: {train_data.shape}
valid_data size: {valid_data.shape}
test_data size: {test_data.shape}""")

# Select features
x_train, x_valid, x_test, y_train, y_valid = select_feat(train_data, valid_data, test_data, select_all=config['select_all'])

# Print out the number of features
print(f'number of features: {x_train.shape[1]}')

train_dataset, valid_dataset, test_dataset = COVID19Dataset(x_train, y_train), \
                                             COVID19Dataset(x_valid, y_valid), \
                                             COVID19Dataset(x_test)

# PyTorch dataloader loads pytorch dataset into batches.
train_loader = DataLoader(train_dataset, batch_size=config['batch_size'], shuffle=True, pin_memory=True)   
# pin_memory=True意为将内存读取设置为「锁页模式」以减少时间开销。
valid_loader = DataLoader(valid_dataset, batch_size=config['batch_size'], shuffle=True, pin_memory=True)
test_loader = DataLoader(test_dataset, batch_size=config['batch_size'], shuffle=True, pin_memory=True)

2.9. 开始训练!

model = MyModel(input_dim=x_train.shape[1]).to(device)   # 将模型送入训练数据所在设备(CPU/GPU)。
trainer(train_loader, valid_loader, model, config, device)

当上述的训练结束后,我们可以调用 tensorboard 程序将训练过程中由 SummaryWriter 记录的损失函数可视化。

# 删除tensorboard端口使用记录以避免出现错误
import tempfile
import shutil  
tb_info_dir = os.path.join(tempfile.gettempdir(), '.tensorboard-info')   # 获取tensorboard临时文件地址
shutil.rmtree(tb_info_dir)   # 递归删除该临时文件所在目录

在调用tensorboard之前,我们需要运行上述代码清理 tensorboard 的端口使用记录以防止程序加载失败 [5]。

%reload_ext tensorboard
%tensorboard --logdir=./runs/

 随后,即可看到形如下图所示的损失值曲线。

图3 损失值

2.10. 运行测试集

def save_pred(preds, file):
    '''Save predictions to specified file'''
    with open(file, 'w') as fp:
        writer = csv.writer(fp)
        writer.writerow(['id', 'tested_positive'])
        for i, p in enumerate(preds):
            writer.writerow([i, p])

model = MyModel(input_dim=x_train.shape[1]).to(device)
model.load_state_dict(torch.load(config['save_path']))   # 从state_dict文件读取模型信息
preds = predict(test_loader, model, device)
save_pred(preds, 'pred.csv')

经过自己对模型进行的改动,并将优化器更改为Adam,我的模型验证集损失值下降到了0.96左右。

引用与参考:

[1] 李宏毅:Machine Learning 2022 Spring (ML 2022 Spring (ntu.edu.tw))

[2] Heng-Jui Chang:ML2021-Spring/HW01.ipynb at main · ga642381/ML2021-Spring · GitHub

[3] Pytorch DataLoader pin_memory 理解 - 知乎 (zhihu.com)

[4] 【Pytorch基础】torch.nn.MSELoss损失函数_一穷二白到年薪百万的博客-CSDN博客

[5] 【debug】Reusing TensorBoard on port 6006_AryaDP的博客-CSDN博客

封面图片:Photo by Maxime Horlaville on Unsplash

更多推荐