前言

本文教程均来自b站【小白也能听懂的人工智能原理】,感兴趣的可自行到b站观看。

代码及工具箱

本专栏的代码和工具函数已经上传到GitHub:1571859588/xiaobai_AI: 零基础入门人工智能 (github.com),可以找到对应课程的代码

正文

对于时间上有关联性的数据,如语音和文字,我们需要一种能够理解序列数据的神经网络。这种网络就是循环神经网络(RNN)。RNN的设计理念是利用记忆来处理序列数据,它的隐藏层不仅取决于当前输入,还取决于上一个时间点的隐藏状态。
以处理文字为例,我们首先需要将文字转换为计算机能够理解的数字形式,这通常是通过嵌入层实现的。嵌入层将每个单词映射为一个固定长度的向量,这个向量能够捕捉单词的语义信息。
接下来,这些向量序列被输入到RNN中。RNN的每个时间点都会处理一个单词向量,并更新其内部状态,这个状态包含了之前所有单词的信息。这样,RNN能够在整个句子层面上理解单词的上下文。
最后,RNN的输出可以被用来进行分类任务,比如判断评论是正面的还是负面的。通过训练,RNN学会在序列的每个时间点捕捉到关键信息,并能够在整个序列处理完成后做出决策。
总结来说,对于时间序列数据,我们使用RNN来处理,因为它能够记住序列中的信息,并利用这些信息来理解和预测序列的下一个状态。

如何把文字转成计算机能够识别的数字?

词典索引

在自然语言处理中,我们通常将词作为处理的基本单位,因为词能够更好地捕捉语言的语义。为了将词转换为数字,我们可以使用一种叫做“词袋”模型的方法。这种方法简单来说,就是给每个词分配一个唯一的数字ID。
例如,我们可以通过查词典,找到每个词在词典中的位置,这个位置就可以作为这个词的数字表示。比如,“nice”是第6787个词,“to”是第2845个词,“meet”是第5898个词,“you”是第9032个词。这样,整句话“nice to meet you”就可以被转换为数字序列[6787, 2845, 5898, 9032]。
这种转换后的数字序列可以作为神经网络的输入。然而,这种方法有一个缺点,就是它没有考虑到词之间的上下文关系。为了更好地捕捉词的语义和上下文信息,我们通常使用词嵌入技术,如Word2Vec或GloVe,这些技术可以将每个词映射到一个高维空间中的向量,其中向量之间的距离反映了词之间的语义关系。这样,神经网络就能更好地理解和处理自然语言数据。

处理中文文本时,我们首先需要进行分词,因为中文没有像英文那样的自然空格来分隔单词。分词是将句子切割成一个个有意义的词的过程。一旦我们有了分好的词汇,我们就可以像处理英文那样,将每个词转换成它在词典中的位置,也就是一个数字。
但是,这种方法可能会遇到一个问题。如果两个词在词典中的位置很接近,比如“开除”和“开心”,它们在数字表示上可能非常相似,尤其是在进行归一化处理后。这种相似性可能会误导我们的模型,因为它没有考虑到词的具体含义和上下文。
为了解决这个问题,我们可以使用词嵌入技术,比如Word2Vec或GloVe,这些技术不仅考虑到词的位置,还考虑到词的上下文,能够为每个词生成一个更加丰富的向量表示。这样,即使“开除”和“开心”在词典中的位置接近,它们的向量表示也能够体现出它们在语义上的差异,从而帮助模型更好地理解中文文本。

onehot编码

在自然语言处理中,为了避免词汇之间的混淆,我们通常使用一种叫做one-hot编码的方法。这种编码方式将每个词表示为一个很长的向量,这个向量的维度等于词典中词汇的总数。在这个向量中,只有一个元素是1,其他所有元素都是0。这个1的位置就代表了特定的词。
比如,如果我们有一个包含1万个词的词典,那么“苹果”这个词可能会被编码为一个向量,其中第500个位置是1,其他位置都是0。同样,“香蕉”这个词可能会被编码为另一个向量,其中第800个位置是1,其他位置都是0。这样,每个词都有了自己独特的编码,不会有任何混淆。
使用one-hot编码后,每个词都是完全不一样的,因为每个词的向量都有一个唯一的1。这样,即使是“开除”和“开心”这样在词典中位置接近的词,在one-hot编码后也会是完全不同的向量。不过,one-hot编码并没有考虑到词之间的语义关系,所以通常我们还会使用词嵌入技术来进一步处理这些编码,以便更好地捕捉词的语义。

