Press "Enter" to skip to content

神经网络之归一化与Batch Normalization理论

这是我参与8月更文挑战的第31天,活动详情查看:8月更文挑战

 

一 简介

数据归一化是机器学习领域的一种对数据处理的常规方式。在传统机器学习领域,由于各特征的量纲不一致,可能出现建模过程中量纲较大的特征得不到有效学习的情况,而通过归一化处理之后的数据可以统一放缩在一个区间内,从而避免的各量纲的学习偏差问题,并且,归一化处理之后的数据能够能够提升模型训练效率、加快模型收敛速度、提升模型稳定性。当然,在传统机器学习领域,有很多需要确保模型可解释的情况,而对数据进行归一化处理会降低模型本身的可解释性。
而在深度学习领域,将数据处理成Zero-centered Data,将能够有效确保模型各层学习的有效性,缓解梯度消失和梯度爆炸的情况发生,并且深度学习并不要求可解释性,因此数据标准化并不存在太多障碍。
当然,深度学习的数据归一化和经典机器学习的归一化有较大差别,但本质上理论是相通的,

1.1 Z-Score标准化

 

和0-1标准化不同,Z-score标准化利用原始数据的均值(mean)和标准差(standard deviation)进行数据的标准化。同样是逐列进行操作,每一条数据都减去当前列的均值再除以当前列的标准差。很明显,通过这种方法处理之后的数据是典型的Zero-Centered Data,并且如果原数据服从正态分布,通过Z-Score处理之后将服从标准正态分布。Z-Score标准化计算公式如下:

 

 

其中 μ代表均值, σ代表标准差。 将Z-Score标准化过程封装为一个函数

 

import random
import matplotlib as mpl
import matplotlib.pyplot as plt
from mpl_toolkits .mplot3d import Axes3D
import seaborn as sns
import numpy as np
import torch
from torch import nn,optim
import torch.nn.functional as F
from torch.utils .data import Dataset,TensorDataset,DataLoader
from torch.utils.data import random_split
from torch.utils.tensorboard import SummaryWriter

 

本文涉及到的自建函数

 

# 回归类数据集创建函数
def tensorGenReg(num_examples = 1000, w = [2, -1, 1], bias = True, delta = 0.01, deg = 1):
    """回归类数据集创建函数。
    :param num_examples: 创建数据集的数据量
    :param w: 包括截距的(如果存在)特征系数向量
    :param bias:是否需要截距
    :param delta:扰动项取值
    :param deg:方程次数
    :return: 生成的特征张和标签张量
    """
    
    if bias == True:
        num_inputs = len(w)-1                                                        # 特征张量
        features_true = torch.randn(num_examples, num_inputs)                        # 不包含全是1的列的特征张量
        w_true = torch.tensor(w[:-1]).reshape(-1, 1).float()                         # 自变量系数
        b_true = torch.tensor(w[-1]).float()                                         # 截距
        if num_inputs == 1:                                                          # 若输入特征只有1个,则不能使用矩阵乘法
            labels_true = torch.pow(features_true, deg) * w_true + b_true
        else:
            labels_true = torch.mm(torch.pow(features_true, deg), w_true) + b_true
        features = torch.cat((features_true, torch.ones(len(features_true), 1)), 1)  # 在特征张量的最后添加一列全是1的列
        labels = labels_true + torch.randn(size = labels_true.shape) * delta         
                
    else: 
        num_inputs = len(w)
        features = torch.randn(num_examples, num_inputs)
        w_true = torch.tensor(w).reshape(-1, 1).float()
        if num_inputs == 1:
            labels_true = torch.pow(features, deg) * w_true
        else:
            labels_true = torch.mm(torch.pow(features, deg), w_true)
        labels = labels_true + torch.randn(size = labels_true.shape) * delta
    return features, labels
# 常用数据处理类
# 适用于封装自定义数据集的类
class GenData(Dataset):
    def __init__(self, features, labels):           
        self.features = features                    
        self.labels = labels                       
        self.lens = len(features)                  
    def __getitem__(self, index):
        return self.features[index,:],self.labels[index]    
    def __len__(self):
        return self.lens
