动手学深度学习笔记(一)

ryluo 2020-06-14 01:29:22
动手学深度学习系列

线性回归Softmax回归多层感知机文本预处理语言模型循环神经网络基础

线性回归

回归模型

线性回归是假设模型的输出与输入是线性关系的,即可用如下公式表示:

其中

一般来说,输入样本的特征有多少维度,模型的权重就有多少维度,模型的总参数量为权重的维度+1(表示偏置项)

均方误差

线性回归的损失函数一般是均方误差,单个样本的误差可以表示为:

但是模型在训练的时候一般是以batch的形式进行训练,假如batch的大小为n,则batch的均方误差为每个单独样本的误差求和再求平均值,如下公式所示:

以上是对线性回归基本概念的总结,以及再作业中遇到的问题的总结。

课程学习完之后自己重新实现的线性回归代码链接:线性回归从零实现


Softmax回归

首先需要明确的是Softmax回归是分类模型,并且是线性的分类模型,与线性回归非常的类似。对于线性回归由于是输出连续的值,所以输出的维度是1,而Softmax回归是分类任务输出的维度是类别数量,其两者都可以看成是单层的神经网络。

Softmax操作

其中

如上述公式所示,Softmax操作本质上是一种归一化的操作,将输出的多个维度归一化到0-1之间,这样就可以将每一维度的输出作为样本属于每一类别的概率值,分类任务中经常使用softmax作为最后的分类层。

  1. 输出值的范围不确定,难以确定输出值的意义
  2. 输出值的范围不确定,导致与样本真实标签的差异难以衡量


交叉熵损失

由于均方误差对于处理离散输出的分类任务来说过于严格,所以对于分类任务的损失函数更适合使用度量两个概率分布差异的函数作为其损失函数,而交叉熵是一种常用的度量方法,对于单个样本交叉熵损失公式如下:

其中:

如下是一个三分类问题例子:

模型预测概率 真实的标签 分类结果
0.3 0.2 0.5 0 0 1 true
0.1 0.7 0.2 0 1 0 true
0.2 0.5 0.3 1 0 0 false

根据交叉熵损失的计算公式有:

由于对真实标签进行了one-hot编码,所以从计算公式来看交叉熵损失只关心对正确类别的预测概率。

详解机器学习中的熵、条件熵、相对熵和交叉熵

损失函数 - 交叉熵损失函数


多层感知机(MLP)

多层感知机本质上是通过多个线性层加上了非线性变换,非线性变换是通过激活函数实现的,常见的激活函数及相应的导数总结如下:

ReLu:

Sigmoid:

tanh:

目前绝大多数都是使用ReLu激活函数,因为其计算简单,并且一定程度上可以防止梯度消失问题,只不过ReLu激活函数只能在隐藏层中使用。sigmoid函数一般用于分类任务中,但是sigmoid和tanh都很容易造成梯度消失,所以有时候需要使用ReLu(当层数比较多的时候)。当然随着深度学习的发展研究者们又提出来了大量的激活函数,也都可以去尝试使用。


多层感知机(MLP)就是至少含有一个隐藏层的由全连接层组成的神经网络,并且每个神经网络的输出都通过激活函数进行变换,而多层感知机的层数以及每一层的节点数都是超参数,需要提前定义。

https://blog.csdn.net/edogawachia/article/details/80515038)


文本预处理

正则表达

文本预处理是几乎所有的nlp任务的开始,并且将会花费大量的时间在上面,而在文本处理中正则表达起到了非常中要的作用,主要是对字符串进行过滤与筛选,下面的链接是正则表达的入门链接,在忘记规则的时候去查一下相关的用法。

Py之re:re正则表达式库的简介、入门、使用方法之详细攻略

预处理

文本是一类序列数据,既可以看成是字符串序列也可以看成是词序列,常见的数据预处理主要分为一下四个步骤:

读取文本

Python open()函数用法详解

import collections
import re

def read_time_machine():
    with open('/home/kesci/input/timemachine7163/timemachine.txt', 'r') as f:
        lines = [re.sub('[^a-z]+', ' ', line.strip().lower()) for line in f]
        # 从打开的文本中按照行读取数据,其中line.strip().lower(),进行了两次字符串操作
        # str.strip() 用于移除字符串头尾指定的字符(默认为空格或换行符)或字符序列,在这里是用来删除单词之间的空格和换行符
        # str.lower() 将字符串中的所有大写字母都转化成小写
        # 正则表达re.sub('[^a-z]+', ' ', str) 表示的是将字符串str中以非a-z的字符串开头的字符串替换为空格
    return lines