但问题是我们严格把每个词都变得完全不一样的同时,也丢失了词汇之间的关联性,比如猫和狗这两个词在数据上应该更加接近,苹果和西瓜更加接近,而猫和苹果的差距应该更大,但呆板的onehot编码却无法体现这一点,因为这种人为的随意且呆板的词表示方法完全不能体现语言中词语的特点,而特征提取不当的数据会让神经网络变得难以训练和泛化。

再者onehot的编码会让输入数据非常的大,比如我们使用一个有1万个词的词典,这样每个词的onehot的编码都是一个1万倍的向量,那么一个有4个词的句子,那么输入数据就是4万个元素。

词向量

在自然语言处理中,为了让计算机更好地理解词汇,人们提出了词向量的概念。词向量是一种将词汇表示为向量(即一组数字)的方法,这些数字代表了词汇的不同特征。就像我们用多个特征(如大小、颜色、硬度)来描述一个物体一样,词向量也是通过提取词汇的不同特征来形成的。
例如,“狗”这个词可以被认为有一些特征:它是一个名词(0.9的概率),它是动物(不是植物),它有皮毛,有尾巴等。这些特征可以被转换成数字,从而形成一个向量。这样,“狗”这个词就被表示为一个多维空间中的一个点,这个点在空间中的位置反映了“狗”这个词的特征。
词向量使得计算机能够更好地理解词汇的语义,因为向量之间的距离可以表示词汇之间的相似性。如果两个词向量在空间中的距离很近,那么它们在语义上可能很相似。这种方法比简单的one-hot编码更加强大,因为它能够捕捉到词汇的语义和上下文信息。
词向量通常是通过机器学习算法在大规模文本数据上训练得到的,这些算法能够自动从文本中学习词汇的特征。常见的词向量训练工具有Word2Vec、GloVe等。这些词向量在许多自然语言处理任务中都非常有用,比如文本分类、情感分析、机器翻译等。

那对于这些特征,苹果这个词的特征值分别是是名词,不是动物,是植物,当然也不一定就是植物,比如它也可以只带一个手机的品牌,所以这个值可能是0.8,8成是个植物,没有皮毛,没有尾巴等等词语的这种表示方法十分美妙不。不仅可以表示出不同的词,甚至具备了一定的推理能力。

为了做出方便,假设我们只提取词的两个特征,也就是说用一个二维的词向量表示一个词,那么我们把一个特征提取合适的词向量集合在二维空间上画出来,最后会是这样。

词义更加接近的词在向量空间中更加接近,反之词义无关的词距离很远,比如动物词汇聚集在了一起,植物词汇聚集在了一起,而动物中猫狗这种家养宠和野生的豺狼虎豹相比距离又更近一些,植物里水果和蔬菜又各自聚集,而动植物之间的距离和像手机电脑这样的非生物相比又要近一些。而有趣的是在一个特征提取适当的词向量集合中,如果我们用警察这个词的词向量去减去小偷这个词的词向量得到的结果向量,和猫这个词的词向量减去老鼠这个词词向量的结果向量非常的接近,这意味着警察和小偷的关系和猫和老鼠的关系十分相似。

词嵌入

在自然语言处理中,词嵌入是一种关键技术,它可以将词汇转换为特征向量。为了得到词向量,我们首先初始化一个词嵌入矩阵,这个矩阵的每一列代表一个词的向量。假设我们只有“猫”、“狗”、“苹果”和“西瓜”这四个词,并且想要每个词都有10个特征,那么我们就需要一个10×4的矩阵。
这个矩阵的初始值是随机设置的,因为我们还没有开始训练。接下来,我们使用one-hot编码来提取词向量。每个词的one-hot编码是一个向量,其中只有一个元素是1,其他都是0,这个1的位置对应于词在矩阵中的列。
当我们用词嵌入矩阵点乘一个词的one-hot编码时,由于one-hot编码中只有一个元素是1,所以结果就是词嵌入矩阵中的那一列。比如,“苹果”的one-hot编码可能是[0, 0, 1, 0],当我们用它点乘词嵌入矩阵时,得到的就是矩阵的第三列,这就是“苹果”的词向量。
通过这种方式,我们可以为每个词提取出一个唯一的词向量。在实际训练过程中,这个嵌入矩阵会通过反向传播不断更新,使得词向量能够更好地捕捉词汇的语义和上下文信息。这样,我们的模型就能更容易地训练和泛化。

