外观
Fasttext
介绍
Fasttext 是 NLP 领域常用的工具包,可用于进行文本分类和训练词向量。
Fasttext 可以在保持高精度的情况下快速训练和预测,原因:
- Fasttext 模型具有十分简单的网络结构。
- 使用 Fasttext 训练词向量时使用 层次 softmax 结构,来提升超多类别下的模型性能。
- Fasttext 模型过于简单无法捕捉词序特征,所以会进行 n-gram 特征提取以弥补模型缺陷,提升精度。
安装:
pip install fasttext模型架构
FastText 模型架构和 Word2Vec 中的 CBOW 模型很类似,不同在于,Fasttext 预测标签,而 CBOW 模型预测中间词。
Fasttext 的模型分为三层架构:
- 输入层: 是对文档embedding之后的向量, 包含N-gram特征
- 隐藏层: 是对输入数据的求和平均
- 输出层: 是文档对应的label

层次 softmax 结构
为了提高效率,在 fastText 中计算分类标签概率的时候,不再使用传统的 softmax 来进行多分类的计算,而是使用哈夫曼树,使用层次化的 softmax 来进行概率的计算。
假设有n个权值, 则构造出的哈夫曼树有n个叶子节点. n个权值分别设为 w1、w2、…、wn, 则哈夫曼树的构造规则为:
- 步骤1: 将w1、w2、…, wn看成是有n 棵树的森林(每棵树仅有一个节点);
- 步骤2: 在森林中选出两个根节点的权值最小的树合并, 作为一颗新树的左、右子树, 且新树的根节点权值为其左、右子树根节点权值之和;
- 步骤3: 从森林中删除选取的两棵树, 并将新树加入森林;
- 步骤4: 重复2-3步骤, 直到森林只有一颗树为止, 该树就是所求的哈夫曼树.
举例说明, 构建huffman树:
- 假设有四个Label分别为: A~D, 统计其在语料库出现的频数:

- 第一次合并建树:

- 第二次合并建树:

- 第三次合并建树:

- 由上图可以看出权重越大, 距离根节点越近.
- 叶子的个数为n, 构造哈夫曼树中新增的节点的个数为n-1.
哈夫曼树编码:
- 哈夫曼编码一般规定哈夫曼树中的左分支为 0, 右分支为 1, 从根节点到每个叶节点所经过的分支对应的 0 和 1 组成的序列便为该节点对应字符的编码. 这样的编码称为哈夫曼编码.
- 上图例子中对应的编码如下:

转化为梯度计算:

上图中, 红色为哈夫曼编码, 即D的哈夫曼编码为110, 那么此时如何定义条件概率P(D|context)?
以D为例, 从根节点到D中间经历了3次分支, 每次分支都可以认为是进行了一次2分类, 根据哈夫曼编码, 可以把数字0对应分支认为是负类, 数字1对应的分支认为是正类.
在机器学习课程中逻辑回归中使用sigmoid函数进行2分类的过程中: 一个节点被分为正类的概率是: σ(XTθ)=1/(1+e−xTθ), 一个节点被分为负类的概率是: 1−σ(XTθ), 其中θ 就是图中非叶子节点对应的参数.
对于从根节点出发, 到达D一共经历三次分支, 将每次分类结果的概率罗列出来:
- 第一次: P(1|X,θ1)=σ(XTθ1), 即从根节点到24节点的概率是在知道x和θ1的情况下取值为1的概率
- 第二次: P(1|X,θ2)=σ(XTθ2)
- 第三次: P(0|X,θ3)=1−σ(XTθ3)
但是我们需要求的是P(D|context), 它等于前3词的概率乘积, 公式如下(dwj是第j个节点的哈夫曼编码)

在机器学习中的逻辑回归中, 我们会经常把二分类的损失函数定义为对数似然损失, 即

式子中, 求和符号表示的是使用样本的过程中, 每个label对应的概率取对数后的和, 之后求取均值.
带入前面P(Label|Context)的定义得到损失函数:

