fastText 是 facebook 于2016年开源的一个词向量计算以及文本分类工具,该工具的理论基础是以下两篇论文:
Enriching Word Vectors with Subword Information
这篇论文提出了用 word n-gram 的向量之和来代替简单的词向量的方法,以解决简单 word2vec 无法处理同一词的不同形态的问题。fastText 中提供了 maxn 这个参数来确定 word n-gram 的 n 的大小。
Bag of Tricks for Efficient Text Classification
这篇论文提出了 fastText 算法,该算法实际上是将目前用来算 word2vec 的网络架构做了个小修改,原先使用一个词的上下文的所有词向量之和来预测词本身(CBOW 模型),现在改为用一段短文本的词向量之和来对文本进行分类。
在作者看来,fastText 的价值是提供了一个 更具可读性,模块化程度较好 的 word2vec 的实现,附带一些新的分类功能,本文详细分析它的源码。
fastText 的代码结构以及各模块的功能如下图所示:
分析各模块时,我只会解释该模块的 主要调用路径 下的源码,以 注释 的方式说明,其它的功能性代码请大家自行阅读。如果对 word2vec 的理论和相关术语不了解,请先阅读这篇 word2vec 中的数学原理详解。
训练数据格式为一行一个句子,每个词用空格分割,如果一个词带有前缀“__label__
”,那么它就作为一个类标签,在文本分类时使用,这个前缀可以通过-label
参数自定义。训练文件支持 UTF-8 格式。
fasttext 是最顶层的模块,它的主要功能是训练
和预测
,首先是训练
功能的调用路径,第一个函数是 train
,它的主要作用是 初始化参数,启动多线程训练,请大家留意源码中的相关部分。
【注:以下所有代码,可以左右滑动查看】
void FastText::train(std::shared_ptr<Args> args) {
args_ = args;
dict_ = std::make_shared<Dictionary>(args_);
std::ifstream ifs(args_->input); if (!ifs.is_open()) {
std::cerr << "Input file cannot be opened!" << std::endl; exit(EXIT_FAILURE);
}
dict_->readFromFile(ifs);
ifs.close();
input_ = std::make_shared<Matrix>(dict_->nwords()+args_->bucket, args_->dim);
if (args_->model == model_name::sup) {
output_ = std::make_shared<Matrix>(dict_->nlabels(), args_->dim);
} else {
output_ = std::make_shared<Matrix>(dict_->nwords(), args_->dim);
}
input_->uniform(1.0 / args_->dim);
output_->zero();
start = clock();
tokenCount = 0;
std::vector<std::thread> threads; for (int32_t i = 0; i < args_->thread; i++) {
threads.push_back(std::thread([=]() { trainThread(i); }));
} for (auto it = threads.begin(); it != threads.end(); ++it) {
it->join();
}
model_ = std::make_shared<Model>(input_, output_, args_, 0);
saveModel(); if (args_->model != model_name::sup) {
saveVectors();
}
}
下面,我们进入 trainThread
函数,看看训练的主体逻辑,该函数的主要工作是 实现了标准的随机梯度下降,并随着训练的进行逐步降低学习率。
void FastText::trainThread(int32_t threadId) {
std::ifstream ifs(args_->input);
utils::seek(ifs, threadId * utils::size(ifs) / args_->thread); Model model(input_, output_, args_, threadId); if (args_->model == model_name::sup) {
model.setTargetCounts(dict_->getCounts(entry_type::label));
} else {
model.setTargetCounts(dict_->getCounts(entry_type::word));
}
const int64_t ntokens = dict_->ntokens();
int64_t localTokenCount = 0;
std::vector<int32_t> line, labels;
while (tokenCount < args_->epoch * ntokens) {
real progress = real(tokenCount) / (args_->epoch * ntokens);
real lr = args_->lr * (1.0 - progress);
localTokenCount += dict_->getLine(ifs, line, labels, model.rng);
if (args_->model == model_name::sup) {
dict_->addNgrams(line, args_->wordNgrams);
supervised(model, lr, line, labels);
} else if (args_->model == model_name::cbow) {
cbow(model, lr, line);
} else if (args_->model == model_name::sg) {
skipgram(model, lr, line);
}
if (localTokenCount > args_->lrUpdateRate) {
tokenCount += localTokenCount;
localTokenCount = 0;
if (threadId == 0) {
printInfo(progress, model.getLoss());
}
}
} if (threadId == 0) {
printInfo(1.0, model.getLoss());
std::cout << std::endl;
}
ifs.close();
}
一哄而上的并行训练:每个训练线程在更新参数时并没有加锁,这会给参数更新带来一些噪音,但是不会影响最终的结果。无论是 google 的 word2vec 实现,还是 fastText 库,都没有加锁。
从 trainThread
函数中我们发现,实际的模型更新策略发生在 supervised
,cbow
,skipgram
三个函数中,这三个函数都调用同一个 model.update
函数来更新参数,这个函数属于 model 模块,但在这里我先简单介绍它,以方便大家理解代码。
update 函数的原型为
void Model::update(const std::vector<int32_t>& input, int32_t target, real lr)
该函数有三个参数,分别是“输入”,“类标签”,“学习率”。
输入是一个 int32_t
数组,每个元素代表一个词在 dictionary 里的 ID。对于分类问题,这个数组代表输入的短文本,对于 word2vec,这个数组代表一个词的上下文。
类标签是一个 int32_t
变量。对于 word2vec 来说,它就是带预测的词的 ID,对于分类问题,它就是类的 label 在 dictionary 里的 ID。因为 label 和词在词表里一起存放,所以有统一的 ID 体系。
下面,我们回到 fasttext 模块的三个更新函数:
void FastText::supervised(Model& model, real lr, const std::vector<int32_t>& line, const std::vector<int32_t>& labels) { if (labels.size() == 0 || line.size() == 0) return;
std::uniform_int_distribution<> uniform(0, labels.size() - 1); int32_t i = uniform(model.rng);
model.update(line, labels[i], lr);
}void FastText::cbow(Model& model, real lr, const std::vector<int32_t>& line) {
std::vector<int32_t> bow;
std::uniform_int_distribution<> uniform(1, args_->ws);
for (int32_t w = 0; w < line.size(); w++) {
int32_t boundary = uniform(model.rng);
bow.clear();
for (int32_t c = -boundary; c <= boundary; c++) {
if (c != 0 && w + c >= 0 && w + c < line.size()) {
const std::vector<int32_t>& ngrams = dict_->getNgrams(line[w + c]);
bow.insert(bow.end(), ngrams.cbegin(), ngrams.cend());
}
}
model.update(bow, line[w], lr);
}
}void FastText::skipgram(Model& model, real lr, const std::vector<int32_t>& line) {
std::uniform_int_distribution<> uniform(1, args_->ws); for (int32_t w = 0; w < line.size(); w++) {
int32_t boundary = uniform(model.rng);
const std::vector<int32_t>& ngrams = dict_->getNgrams(line[w]);
for (int32_t c = -boundary; c <= boundary; c++) { if (c != 0 && w + c >= 0 && w + c < line.size()) {
model.update(ngrams, line[w + c], lr);
}
}
}
}
训练部分的代码已经分析完毕,预测部分的代码就简单多了,它的主要逻辑都在 model.predict
函数里。
void FastText::predict(const std::string& filename, int32_t k, bool print_prob) {
std::vector<int32_t> line, labels;
std::ifstream ifs(filename); if (!ifs.is_open()) {
std::cerr << "Test file cannot be opened!" << std::endl; exit(EXIT_FAILURE);
} while (ifs.peek() != EOF) {
dict_->getLine(ifs, line, labels, model_->rng);
dict_->addNgrams(line, args_->wordNgrams); if (line.empty()) {
std::cout << "n/a" << std::endl; continue;
}
std::vector<std::pair<real, int32_t>> predictions;
model_->predict(line, k, predictions);
for (auto it = predictions.cbegin(); it != predictions.cend(); it++) { if (it != predictions.cbegin()) {
std::cout << ' ';
}
std::cout << dict_->getLabel(it->second); if (print_prob) {
std::cout << ' ' << exp(it->first);
}
}
std::cout << std::endl;
}
ifs.close();
}
通过对 fasttext 模块的分析,我们发现它最核心的预测和更新逻辑都在 model 模块中,接下来,我们进入 model 模块一探究竟。
model 模块对外提供的服务可以分为 update
和 predict
两类,下面我们分别对它们进行分析。由于这里的参数较多,我们先以图示标明各个参数在模型中所处的位置,以免各位混淆。
图中所有变量的名字全部与 model 模块中的名字保持一致,注意到 wo_
矩阵在不同的输出层结构中扮演着不同的角色。
update
update
函数的作用已经在前面介绍过,下面我们看一下它的实现:
void Model::update(const std::vector<int32_t>& input, int32_t target, real lr) {
assert(target >= 0);
assert(target < osz_); if (input.size() == 0) return;
hidden_.zero(); for (auto it = input.cbegin(); it != input.cend(); ++it) {
hidden_.addRow(*wi_, *it);
}
hidden_.mul(1.0 / input.size());
if (args_->loss == loss_name::ns) {
loss_ += negativeSampling(target, lr);
} else if (args_->loss == loss_name::hs) {
loss_ += hierarchicalSoftmax(target, lr);
} else {
loss_ += softmax(target, lr);
}
nexamples_ += 1;
if (args_->model == model_name::sup) {
grad_.mul(1.0 / input.size());
}
for (auto it = input.cbegin(); it != input.cend(); ++it) {
wi_->addRow(grad_, *it, 1.0);
}
}
下面我们看看三种输出层对应的更新函数:
negativeSampling
,hierarchicalSoftmax
,softmax
。
model 模块中最有意思的部分就是将层次 softmax 和负采样统一抽象成多个二元 logistic regression 计算。
如果使用负采样,训练时每次选择一个正样本,随机采样几个负样本,每种输出都对应一个参数向量,保存于 wo_
的各行。对所有样本的参数更新,都是一次独立的 LR 参数更新。
如果使用层次 softmax,对于每个目标词,都可以在构建好的霍夫曼树上确定一条从根节点到叶节点的路径,路径上的每个非叶节点都是一个 LR,参数保存在 wo_
的各行上,训练时,这条路径上的 LR 各自独立进行参数更新。
无论是负采样还是层次 softmax,在神经网络的计算图中,所有 LR 都会依赖于 hidden_
的值,所以 hidden_
的梯度 grad_
是各个 LR 的反向传播的梯度的累加。
LR 的代码如下:
real Model::binaryLogistic(int32_t target, bool label, real lr) {
real score = utils::sigmoid(wo_->dotRow(hidden_, target));
real alpha = lr * (real(label) - score);
grad_.addRow(*wo_, target, alpha);
wo_->addRow(hidden_, target, alpha);
if (label) { return -utils::log(score);
} else { return -utils::log(1.0 - score);
}
}
经过以上的分析,下面三种逻辑就比较容易理解了:
real Model::negativeSampling(int32_t target, real lr) {
real loss = 0.0;
grad_.zero(); for (int32_t n = 0; n <= args_->neg; n++) {
if (n == 0) {
loss += binaryLogistic(target, true, lr);
} else {
loss += binaryLogistic(getNegative(target), false, lr);
}
} return loss;
}
real Model::hierarchicalSoftmax(int32_t target, real lr) {
real loss = 0.0;
grad_.zero();
const std::vector<bool>& binaryCode = codes[target]; const std::vector<int32_t>& pathToRoot = paths[target];
for (int32_t i = 0; i < pathToRoot.size(); i++) {
loss += binaryLogistic(pathToRoot[i], binaryCode[i], lr);
} return loss;
}real Model::softmax(int32_t target, real lr) {
grad_.zero();
computeOutputSoftmax(); for (int32_t i = 0; i < osz_; i++) {
real label = (i == target) ? 1.0 : 0.0;
real alpha = lr * (label - output_[i]);
grad_.addRow(*wo_, i, alpha);
wo_->addRow(hidden_, i, alpha);
} return -utils::log(output_[target]);
}
predict
predict 函数可以用于给输入数据打上 1 ~ K 个类标签,并输出各个类标签对应的概率值,对于层次 softmax,我们需要遍历霍夫曼树,找到 top-K 的结果,对于普通 softmax(包括负采样和 softmax 的输出),我们需要遍历结果数组,找到 top-K。
void Model::predict(const std::vector<int32_t>& input, int32_t k, std::vector<std::pair<real, int32_t>>& heap) {
assert(k > 0);
heap.reserve(k + 1);
computeHidden(input);
if (args_->loss == loss_name::hs) {
dfs(k, 2 * osz_ - 2, 0.0, heap);
} else {
findKBest(k, heap);
}
std::sort_heap(heap.begin(), heap.end(), comparePairs);
}void Model::findKBest(int32_t k, std::vector<std::pair<real, int32_t>>& heap) {
computeOutputSoftmax(); for (int32_t i = 0; i < osz_; i++) { if (heap.size() == k && utils::log(output_[i]) < heap.front().first) { continue;
}
heap.push_back(std::make_pair(utils::log(output_[i]), i));
std::push_heap(heap.begin(), heap.end(), comparePairs); if (heap.size() > k) {
std::pop_heap(heap.begin(), heap.end(), comparePairs);
heap.pop_back();
}
}
}void Model::dfs(int32_t k, int32_t node, real score, std::vector<std::pair<real, int32_t>>& heap) { if (heap.size() == k && score < heap.front().first) { return;
} if (tree[node].left == -1 && tree[node].right == -1) {
heap.push_back(std::make_pair(score, node));
std::push_heap(heap.begin(), heap.end(), comparePairs); if (heap.size() > k) {
std::pop_heap(heap.begin(), heap.end(), comparePairs);
heap.pop_back();
} return;
}
real f = utils::sigmoid(wo_->dotRow(hidden_, node - osz_));
dfs(k, tree[node].left, score + utils::log(1.0 - f), heap);
dfs(k, tree[node].right, score + utils::log(f), heap);
}
除了以上两个模块,dictionary 模块也相当重要,它完成了训练文件载入,哈希表构建,word n-gram 计算等功能,但是并没有太多算法在里面。
其它模块例如 Matrix, Vector 也只是封装了简单的矩阵向量操作,这里不再做详细分析。
附录:构建霍夫曼树算法分析
在学信息论的时候接触过构建 Huffman 树的算法,课本中的方法描述往往是:
找到当前权重最小的两个子树,将它们合并
算法的性能取决于如何实现这个逻辑。网上的很多实现都是在新增节点都时遍历一次当前所有的树,这种算法的复杂度是 o(n²)
,性能很差。
聪明一点的方法是用一个优先级队列来保存当前所有的树,每次取 top 2,合并,加回队列。这个算法的复杂度是
O
(
n
l
o
g
n
)
,缺点是必需使用额外的数据结构,而且进堆出堆的操作导致常数项较大。
word2vec 以及 fastText 都采用了一种更好的方法,时间复杂度是
O
(
n
l
o
g
n
)
,只用了一次排序,一次遍历,简洁优美,但是要理解它需要进行一些推理。
算法如下:
void Model::buildTree(const std::vector<int64_t>& counts) {
tree.resize(2 * osz_ - 1);
for (int32_t i = 0; i < 2 * osz_ - 1; i++) {
tree[i].parent = -1;
tree[i].left = -1;
tree[i].right = -1;
tree[i].count = 1e15;
tree[i].binary = false;
} for (int32_t i = 0; i < osz_; i++) {
tree[i].count = counts[i];
}
int32_t leaf = osz_ - 1;
int32_t node = osz_;
for (int32_t i = osz_; i < 2 * osz_ - 1; i++) {
int32_t mini[2];
for (int32_t j = 0; j < 2; j++) {
if (leaf >= 0 && tree[leaf].count < tree[node].count) {
mini[j] = leaf--;
} else {
mini[j] = node++;
}
}
tree[i].left = mini[0];
tree[i].right = mini[1];
tree[i].count = tree[mini[0]].count + tree[mini[1]].count;
tree[mini[0]].parent = i;
tree[mini[1]].parent = i;
tree[mini[1]].binary = true;
}
for (int32_t i = 0; i < osz_; i++) {
std::vector<int32_t> path;
std::vector<bool> code; int32_t j = i; while (tree[j].parent != -1) {
path.push_back(tree[j].parent - osz_);
code.push_back(tree[j].binary);
j = tree[j].parent;
}
paths.push_back(path);
codes.push_back(code);
}
}
算法首先对输入的叶子节点进行一次排序
O
(
n
l
o
g
n
)
),然后确定两个下标 leaf
和 node
,leaf
总是指向当前最小的叶子节点,node
总是指向当前最小的非叶子节点,所以,最小的两个节点可以从 leaf, leaf - 1, node, node + 1 四个位置中取得,时间复杂度
O
(
1
)
,每个非叶子节点都进行一次,所以总复杂度为
O
(
n
)
,算法整体复杂度为
O
(
n
l
o
g
n
)
。
原文:https://heleifz.github.io/14732610572844.html
资源 | 2017年GitHub中Top 30开源机器学习项目
自然语言对话引擎(技术类)
AdaBoost元算法如何提高分类性能——机器学习实战
奇异值分解(SVD)原理
分享 | 由0到1走入Kaggle-入门指导 (长文、干货)
常见文本相似度量方法总结
干货|免费文本语料训练数据集