当然我们也可以让词向量的维度更多,换句话说提取更加丰富的特征,比如300个,那么我们就把这个嵌入矩阵改成300行就好了,同时通常我们所处理的文本语料中所包含的词汇量肯定不会是4个这么少,比如有1万个词,那么就把嵌入矩阵的列改成1万就好,这时候每个词对应一个1万维度的onehot编码向量,同样可以提取出各自的300位词向量,有了这个嵌入矩阵,我们就可以把一句话中所有的词转化为词向量。

为了展示上的清晰,假设全部语料只有2句话,统计一下一共有5个词,首先我们用全部词汇构建1个词嵌入矩阵,然后给所有的词做onehot编码,那要把第一句话中的词都转化成词向量,首先我们需要把这句话中4个词的onehot编码向量合成1个矩阵,这样让嵌入矩阵点成这个onehot矩阵,就把这个句子变成了1个词向量矩阵,每一列都是句子中对应词的词向量。

如果后面接的是普通的全连接神经网络,那么就把这些词向量铺开作为输入,然后进行前项传播,最后得到输出的预测值。

而在反向传播的时候,因为句子词向量提取的运算形式是我们用嵌入矩阵点成onehot编码矩阵。你看这和一个普通的全连接层的线性运算部分一样,我们把它称之为嵌入层,就像普通的全连接层中的反向传播,把误差传递到权重矩阵并更新它一样,对于这个嵌入层误差通过反向传播可以继续传递到这个词嵌入矩阵并更新它,而如我们所说嵌入矩阵就是词汇表的词向量集合,所以我们的词向量就可以像卷积神经网络中的卷积和那样,在训练的时候不断学习,最后自己学习到合适的词向量表示。

在自然语言处理中,词嵌入矩阵的训练确实需要大量的文本数据,这样模型才能从中学习到词汇的丰富语义信息。但是,在实际应用中,我们可能并没有那么多的数据。这时,我们可以使用预训练的词向量。
预训练的词向量是使用大规模语料库训练得到的,这些词向量已经捕捉到了词汇的语义和上下文信息。我们可以直接使用这些预训练的词向量来初始化我们的模型,这样可以大大减少我们需要的训练数据量。
比如,我们想要构建一个情感分析的模型来判断评论区的评论是正面的还是负面的,但我们只有几百条或者几千条评论。这时,我们可以使用像Word2Vec、GloVe这样的工具来获取预训练的词向量,然后将这些词向量作为我们模型的一部分。
通过使用预训练的词向量,我们的模型即使在有限的训练数据下也能达到不错的效果,因为这些词向量已经包含了大量的语言知识。当然,如果我们有更多的数据,我们也可以在这些预训练的词向量基础上进行微调,以适应我们的具体任务。
总之,预训练的词向量是一个强大的工具,它可以帮助我们在自然语言处理任务中取得更好的效果,尤其是在训练数据有限的情况下。

在自然语言处理中,由于语言具有一定的普遍性,我们通常可以利用在大规模数据集上预训练的词向量,而不是从零开始训练。流行的词向量训练算法有Word2Vec和GloVe。我们可以下载这些算法预训练好的词向量,并将它们应用到我们的模型中。这样做的好处是,我们不需要大量的数据来训练词向量,而且这些预训练的词向量已经捕捉到了词汇的语义和上下文信息。
这种方法也是一种迁移学习的实践,它允许我们站在巨人的肩膀上,利用已有的知识来解决我们的问题。在图像识别中,我们也可以采用类似的方法,使用预训练的网络来提取特征,这比从少量数据集开始训练要高效得多。
对于自然语言处理,仅仅将句子转换成词向量并输入到全连接层是不够的,因为这样无法捕捉到语言在时间上的关联性。例如,“非常好看”和“非常不好看”这样的句子,虽然包含了一些相似的正向词汇,但由于“不”的存在,整个句子的意思发生了反转。因此,我们需要一种能够理解和处理这种时间上关联性的神经网络结构。
为了处理这种序列数据的关联性,我们通常使用循环神经网络(RNN)或者其变体,如长短期记忆网络(LSTM)和门控循环单元(GRU)。这些网络结构能够记住先前的输入,并在处理当前输入时考虑到这些信息,从而更好地理解和预测序列数据。在下节课中,我们将探讨如何将这些结构应用于自然语言处理,就像我们之前将卷积操作应用于图像数据一样。