lines = read_time_machine()
print('# sentences %d' % len(lines))

分词

文本的分词技术已经有了很多现成的软件包,并且根据文本的不同以及需要对文本的预处理不同选择的分词软件包也会不一样,下面是一个直接将英文文本通过空格或者通过直接将字符串直接分词的方法

def tokenize(sentences, token='word'):
    if token == 'word':
        return [sentence.split(' ') for sentence in sentences]
       elif token == 'char':
        return [list(sentence) for sentence in sentences] # 直接把一个句子全部转化成字符串
    else:
        print('ERROR: unkown token type '+token)

建立字典

Vocab类想实现将词映射成一个索引,既然是索引那么相同的词就应该具有相同的索引,所以这里对于输入的文本还会进行一个去重的操作。

此外,Vocab还想方便的获取给定某个词对应的索引,以及给定一个索引获取这个索引所对应的词。除了上面说的两个功能,还有一个就是统计了每一个词的词频。

分词工具


语言模型

语言模型

一段自然语言文本可以看作是一个离散时间序列,给定一个长度为$T$的词的序列$w_1, w_2, \ldots, w_T$,语言模型的目标就是评估该序列是否合理,即计算该序列的概率:

假设序列$w_1, w_2, \ldots, w_T$中的每个词是依次生成的,我们有

例如,一段含有4个词的文本序列的概率

语言模型的参数就是词的概率以及给定前几个词情况下的条件概率。设训练数据集为一个大型文本语料库,如维基百科的所有条目,词的概率可以通过该词在训练数据集中的相对词频来计算,例如,$w_1$的概率可以计算为:

其中$n(w_1)$为语料库中以$w_1$作为第一个词的文本的数量,$n$为语料库中文本的总数量。

类似的,给定$w_1$情况下,$w_2$的条件概率可以计算为:

其中$n(w_1, w_2)$为语料库中以$w_1$作为第一个词,$w_2$作为第二个词的文本的数量。


n元语法

序列长度增加,计算和存储多个词共同出现的概率的复杂度会呈指数级增加。$n$元语法通过马尔可夫假设简化模型,马尔科夫假设是指一个词的出现只与前面$n$个词相关,即$n$阶马尔可夫链(Markov chain of order $n$),如果$n=1$,那么有$P(w_3 \mid w_1, w_2) = P(w_3 \mid w_2)$。基于$n-1$阶马尔可夫链,我们可以将语言模型改写为

以上也叫$n$元语法($n$-grams),它是基于$n - 1$阶马尔可夫链的概率语言模型。例如,当$n=2$时,含有4个词的文本序列的概率就可以改写为:

当$n$分别为1、2和3时,我们将其分别称作一元语法(unigram)、二元语法(bigram)和三元语法(trigram)。例如,长度为4的序列$w_1, w_2, w_3, w_4$在一元语法、二元语法和三元语法中的概率分别为

当$n$较小时,$n$元语法往往并不准确。例如,在一元语法中,由三个词组成的句子“你走先”和“你先走”的概率是一样的。然而,当$n$较大时,$n$元语法需要计算并存储大量的词频和多词相邻频率。

n元语法的缺陷

  1. 参数空间过大(模型不仅需要计算单个词的词频,还要计算多次相邻的频率,参数量是非常大的)
  2. 数据稀疏(一般词频比较高的词,其重要程度就越低,但是对于重要的词,词频却很小)


时序数据的采样

在训练中我们需要每次随机读取小批量样本和标签。与之前章节的实验数据不同的是,时序数据的一个样本通常包含连续的字符。假设时间步数为5,样本序列为5个字符,即“想”“要”“有”“直”“升”。该样本的标签序列为这些字符分别在训练集中的下一个字符,即“要”“有”“直”“升”“机”,即$X$=“想要有直升”,$Y$=“要有直升机”。

现在我们考虑序列“想要有直升机,想要和你飞到宇宙去”,如果时间步数为5,有以下可能的样本和标签:

可以看到,如果序列的长度为$T$,时间步数为$n$,那么一共有$T-n$个合法的样本,但是这些样本有大量的重合,我们通常采用更加高效的采样方式。我们有两种方式对时序数据进行采样,分别是随机采样和相邻采样。