有了损失函数之后, 接下来就是对其中的X,θ进行求导, 并更新.
层次softmax的优势:
传统的softmax的时间复杂度为L(labels的数量), 但是使用层次化softmax之后时间复杂度的log(L) (二叉树的高度和宽度的近似), 从而在多分类的场景提高了效率.
负采样
负采样原理
当我们训练一个神经网络意味着要输入训练样本并且不断调整神经元的权重, 从而不断提高对目标的准确预测. 每当神经网络经过一个训练样本的训练, 它的权重就会进行一次调整. 比如我们利用Skip-Gram进行词向量的训练, 如果词汇量的数量为上万个, 那么我们利用softmax计算概率时, 需要对计算上万个概率值, 且每个值都需要进行反向传播更新模型参数, 这是非常消耗计算资源的, 并且实际中训练起来会非常慢.
不同于原本每个训练样本更新所有的权重, 负采样每次让一个训练样本仅仅更新一小部分的权重, 这样就会降低梯度下降过程中的计算量.
举例说明(负采样原理):
当我们用训练样本 ( input word: "hello", output word: "man") 来训练我们的神经网络时, “ hello”和“man”都是经过one-hot编码的. 如果我们的vocabulary大小为10000时, 在输出层, 我们期望对应“man”单词的那个神经元结点输出1, 其余9999个都应该输出0. 在这里, 这9999个我们期望输出为0的神经元结点所对应的单词我们称为“negative” word.
当使用负采样时, 我们将随机选择一小部分的negative words(比如选5个negative words)来更新对应的权重. 我们也会对我们的“positive” word进行权重更新(在我们上面的例子中, 这个单词指的是”man“).
注意, 对于小规模数据集, 选择5-20个negative words会比较好, 对于大规模数据集可以仅选择2-5个negative words.
假如我们的隐层-输出层拥有300 x 10000的权重矩阵. 如果使用了负采样的方法我们仅仅去更新我们的positive word-“man”的和我们选择的其他5个negative words的结点对应的权重, 共计6个输出神经元, 相当于每次只更新300×6=1800个权重. 对于3百万的权重来说, 相当于只计算了0.06%的权重, 这样计算效率就大幅度提高.
负采样的优势
提高训练速度, 选择了部分数据进行计算损失, 损失计算更加简单.
改进效果, 增加部分负样本, 能够模拟真实场景下的噪声情况, 能够让模型的稳健性更强.
文本分类
文本分类是将文档分配给一个或多个类别。
文本分类有二分类、单标签多分类和多标签多分类。
获取数据
使用 head cooking.stackexchange.txt 观看前十条数据。
__label__sauce __label__cheese How much does potato starch affect a cheese sauce recipe?
__label__food-safety __label__acidity Dangerous pathogens capable of growing in acidic environments
__label__cast-iron __label__stove How do I cover up the white spots on my cast iron stove?
__label__restaurant Michelin Three Star Restaurant; but if the chef is not there
__label__knife-skills __label__dicing Without knife skills, how can I quickly and accurately dice vegetables?
__label__storage-method __label__equipment __label__bread What's the purpose of a bread box?
__label__baking __label__food-safety __label__substitutions __label__peanuts how to seperate peanut oil from roasted peanuts at home?
__label__chocolate American equivalent for British chocolate terms
__label__baking __label__oven __label__convection Fan bake vs bake
__label__sauce __label__storage-lifetime __label__acidity __label__mayonnaise Regulation and balancing of readymade packed mayonnaise and other sauces每一行都代表一个标签列表,后跟相应的文档,标签的语法类似 __label__sauce __label__cheese 的形式展现,代表两个标签。
分割训练集和测试集
# 查看数据总数
$ wc cooking.stackexchange.txt
15404 169582 1401900 cooking.stackexchange.txt
# 多少行 多少单词 占用字节数(多大)
# 12404条数据作为训练数据
$ head -n 12404 cooking.stackexchange.txt > cooking.train
# 3000条数据作为验证数据
$ tail -n 3000 cooking.stackexchange.txt > cooking.valid训练模型
import fasttext
model = fasttext.train_supervised(input="data/cooking/cooking.train")结果输出:
# 获得结果
Read 0M words
# 不重复的词汇总数
Number of words: 14543
# 标签总数
Number of labels: 735
# Progress: 训练进度, 因为我们这里显示的是最后的训练完成信息, 所以进度是100%
# words/sec/thread: 每个线程每秒处理的平均词汇数
# lr: 当前的学习率, 因为训练完成所以学习率是0
# avg.loss: 训练过程的平均损失
# ETA: 预计剩余训练时间, 因为已训练完成所以是0
Progress: 100.0% words/sec/thread: 60162 lr: 0.000000 avg.loss: 10.056812 ETA: 0h 0m 0s测试:
# 使用模型预测一段输入文本, 通过我们常识, 可知预测是正确的, 但是对应预测概率并不大
>>> model.predict("Which baking dish is best to bake a banana bread ?")
# 元组中的第一项代表标签, 第二项代表对应的概率
(('__label__baking',), array([0.06550845]))
# 为了评估模型到底表现如何, 我们在3000条的验证集上进行测试
>>> model.test("data/cooking/cooking.valid")
# 元组中的每项分别代表, 验证集样本数量, 精度以及召回率
# 我们看到模型精度和召回率表现都很差, 接下来我们讲学习如何进行优化.
(3000, 0.124, 0.0541)模型调优
清洗数据
清洗前的:
# 通过查看数据, 我们发现数据中存在许多标点符号与单词相连以及大小写不统一,
# 这些因素对我们最终的分类目标没有益处, 反是增加了模型提取分类规律的难度,
# 因此我们选择将它们去除或转化
# 处理前的部分数据
__label__fish Arctic char available in North-America
__label__pasta __label__salt __label__boiling When cooking pasta in salted water how much of the salt is absorbed?
__label__coffee Emergency Coffee via Chocolate Covered Coffee Beans?
__label__cake Non-beet alternatives to standard red food dye
__label__cheese __label__lentils Could cheese "halt" the tenderness of cooking lentils?
__label__asian-cuisine __label__chili-peppers __label__kimchi __label__korean-cuisine What kind of peppers are used in Gochugaru ()?
__label__consistency Pavlova Roll failure
__label__eggs __label__bread What qualities should I be looking for when making the best French Toast?
__label__meat __label__flour __label__stews __label__braising Coating meat in flour before browning, bad idea?
__label__food-safety Raw roast beef on the edge of safe?
__label__pork __label__food-identification How do I determine the cut of a pork steak prior to purchasing it?清洗:
# 通过服务器终端进行简单的数据预处理
# 使标点符号与单词分离并统一使用小写字母
>> cat cooking.stackexchange.txt | sed -e "s/\([.\!?,'/()]\)/ \1 /g" | tr "[:upper:]" "[:lower:]" > cooking.preprocessed.txt
>> head -n 12404 cooking.preprocessed.txt > cooking.pre.train
>> tail -n 3000 cooking.preprocessed.txt > cooking.pre.valid# 处理后的部分数据
__label__fish arctic char available in north-america
__label__pasta __label__salt __label__boiling when cooking pasta in salted water how much of the salt is absorbed ?
__label__coffee emergency coffee via chocolate covered coffee beans ?
__label__cake non-beet alternatives to standard red food dye
__label__cheese __label__lentils could cheese "halt" the tenderness of cooking lentils ?
__label__asian-cuisine __label__chili-peppers __label__kimchi __label__korean-cuisine what kind of peppers are used in gochugaru ( ) ?
__label__consistency pavlova roll failure
__label__eggs __label__bread what qualities should i be looking for when making the best french toast ?
__label__meat __label__flour __label__stews __label__braising coating meat in flour before browning , bad idea ?
__label__food-safety raw roast beef on the edge of safe ?
__label__pork __label__food-identification how do i determine the cut of a pork steak prior to purchasing it ?数据处理后进行训练并测试
# 重新训练
>>> model = fasttext.train_supervised(input="data/cooking/cooking.pre.train")
Read 0M words
# 不重复的词汇总数减少很多, 因为之前会把带大写字母或者与标点符号相连接的单词都认为是新的单词
Number of words: 8952
Number of labels: 735
# 我们看到平均损失有所下降
Progress: 100.0% words/sec/thread: 65737 lr: 0.000000 avg.loss: 9.966091 ETA: 0h 0m 0s
# 重新测试
>>> model.test("data/cooking/cooking.pre.valid")
# 我们看到精度和召回率都有所提升
(3000, 0.161, 0.06962663975782038)增加训练轮数
# 设置train_supervised方法中的参数epoch来增加训练轮数, 默认的轮数是5次
# 增加轮数意味着模型能够有更多机会在有限数据中调整分类规律, 当然这也会增加训练时间
>>> model = fasttext.train_supervised(input="data/cooking/cooking.pre.train", epoch=25)
Read 0M words
Number of words: 8952
Number of labels: 735
# 我们看到平均损失继续下降
Progress: 100.0% words/sec/thread: 66283 lr: 0.000000 avg.loss: 7.203885 ETA: 0h 0m 0s
>>> model.test("data/cooking/cooking.pre.valid")
# 我们看到精度已经提升到了42%, 召回率提升至18%.
(3000, 0.4206666666666667, 0.1819230214790255)调整学习率
# 设置train_supervised方法中的参数lr来调整学习率, 默认的学习率大小是0.1
# 增大学习率意味着增大了梯度下降的步长使其在有限的迭代步骤下更接近最优点
>>> model = fasttext.train_supervised(input="data/cooking/cooking.pre.train", lr=1.0, epoch=25)
Read 0M words
Number of words: 8952
Number of labels: 735
# 平均损失继续下降
Progress: 100.0% words/sec/thread: 66027 lr: 0.000000 avg.loss: 4.278283 ETA: 0h 0m 0s
>>> model.test("data/cooking/cooking.pre.valid")
# 我们看到精度已经提升到了47%, 召回率提升至20%.
(3000, 0.47633333333333333, 0.20599682860025947)增加n-gram特征
# 设置train_supervised方法中的参数wordNgrams来添加n-gram特征, 默认是1, 也就是没有n-gram特征
# 我们这里将其设置为2意味着添加2-gram特征, 这些特征帮助模型捕捉前后词汇之间的关联, 更好的提取分类规则用于模型分类, 当然这也会增加模型训时练占用的资源和时间.
>>> model = fasttext.train_supervised(input="data/cooking/cooking.pre.train", lr=1.0, epoch=25, wordNgrams=2)
Read 0M words
Number of words: 8952
Number of labels: 735
# 平均损失继续下降
Progress: 100.0% words/sec/thread: 65084 lr: 0.000000 avg.loss: 3.189422 ETA: 0h 0m 0s
>>> model.test("data/cooking/cooking.pre.valid")
# 我们看到精度已经提升到了49%, 召回率提升至21%.
(3000, 0.49233333333333335, 0.2129162462159435)修改损失计算方式
# 随着我们不断的添加优化策略, 模型训练速度也越来越慢
# 为了能够提升fasttext模型的训练效率, 减小训练时间
# 设置train_supervised方法中的参数loss来修改损失计算方式(等效于输出层的结构), 默认是softmax层结构
# 我们这里将其设置为'hs', 代表层次softmax结构, 意味着输出层的结构(计算方式)发生了变化, 将以一种更低复杂度的方式来计算损失.
>>> model = fasttext.train_supervised(input="data/cooking/cooking.pre.train", lr=1.0, epoch=25, wordNgrams=2, loss='hs')
Read 0M words
Number of words: 8952
Number of labels: 735
Progress: 100.0% words/sec/thread: 1341740 lr: 0.000000 avg.loss: 2.225962 ETA: 0h 0m 0s
>>> model.test("data/cooking/cooking.pre.valid")
# 我们看到精度和召回率稍有波动, 但训练时间却缩短到仅仅几秒
(3000, 0.483, 0.20887991927346114)自动超参数调优
# 手动调节和寻找超参数是非常困难的, 因为参数之间可能相关, 并且不同数据集需要的超参数也不同,
# 因此可以使用fasttext的autotuneValidationFile参数进行自动超参数调优.
# autotuneValidationFile参数需要指定验证数据集所在路径, 它将在验证集上使用随机搜索方法寻找可能最优的超参数.
# 使用autotuneDuration参数可以控制随机搜索的时间, 默认是300s, 根据不同的需求, 我们可以延长或缩短时间.
# 验证集路径'cooking.valid', 随机搜索600秒
>>> model = fasttext.train_supervised(input='data/cooking/cooking.pre.train', autotuneValidationFile='data/cooking/cooking.pre.valid', autotuneDuration=600)
Progress: 100.0% Trials: 38 Best score: 0.376170 ETA: 0h 0m 0s
Training again with best arguments
Read 0M words
Number of words: 8952
Number of labels: 735
Progress: 100.0% words/sec/thread: 63791 lr: 0.000000 avg.loss: 1.888165 ETA: 0h 0m 0s实际生产中多标签多分类问题的损失计算方式
# 针对多标签多分类问题, 使用'softmax'或者'hs'有时并不是最佳选择, 因为我们最终得到的应该是多个标签, 而softmax却只能最大化一个标签.
# 所以我们往往会选择为每个标签使用独立的二分类器作为输出层结构,
# 对应的损失计算方式为'ova'表示one vs all.
# 这种输出层的改变意味着我们在统一语料下同时训练多个二分类模型,
# 对于二分类模型来讲, lr不宜过大, 这里我们设置为0.2
>>> model = fasttext.train_supervised(input="data/cooking/cooking.pre.train", lr=0.2, epoch=25, wordNgrams=2, loss='ova')
Read 0M words
Number of words: 8952
Number of labels: 735
Progress: 100.0% words/sec/thread: 65044 lr: 0.000000 avg.loss: 7.713312 ETA: 0h 0m 0s
# 我们使用模型进行单条样本的预测, 来看一下它的输出结果.
# 参数k代表指定模型输出多少个标签, 默认为1, 这里设置为-1, 意味着尽可能多的输出.
# 参数threshold代表显示的标签概率阈值, 设置为0.5, 意味着显示概率大于0.5的标签
>>> model.predict("Which baking dish is best to bake a banana bread ?", k=-1, threshold=0.5)
# 我看到根据输入文本, 输出了它的三个最有可能的标签
((u'__label__baking', u'__label__bananas', u'__label__bread'), array([1.00000, 0.939923, 0.592677]))模型保存与重加载
# 使用model的save_model方法保存模型到指定目录
# 你可以在指定目录下找到model_cooking.bin文件
>>> model.save_model("data/model/model_cooking.bin")
# 使用fasttext的load_model进行模型的重加载
>>> model = fasttext.load_model("data/model/model_cooking.bin")
# 重加载后的模型使用方法和之前完全相同
>>> model.predict("Which baking dish is best to bake a banana bread ?", k=-1, threshold=0.5)
((u'__label__baking', u'__label__bananas', u'__label__bread'), array([1.00000, 0.939923, 0.592677]))