编程实验

好的,同学们,我们开始进行本节课的编程实验。在过去的两节课中,我们学习了序列数据和序列模型,这些内容可能有些抽象。不过别担心,通过今天的编程实验,我们将逐步编写代码,以便更好地理解这些概念。
本次实验的任务是进行文本情感分类。我们有一个CSV文件,其中包含了超过10万条网购评论数据。每条数据有三部分:商品分类、情感标签和评论文本。商品分类在这个任务中用不上,我们主要关注的是情感标签和评论文本。情感标签为1表示正面评价,为0表示负面评价。我们的目标是根据评论文本预测其情感标签。
在实验中,我们将按照以下步骤进行操作:

  1. 加载数据并进行预处理,例如分词、去停用词等。
  2. 将文本数据转换为词向量。
  3. 使用词向量作为输入,构建一个神经网络模型进行训练。
  4. 评估模型的性能,看看我们的模型能否准确地预测评论的情感。

好的,同学们,我们现在要做的实验是训练一个神经网络来识别网购评论是正面的还是负面的。为了方便大家编码,我已经在工程目录下准备了一个名为shopping_data.py的辅助工具,它可以帮助我们读取和处理这些数据。
我们现在就开始使用这个工具来读取数据,并查看数据的形状。训练集有13,276个数据,测试集有3,319个数据。你可能会注意到,这个数据量比我们之前说的10万多条数据要少。这是因为我在shopping_data.py中过滤掉了那些特别长的评论,只保留了评价字数不超过20的评论。这样做的目的是为了让我们的模型更容易训练,同时也能让训练过程更快一些。
接下来,我们就开始编写代码,使用这些数据来训练我们的模型。如果在编码过程中遇到任何问题,随时可以提问,我们一起解决。让我们开始吧!

import utils as shopping_data
from keras_preprocessing import sequence
from keras.models import Sequential
from keras.layers import Dense, Embedding, LSTM, Flatten


# 处理爬取的数据,6w条数据,情感标签+文本数据 #
X_train, y_train, X_test, y_test = shopping_data.load_data(
    input_file='./input/online_shopping_10_cats.csv')
print(f'X_train.shape:,{X_train.shape}')
print(f'y_train.shape:,{y_train.shape}')
print(f'X_test.shape:,{X_test.shape}')
print(f'y_test.shape:,{y_test.shape}')
print(X_train[0])
print(y_train[0])

好的,那接下来呢我们就要把评论文本转化为词向量,然后送入到神经网络中进行训练,那按照我们课上说的,我们需要统计一下全体文本中的词汇,那构建一个词嵌入矩阵,然后给每个词呢做onehot的编码,这样让词嵌入矩阵点乘一句话的onehot编码矩阵,就能把这句话转化成一个词向量矩阵,那不过这个过程呢其实并不需要我们手动操作,keras有个叫做embedding的专门来做这件事情。

所以第一步把数据集中所有的文本转化为词典的索引值,这件事情呢需要我们自己来做,那我们需要一个词典,不过不是真的去找一本词典,那词典的构造过程呢一般是这样,我们用程序去遍历语料中的句子,那如果是中文呢就进行分词,在这个过程中统计全体语料上所有的词语,比如是5000个,那么就可以把这5000个词放在Python的一个数组或者字典的结构中,而你可以通过读音呢对他们进行排序,当然你可以不排序就使用随机的顺序,反正不论怎样,每个词就会在数组或者字典里有一个位置的索引。

# 把数据集所有文本进行分词,获得索引词典 #
vocalen, word_index = shopping_data.createWordIndex(X_train, X_test)
""" 
shopping_data.createWordIndex为自行封装的函数。
函数模块内容就是对数据集中所有文本去除标点符号后,使用jieba分词,获得所有词列表,按出现次数倒序排序,然后使用keras分词器Tokenizer来根据顺序从1开始创建词索引。
"""