随机采样

下面的代码每次从数据里随机采样一个小批量。其中批量大小batch_size是每个小批量的样本数,num_steps是每个样本所包含的时间步数。
在随机采样中,每个样本是原始序列上任意截取的一段序列,相邻的两个随机小批量在原始序列上的位置不一定相毗邻。

# 函数的返回值
# corpus_indices: 输入语料的索引列表,用于采样数据集用的,没有经过删减(除了'\r'和'\n')
# char_to_idx: 由词转换成索引的列表
# idx_to_char: 由索引转换成词的列表
# vocab_size: 输入语料的大小
def load_data_jay_lyrics():
    with open('/home/kesci/input/jaychou_lyrics4703/jaychou_lyrics.txt') as f:
        corpus_chars = f.read()
    corpus_chars = corpus_chars.replace('\n', ' ').replace('\r', ' ')
    corpus_chars = corpus_chars[0:10000]
    idx_to_char = list(set(corpus_chars))
    char_to_idx = dict([(char, i) for i, char in enumerate(idx_to_char)])
    vocab_size = len(char_to_idx)
    corpus_indices = [char_to_idx[char] for char in corpus_chars]
    return corpus_indices, char_to_idx, idx_to_char, vocab_size
import torch
import random
def data_iter_random(corpus_indices, batch_size, num_steps, device=None):
    # 减1是因为对于长度为n的序列,X最多只有包含其中的前n - 1个字符
    num_examples = (len(corpus_indices) - 1) // num_steps  # 下取整,得到不重叠情况下的样本个数
    example_indices = [i * num_steps for i in range(num_examples)]  # 每个样本的第一个字符在corpus_indices中的下标
    random.shuffle(example_indices)

    def _data(i):
        # 返回从i开始的长为num_steps的序列
        return corpus_indices[i: i + num_steps]
    if device is None:
        device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')

    for i in range(0, num_examples, batch_size):
        # 每次选出batch_size个随机样本
        batch_indices = example_indices[i: i + batch_size]  # 当前batch的各个样本的首字符的下标
        X = [_data(j) for j in batch_indices]
        Y = [_data(j + 1) for j in batch_indices]
        yield torch.tensor(X, device=device), torch.tensor(Y, device=device)

相邻采样

在相邻采样中,相邻的两个随机小批量在原始序列上的位置相毗邻。

def data_iter_consecutive(corpus_indices, batch_size, num_steps, device=None):
    if device is None:
        device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
    corpus_len = len(corpus_indices) // batch_size * batch_size  # 保留下来的序列的长度
    corpus_indices = corpus_indices[: corpus_len]  # 仅保留前corpus_len个字符
    indices = torch.tensor(corpus_indices, device=device)
    indices = indices.view(batch_size, -1)  # resize成(batch_size, )
    batch_num = (indices.shape[1] - 1) // num_steps
    for i in range(batch_num):
        i = i * num_steps
        X = indices[:, i: i + num_steps]
        Y = indices[:, i + 1: i + num_steps + 1]
        yield X, Y


循环神经网络

网络结构及模型

循环神经网络引入一个隐藏变量$H$,用$H_{t}$表示$H$在时间步$t$的值。$H_{t}$的计算基于$X_{t}$和$H_{t-1}$,可以认为$H_{t}$记录了到当前字符为止的序列信息,利用$H_{t}$对序列的下一个字符进行预测。

假设$\boldsymbol{X}_t \in \mathbb{R}^{n \times d}$是时间步$t$的小批量输入,$\boldsymbol{H}_t \in \mathbb{R}^{n \times h}$是该时间步的隐藏变量,则:

其中,$\boldsymbol{W}_{xh} \in \mathbb{R}^{d \times h}$,$\boldsymbol{W}_{hh} \in \mathbb{R}^{h \times h}$,$\boldsymbol{b}_{h} \in \mathbb{R}^{1 \times h}$,$\phi$函数是非线性激活函数。由于引入了$\boldsymbol{H}_{t-1} \boldsymbol{W}_{hh}$,$H_{t}$能够捕捉截至当前时间步的序列的历史信息,就像是神经网络当前时间步的状态或记忆一样。由于$H_{t}$的计算基于$H_{t-1}$,上式的计算是循环的,使用循环计算的网络即循环神经网络(recurrent neural network)。