def split_loader(features, labels, batch_size=10, rate=0.7):
    """数据封装、切分和加载函数:
    
    :param features:输入的特征 
    :param labels: 数据集标签张量
    :param batch_size:数据加载时的每一个小批数据量 
    :param rate: 训练集数据占比
    :return:加载好的训练集和测试集
    """    
    data = GenData(features, labels) 
    num_train = int(data.lens * 0.7)
    num_test = data.lens - num_train
    data_train, data_test = random_split(data, [num_train, num_test])
    train_loader = DataLoader(data_train, batch_size=batch_size, shuffle=True)
    test_loader = DataLoader(data_test, batch_size=batch_size, shuffle=False)
    return(train_loader, test_loader)
class Sigmoid_class3(nn.Module):                                   
    def __init__(self, in_features=2, n_hidden1=4, n_hidden2=4, n_hidden3=4, out_features=1, BN_model=None):       
        super(Sigmoid_class3, self).__init__()
        self.linear1 = nn.Linear(in_features, n_hidden1)
        self.normalize1 = nn.BatchNorm1d(n_hidden1)
        self.linear2 = nn.Linear(n_hidden1, n_hidden2)
        self.normalize2 = nn.BatchNorm1d(n_hidden2)
        self.linear3 = nn.Linear(n_hidden2, n_hidden3)
        self.normalize3 = nn.BatchNorm1d(n_hidden3)
        self.linear4 = nn.Linear(n_hidden3, out_features) 
        self.BN_model = BN_model
        
    def forward(self, x): 
        if self.BN_model == None:
            z1 = self.linear1(x)
            p1 = torch.sigmoid(z1)
            z2 = self.linear2(p1)
            p2 = torch.sigmoid(z2)
            z3 = self.linear3(p2)
            p3 = torch.sigmoid(z3)
            out = self.linear4(p3)
        elif self.BN_model == 'pre':
            z1 = self.normalize1(self.linear1(x))
            p1 = torch.sigmoid(z1)
            z2 = self.normalize2(self.linear2(p1))
            p2 = torch.sigmoid(z2)
            z3 = self.normalize3(self.linear3(p2))
            p3 = torch.sigmoid(z3)
            out = self.linear4(p3)
        elif self.BN_model == 'post':
            z1 = self.linear1(x)
            p1 = torch.sigmoid(z1)
            z2 = self.linear2(self.normalize1(p1))
            p2 = torch.sigmoid(z2)
            z3 = self.linear3(self.normalize2(p2))
            p3 = torch.sigmoid(z3)
            out = self.linear4(self.normalize3(p3))
        return out
def mse_cal(data_loader, net):
    """mse计算函数
    
    :param data_loader:加载好的数据
    :param net: 模型
    :return:根据输入的数据,输出其MSE计算结果
    """
    data = data_loader.dataset                # 还原Dataset类
    X = data[:][0]                            # 还原数据的特征
    y = data[:][1]                            # 还原数据的标签
    yhat = net(X)
    return F.mse_loss(yhat, y)
def fit(net, criterion, optimizer, batchdata, epochs=3, cla=False):
    """模型训练函数
    
    :param net:待训练的模型 
    :param criterion: 损失函数
    :param optimizer:优化算法
    :param batchdata: 训练数据集
    :param cla: 是否是分类问题
    :param epochs: 遍历数据次数
    """
    for epoch  in range(epochs):
        for X, y in batchdata:
            if cla == True:
                y = y.flatten().long()          # 如果是分类问题,需要对y进行整数转化
            yhat = net.forward(X)
            loss = criterion(yhat, y)
            optimizer.zero_grad()
            loss.backward()
            optimizer.step()
def model_train_test(model, 
                     train_data,
                     test_data,
                     num_epochs = 20, 
                     criterion = nn.MSELoss(), 
                     optimizer = optim.SGD, 
                     lr = 0.03, 
                     cla = False, 
                     eva = mse_cal):
    """模型误差测试函数:
    
    :param model_l:模型
    :param train_data:训练数据
    :param test_data: 测试数据   
    :param num_epochs:迭代轮数
    :param criterion: 损失函数
    :param lr: 学习率
    :param cla: 是否是分类模型
    :return:MSE列表
    """  
    # 模型评估指标矩阵
    train_l = []
    test_l = []
    # 模型训练过程
    for epochs in range(num_epochs):
        model.train()
        fit(net = model, 
            criterion = criterion, 
            optimizer = optimizer(model.parameters(), lr = lr), 
            batchdata = train_data, 
            epochs = epochs, 
            cla = cla)
        model.eval()
        train_l.append(eva(train_data, model).detach())
        test_l.append(eva(test_data, model).detach())
    return train_l, test_l