然后我们就可以利用这个词典把数据集中文本句子都转化为索引数据。

# 获得训练和测试数据的索引表示 #
X_train_index = shopping_data.word2Index(X_train, word_index)
X_test_index = shopping_data.word2Index(X_test, word_index)

好的,接下来呢还有1个问题需要处理一下,因为我们的评论数据每句话的长短都不一样,有的句子是5个词,有的句子是6个词或者7个词8个词等等,那这样呢最后得到的每句话的索引向量的长短就不一样,那我们在全部训练集上就无法形成一个整齐的张量,所以呢我们需要让这些句子做一个对齐的操作。

from keras_preprocessing import sequence
# 把序列按照maxlen进行对齐 #
maxlen = 25
X_train_pad = sequence.pad_sequences(X_train_index, maxlen=maxlen)
X_test_pad = sequence.pad_sequences(X_test_index, maxlen=maxlen)
"""
[ 58., 299.,  92., 400.,  37.,  39., 157., 192., 367.,  95.]
→
[  0,   0,   0,   0,   0,   0,   0,   0,   0,   0,   0,   0,   0, 0,   0,  58, 299,  92, 400,  37,  39, 157, 192, 367,  95]
"""

好的,这样呢我们就完成了文本数据的预处理,那接下来呢我们就把数据送入到神经网络之中。Embedding层中的参数矩阵呢就是全体词汇的词向量集合,而当我们给它配置为False不训练的时候,这些词向量不会随着训练而更新,而当我们把它配置为trainable=True的时候,词嵌入矩阵呢就会随着训练一起更新,那按照我们课上说的,显然随着训练一起更新的词嵌入矩阵的效果呢要好一些,我们这里呢先配置为不训练,那一会我们再打开做一个对比。

# 模型构建 #
model = Sequential()
"""Embedding层
对测试集和训练集的词语创建特征权值矩阵,对句子索引创建onehot编码矩阵,两个矩阵点乘就是这个句子的特征矩阵。这一步可以直接使用keras的Embedding层实现
- input_dim 设置输入维度,即输入的最大词数;
- output_dim 设置输出维度,即特征维度;
"""
model.add(Embedding(trainable=False, input_dim=vocalen,
          output_dim=300, input_length=maxlen))

model.add(Flatten())
model.add(Dense(256, activation='relu'))
model.add(Dense(256, activation='relu'))
model.add(Dense(256, activation='relu'))
model.add(Dense(1, activation='sigmoid'))

compile这个模型,这里的损失函数和优化器呢和之前有点不一样,损失函数我们使用的是binary_crossentropy,其实看名字呢就知道这是一个适用于二分类问题的交叉熵代价函数,然后呢优化器我们使用的是adm而不是sgd。因为序列问题呢一般都比较难训练,所以我们使用这个更快的优化器。那具体的细节呢我们就不展开了,你只需要知道啊Adam是一种使用动量的自适应优化器,那比普通的sgd优化器要更快,效果要更好一点。

model.compile(loss='binary_crossentropy',
              optimizer='adam', metrics=['accuracy'])

那再下面的代码呢就是开始训练,我们使用200个回合,p尺寸呢设置为512,那最后呢在测试集上做一个评估,好的,这就是全部的代码,那我们运行一下。

# 模型训练 #
model.fit(X_train_pad, y_train, batch_size=512, epochs=200)

# 保存模型
model.save('./model/12_model.h5')
loss, accuracy = model.evaluate(X_test_pad, y_test)
print(f'loss:{loss:.4f},accuracy:{accuracy:.4f}')

最后在测试机场准确率呢是79%,那不是很高,那我们刚才呢把嵌入层的trainable配置成了False,不训练词嵌入矩阵,那我们现在把它打开再试试看效果。

model.add(Embedding(trainable=True, input_dim=vocalen,
          output_dim=300, input_length=maxlen))

Ok运行一下。你看现在在测试题上准确率呢是85.6%,有了明显的提高。

最后我们在课上说,如果我们从网上下载别人在海量数据上训练好的词向量,而不是用自己这些少量的数据训练效果会更好,那我们就在下节课说完循环神经网络结构之后,一起来试试,并把它们在数据集上的表现呢做一个详细的对比,我们下节课见。