在时间步$t$,输出层的输出为:

其中$\boldsymbol{W}_{hq} \in \mathbb{R}^{h \times q}$,$\boldsymbol{b}_q \in \mathbb{R}^{1 \times q}$。

模型参数

循环神经网络的参数就是上述的三个权重和两个偏置,并且在沿着时间训练(参数的更新),参数的数量没有发生变化,仅仅是上述的参数的值在更新。循环神经网络可以看作是沿着时间维度上的权值共享

在卷积神经网络中,一个卷积核通过在特征图上滑动进行卷积,是空间维度的权值共享。在卷积神经网络中通过控制特征图的数量来控制每一层模型的复杂度,而循环神经网络是通过控制W_xh和W_hh中h的维度来控制模型的复杂度。

一个batch的数据的表示

如何将一个batch的数据转换成时间步数个(批量大小,词典大小)的矩阵?

  • 每个字符都是一个词典大小的向量,每个样本是时间步数个序列,每个batch是批量大小个样本
  • 第一个(批量大小,词典大小)的矩阵:取出一个批量样本中每个序列的第一个字符,并将每个字符展开成词典大小的向量,就形成了第一个时间步所表示的矩阵
  • 第二个(批量大小,词典大小)的矩阵:取出一个批量样本中每个序列的第二个字符,并将每个字符展开成词典大小的向量,就形成了第二个时间步所表示的矩阵
  • 最后就形成了时间步个(批量大小,词典大小)的矩阵,这也就是每个batch最后的形式

隐藏状态的初始化

随机采样时:每次迭代都需要重新初始化隐藏状态(每个epoch有很多词迭代,每次迭代都需要进行初始化,因为对于随机采样的样本中只有一个批量内的数据是连续的)

相邻采样时:如果是相邻采样,则说明前后两个batch的数据是连续的,所以在训练每个batch的时候只需要更新一次(也就是说模型在一个epoch中的迭代不需要重新初始化隐藏状态)

编程知识点总结

torch的广播机制

例子:

import torch

x = torch.empty(5, 1, 4, 1)
y = torch.empty(   3, 1, 1)
print((x + y).size())

x=torch.empty(1)
y=torch.empty(3,1,7)
print((x+y).size())

x=torch.empty(5,2,4,1)
y=torch.empty(  3,1,1)
print((x+y).size())

输出结果:

torch.Size([5, 3, 4, 1])
torch.Size([3, 1, 7])
Traceback (most recent call last):
  File "test.py", line 13, in <module>
    print((x+y).size())
RuntimeError: The size of tensor a (2) must match the size of tensor b (3) at non-singleton dimension 1

比较好的示例链接torch的广播机制(broadcast mechanism

torch.gather的用法

如下是gather的官方解释

torch.gather(input, dim, index, out=None) → Tensor

    Gathers values along an axis specified by dim.

    For a 3-D tensor the output is specified by:

    out[i][j][k] = input[index[i][j][k]][j][k]  # dim=0
    out[i][j][k] = input[i][index[i][j][k]][k]  # dim=1
    out[i][j][k] = input[i][j][index[i][j][k]]  # dim=2

    Parameters: 

        input (Tensor) – The source tensor
        dim (int) – The axis along which to index
        index (LongTensor) – The indices of elements to gather
        out (Tensor, optional) – Destination tensor
    Example:

    >>> t = torch.Tensor([[1,2],[3,4]])
    >>> torch.gather(t, 1, torch.LongTensor([[0,0],[1,0]]))
     1  1
     4  3
    [torch.FloatTensor of size 2x2]

其实gather要做的事情就是根据index从input中选元素,而dim就是控制从哪个维度选择如示例中:

input:

1   2
3   4

index:

0   0 
1   0

如果dim=1,则说明是从行方向上选取,也就是说index中的数代表的是列索引

如果dim=0,则说明是从列方向上选取,也就是说index中的数代表的是行索引

而index的形状决定了最后选出来数据的形状,所以可以得出选择出来的元素为:

1   1
4   3

更加具体的说就是,再index中左上角的0表示的是第一行第一列的数,右上角的0代表第一行第一列的数,左下角的1表示的是第二行第二个数,右下角的0表示的是第二行第一个数

gather在one-hot为输出的多分类问题中,可以把最大值坐标作为index传进去,然后提取到每一行的正确预测结果,这也是gather可能的一个作用。