def weights_vp(model, att="grad"):
    """观察各层参数取值和梯度的小提琴图绘图函数。
    
    :param model:观察对象(模型)
    :param att:选择参数梯度(grad)还是参数取值(weights)进行观察
    :return: 对应att的小提琴图    
    """
    vp = []
    for i, m in enumerate(model.modules()):
        if isinstance(m, nn.Linear):
            if att == "grad":
                vp_x = m.weight.grad.detach().reshape(-1, 1).numpy()
            else:
                vp_x = m.weight.detach().reshape(-1, 1).numpy()
            vp_y = np.full_like(vp_x, i)
            vp_a = np.concatenate((vp_x, vp_y), 1)
            vp.append(vp_a)
    vp_r = np.concatenate((vp), 0)
    ax = sns.violinplot(y = vp_r[:, 0], x = vp_r[:, 1])
    ax.set(xlabel='num_hidden', title=att)
    
    
class tanh_class2(nn.Module):                                   
    def __init__(self, in_features=2, n_hidden1=4, n_hidden2=4, out_features=1, BN_model=None):       
        super(tanh_class2, self).__init__()
        self.linear1 = nn.Linear(in_features, n_hidden1)
        self.normalize1 = nn.BatchNorm1d(n_hidden1)
        self.linear2 = nn.Linear(n_hidden1, n_hidden2)
        self.normalize2 = nn.BatchNorm1d(n_hidden2)
        self.linear3 = nn.Linear(n_hidden2, out_features) 
        self.BN_model = BN_model
        
    def forward(self, x):
        if self.BN_model == None:
            z1 = self.linear1(x)
            p1 = torch.tanh(z1)
            z2 = self.linear2(p1)
            p2 = torch.tanh(z2)
            out = self.linear3(p2)
        elif self.BN_model == 'pre':
            z1 = self.normalize1(self.linear1(x))
            p1 = torch.tanh(z1)
            z2 = self.normalize2(self.linear2(p1))
            p2 = torch.tanh(z2)
            out = self.linear3(p2)
        elif self.BN_model == 'post':
            z1 = self.linear1(x)
            p1 = torch.tanh(z1)
            z2 = self.linear2(self.normalize1(p1))
            p2 = torch.tanh(z2)
            out = self.linear3(self.normalize2(p2))
        return out
    
# 分类数据集的创建函数
def tensorGenCla(num_examples = 500, num_inputs = 2, num_class = 3, deg_dispersion = [4, 2], bias = False):
    """分类数据集创建函数。
    
    :param num_examples: 每个类别的数据数量
    :param num_inputs: 数据集特征数量
    :param num_class:数据集标签类别总数
    :param deg_dispersion:数据分布离散程度参数,需要输入一个列表,其中第一个参数表示每个类别数组均值的参考、第二个参数表示随机数组标准差。
    :param bias:建立模型逻辑回归模型时是否带入截距
    :return: 生成的特征张量和标签张量,其中特征张量是浮点型二维数组,标签张量是长正型二维数组。
    """
    
    cluster_l = torch.empty(num_examples, 1)                         # 每一类标签张量的形状
    mean_ = deg_dispersion[0]                                        # 每一类特征张量的均值的参考值
    std_ = deg_dispersion[1]                                         # 每一类特征张量的方差
    lf = []                                                          # 用于存储每一类特征张量的列表容器
    ll = []                                                          # 用于存储每一类标签张量的列表容器
    k = mean_ * (num_class-1) / 2                                    # 每一类特征张量均值的惩罚因子(视频中部分是+1,实际应该是-1)
    
    for i in range(num_class):
        data_temp = torch.normal(i*mean_-k, std_, size=(num_examples, num_inputs))     # 生成每一类张量
        lf.append(data_temp)                                                           # 将每一类张量添加到lf中
        labels_temp = torch.full_like(cluster_l, i)                                    # 生成类一类的标签
        ll.append(labels_temp)                                                         # 将每一类标签添加到ll中
        
    features = torch.cat(lf).float()
    labels = torch.cat(ll).long()
    
    if bias == True:
        features = torch.cat((features, torch.ones(len(features), 1)), 1)              # 在特征张量中添加一列全是1的列
    return features, labels

 

def Z_ScoreNormalization(data):
    stdDf = data.std(0)
    meanDf = data.mean(0)
    normSet = (data - meanDf) / stdDf
    return normSet

 

二 归一化算法在深度学习中的实践

在训练集上训练,测试集上测试

在建模之前,首先需要明确两个问题,其一是标签是否需要标准化(如果是回归类问题的话),其二是测试集的特征是否需要标准化? 首先,标签是否标准化对建模没有影响,因此一般我们不会对标签进行标准化;其次,在实际模型训练过程中,由于数据集要划分成训练集和测试集,因此一般来说我们会在训练集的特征中逐行计算其均值和标准差,然后进行模型训练,当输入测试集进行测试时,我们会将在训练集上计算得出的每一列的均值和标准差带入测试集并对测试集特征进行标准化,然后再带入进行模型测试。值得注意的是,此时进行标准化时涉及到的每一列的均值和方差也相当于是模型参数,必须从训练集上得出,不能借助测试集的数据。

 

2.1 Z-Score建模实验

 

对数据进行标准化,再带入模型进行训练,测试Z-Score标准化对深度学习模型的实际效果,此处简化了在训练集上计算均值方差再带入测试集进行操作的流程,直接采用全部数据集进行数据归一化操作。

 

#设置随机种子
torch.manual_seed(420)
#创建最高项为2的多项式回归数据集
features,labels=tensorGenReg(w=[2,-1],bias=False,deg=2)
features_norm=Z_ScoreNormalization(features)
#进行数据集切分与加载
train_loader,test_loader=split_loader(features,labels)
train_loader_norm,test_loader=split_loader(features_norm,labels)

 

#设置随机种子
torch.manual_seed(420)
#关键参数
lr=0.03
num_epochs=40
#实例化模型
sigmoid_model3=Sigmoid_class3()
sigmoid_model3_norm=Sigmoid_class3()
#进行Xavier初始化
for m in sigmoid_model3.modules():
    if isinstance(m,nn.Linear):
        nn.init.xavier_uniform_(m.weight)
        
        
for m in sigmoid_model3_norm.modules():
    if isinstance(m,nn.Linear):
        nn.init.xavier_uniform_(m.weight)
        
        
#sigmoid_model3模型训练
train_l,test_l=model_train_test(sigmoid_model3
                               ,train_loader
                                ,test_loader
                                ,num_epochs=num_epochs
                                ,criterion=nn.MSELoss()
                                ,optimizer=optim.SGD
                                ,lr=lr
                               , cla=False
                                ,eva=mse_cal
                               )
#sigmoid_model3_norm模型训练
train_l_norm,test_l_norm=model_train_test(sigmoid_model3_norm
                                         ,train_loader_norm
                                          ,test_loader
                                          ,num_epochs=num_epochs
                                          ,criterion=nn.MSELoss()
                                          ,optimizer=optim.SGD
                                          ,lr=lr
                                          ,cla=False
                                          ,eva=mse_cal
                                         )
plt.plot(list(range(num_epochs)),train_l,label='train_mse')
plt.plot(list(range(num_epochs)),train_l_norm,label='train_norm_mse')
plt.legend(loc=1)

 

从模型最终运行结果能够看出,经过Z-Score归一化的数据收敛速度更快,在某些情况下也能获得更好的结果,

 

三 Z-Score数据归一化的局限

 

Z-Score初始化并不是为深度学习算法量身设计的数据归一化方法,在实际神经网络建模过程中,Z-Score的使用还是存在很多局限,具体来说主要有以下两点。

 

3.1 Zero-Centered特性消失

尽管Z-Score归一化能够一定程度保证梯度平稳,进而提升模型收敛速度甚至是提升模型效果,但是,和Xavier初始化方法一样,由于是对于“初始值”的修改,因此也会存在随着迭代次数增加就逐渐破坏了Zero-Centered Data这一条件。
并且,随着参数和输入数据都回到不可控状态,各层的梯度又将回到不可控的状态,而所谓的控制梯度平稳性也无法做到
创建相对梯度容易不平稳的tanh激活函数模型,查看迭代5轮和40轮时各层梯度变化情况。

#设置随机种子
torch.manual_seed(420)
#学习率
lr=0.03
#实例化模型
tanh_model2_norm1=tanh_class2()
tanh_model2_norm2=tanh_class2()
#进行Xavier初始化
for m in tanh_model2_norm1.modules():
    if isinstance(m,nn.Linear):
        nn.init.xavier_uniform_(m.weight)
        
for m in tanh_model2_norm2.modules():
    if isinstance(m,nn.Linear):
        nn.init.xavier_uniform_(m.weight)
        
#tanh_model2模型训练
train_l,test_l=model_train_test(tanh_model2_norm1
                               ,train_loader_norm
                                ,test_loader
                                ,num_epochs=5
                                ,criterion=nn.MSELoss()
                                ,optimizer=optim.SGD
                                ,lr=lr
                                ,cla=False
                                ,eva=mse_cal                        
                               )
#tanh_model2_norm模型训练
train_l_norm,test_l_norm=model_train_test(tanh_model2_norm2
                                         ,train_loader_norm
                                          ,test_loader
                                          ,num_epochs=40
                                          ,criterion=nn.MSELoss()
                                          ,optimizer=optim.SGD
                                          ,lr=lr
                                          ,cla=False
                                          ,eva=mse_cal
                                         )
weights_vp(tanh_model2_norm1,att='grad')

 

 

weights_vp(tanh_model2_norm2,att='grad')

 

刚开始时梯度较为平稳,而迭代到后期时就出现了明显的梯度爆炸现象。

 

3.2 Zero-Centered Data的作用局限

 

输入数据在迭代过程中会逐渐丧失Zero-Centered特性外,Z-Score标准化在应用到深度学习模型中,Zero-Centered Data本身作用范围也是有限的,即使维持输入数据的Zero-Centered特性,也很难保证只凭借这一点就能确保梯度平稳。 由于各层的梯度实际上受到激活函数、各层输入数据和参数三者共同影响,因此哪怕将所有的输入数据都调整为零均值的,各层梯度的计算结果还是有可能因为受到其他因素影响导致不平稳。因此,一味追求输入数据的Zero-Centered或许并不是最好的选择。

 

四 输入数据调整保证梯度平稳

影响梯度平稳性的核心因素有三个,其一是各层的参数、其二是各线性层接收到的数据、其三则是激活函数。除了参数调整外,在确保梯度平稳性上就只剩下选择激活函数和调整输入数据两种方法。
Batch Normalization已经是被验证的、行之有效的模型优化手段,
深度学习作为“实证型”技术,在很多时候模型效果才是首要考虑因素,因此类似BN这种,虽然理论基础不成立,但实践效果很好的方法在深度学习领域是广泛存在的,但是,这并不意味着我们可以不管不顾只讨论怎幺用而忽略背后的理论讨论。对于一名合格的算法工程师,我们还是需要对诸多方法的使用及原理背景树立正确的认知。

4.1 归一化方法与数据分布的相互独立

 

任何归一化的本质都是对数据进行平移和放缩,所谓平移,就是指数据集每一列统一加上或减去某一个数,在Z-Score中就是每一列减去该列的均值,而所谓的放缩,就是指数据集中每一列数据统一除以或乘以某一个数,在Z-Score中就是每一列除以当前列的标准差。而数据的平移和放缩,是不会影响数据特征的分布情况的。

 

#设置随机种子
torch.manual_seed(420)
#创建数据集
features,labels=tensorGenCla(num_class=2,deg_dispersion=[6,2])
#查看其分布
plt.scatter(features[:,0],features[:,1],c=labels)

 

 

features

 

 

f=Z_ScoreNormalization(features)
f

 

对比归一化前后数据集分布

 

plt.subplot(121)
plt.scatter(features[:,0],features[:,1],c=labels)
plt.title('features distribution')
plt.subplot(122)
plt.scatter(f[:,0],f[:,1],c=labels)
plt.title('f distribution')

 

归一化前后数据分布不变,但数据在空间中的坐标的绝对值发生变化。
数据的分布其实就代表着数据背后的规律,我们使用模型去捕捉数据规律,其实就是对数据分布情况进行学习。因此,数据归一化不修改数据分布,是我们使用归一化方法的基本前提,否则,一旦数据归一化方法会修改数据分布,则相当于是人为破坏了数据原始规律,这将会对后续的模型学习造成巨大的影响。

Be First to Comment

发表回复

您的电子邮箱地址不会被公开。 必填项已用*标注