关于LDA有两种含义,一种是线性判别分析(Linear Discriminant Analysis),一种是概率主题模型:隐含狄利克雷分布(Latent Dirichlet Allocation,简称LDA),2003年提出,我们这里讲的是后者。
知乎上有篇帖子关于LDA解释的非常详细:一文详解LDA主题模型
传统判断两个文档相似性的方法是通过查看两个文档共同出现的单词的多少,如TF-IDF,但是这种方法忽略了对语义的深层次挖掘,比如两篇文档可能用到的词汇不一样,但是却在说一个事情,或者说是一个主题的。主题模型就是对文档进行语义挖掘,LDA是很经典的方法。三个重要的概念是文档、主题、词语。我们把三者关系进行建模,利用一个生成模型。何谓生成模型?
生成模型:不同的文档选择主题的方法不一样,满足一种分布 α α 。我们认为产生一篇文档,首先要确定选择主题的方法,也就是确定一套分布参数 θ θ ,按照 θ θ 分布,要从N个主题中以某个概率选择某个主题,也就是确定了主题向量z,然后针对这个主题,按照分布 β β ,确定选词方法,选择词语w,最后构成一个文档来表达该主题。我们用以下图,一个联合概率分布来表示此生成过程:
符号解释:
N:表示一个文档中的词的个数
α α : 一篇文章选择一个主题时遵循的分布
θ θ : 确定一套 α α 分布的参数
1. 数据预处理:分词
2. 训练:
1. 对语料库中的每篇文档中的每个词汇$\omega$,随机的赋予一个topic编号z
2. 重新扫描语料库,对每个词\omega,使用Gibbs Sampling公式对其采样,求出它的topic,在语料中更新
3. 重复步骤2,直到Gibbs Sampling收敛
4. 统计语料库的topic-word共现频率矩阵,该矩阵就是LDA的模型
百度开源的LDA工业级主题模型源码:github源码。该应用目前有两大功能,语义表示和语义匹配。该源码因为是面向应用的,不包含LDA主题模型的训练代码,只有应用已有模型进行主题推理的源码。
在头文件/include/familia/document.h中定义了基本的数据结构和API,其方法均在document.cpp中得以实现。基本数据结构有:主题(Topic),单词(Token),句子(Sentence),文档(LDADoc),扩展的句子文档(SLDADoc)。其中后两者为类,具有方法接口。
// -------------LDA Begin---------------
void LDADoc::init(int num_topics) {
//传入主题的数目
_num_topics = num_topics;
_num_accum = 0; // 清空采样累积次数
//清空tokens存储空间
_tokens.clear();
_topic_sum.resize(_num_topics, 0);
_accum_topic_sum.resize(_num_topics, 0);
}
void LDADoc::add_token(const Token& token) {
//检查主题id没有越界
CHECK_GE(token.topic, 0) << "Topic " << token.topic << " out of range!";
CHECK_LT(token.topic, _num_topics) << "Topic " << token.topic << " out of range!";
//把词存储到doc对象中
_tokens.push_back(token);
//对不同主题的词计数
_topic_sum[token.topic]++;
}
void LDADoc::set_topic(int index, int new_topic) {
CHECK_GE(new_topic, 0) << "Topic " << new_topic << " out of range!";
CHECK_LT(new_topic, _num_topics) << "Topic " << new_topic << " out of range!";
int old_topic = _tokens[index].topic;
if (new_topic == old_topic) {
return;
}
_tokens[index].topic = new_topic;
_topic_sum[old_topic]--;
_topic_sum[new_topic]++;
}
void LDADoc::sparse_topic_dist(vector & topic_dist, bool sort) const {
topic_dist.clear();
size_t sum = 0;
for (int i = 0; i < _num_topics; ++i) {
sum += _accum_topic_sum[i];
}
if (sum == 0) {
return; // 返回空结果
}
for (int i = 0; i < _num_topics; ++i) {
// 跳过0的的项,得到稀疏主题分布
if (_accum_topic_sum[i] == 0) {
continue;
}
topic_dist.push_back({i, _accum_topic_sum[i] * 1.0 / sum});
}
if (sort) {
std::sort(topic_dist.begin(), topic_dist.end());
}
}
void LDADoc::dense_topic_dist(vector<float>& dense_dist) const {
dense_dist.clear();
dense_dist.resize(_num_topics, 0.0);
// 若文档长度为0,则范围0向量
if (size() == 0) {
return;
}
for (int i = 0; i < _num_topics; ++i) {
dense_dist[i] = (_accum_topic_sum[i] * 1.0/ _num_accum + _alpha)
/ (size() + _alpha * _num_topics);
}
}
void LDADoc::accumulate_topic_sum() {
for (int i = 0; i < _num_topics; ++i) {
_accum_topic_sum[i] += _topic_sum[i];
}
_num_accum += 1;
}
// -------------LDA End---------------
推理引擎类InferenceEngine主要负责利用两种采样算法进行文档中词汇的主题模型分布推理,现支持Gibbs采样和Metroplis-Hastings两种采样算法。
- 读取模型的配置和存储文件,模型的配置文件存储在prototxt中
- 根据配置初始化采样器
InferenceEngine::InferenceEngine(const std::string& model_dir,
const std::string& conf_file,
SamplerType type) {
LOG(INFO) << "Inference Engine initializing...";
// 读取模型配置和模型
ModelConfig config;
load_prototxt(model_dir + "/" + conf_file, config);
_model = std::make_shared<TopicModel>(model_dir, config);
// 根据配置初始化采样器
if (type == SamplerType::GibbsSampling) {
LOG(INFO) << "Use GibbsSamling.";
_sampler = std::unique_ptr<Sampler>(new GibbsSampler(_model));
} else if (type == SamplerType::MetropolisHastings) {
LOG(INFO) << "Use MetropolisHastings.";
_sampler = std::unique_ptr<Sampler>(new MHSampler(_model));
}
LOG(INFO) << "InferenceEngine initialize successfully!";
}
//输入参数:分词后的字符串向量input,文档对象LDADoc
int InferenceEngine::infer(const std::vector<std::string>& input, LDADoc& doc) {
fix_random_seed(); // 固定随机数种子, 保证同样输入下推断的的主题分布稳定
//设置主题数目
doc.init(_model->num_topics());
//设置alpha值
doc.set_alpha(_model->alpha());
//遍历文档中所有词,如果没有出现,就随机初始化到一个主题中,并加到doc中去
for (const auto& token : input) {
int id = _model->term_id(token);
if (id != OOV) {
int init_topic = rand_k(_model->num_topics());
doc.add_token({init_topic, id});
}
}
//开始推理训练
lda_infer(doc, 20, 50);
return 0;
}
推理的方法:
//传入三个参数,文档对象,
void InferenceEngine::lda_infer(LDADoc& doc, int burn_in_iter, int total_iter) const {
CHECK_GE(burn_in_iter, 0);
CHECK_GT(total_iter, 0);
CHECK_GT(total_iter, burn_in_iter);
for (int iter = 0; iter < total_iter; ++iter) {
//调用采样器的采样方法,对doc中的每个词都进行采样,求出它的topic
_sampler->sample_doc(doc);
if (iter >= burn_in_iter) {
// 经过burn-in阶段后, 对每轮采样的结果进行累积,以得到更平滑的分布
doc.accumulate_topic_sum();
}
}
}
在model.h中定义类TopicModel,定义主题模型的模型存储结构,cpp中负责实现方法,主要包括模型加载方法,topic-word的读取方法等。
TopicModel::TopicModel(const std::string& model_dir, const ModelConfig& config) {
_num_topics = config.num_topics();
_beta = config.beta();
_alpha = config.alpha();
_alpha_sum = _alpha * _num_topics;
_topic_sum = std::vector (_num_topics, 0);
_type = config.type();
// 加载模型
load_model(model_dir + "/" + config.word_topic_file(), model_dir + "/" + config.vocab_file());
}
//加载模型的方法实现
void TopicModel::load_model(const std::string& word_topic_path,
const std::string& vocab_path) {
LOG(INFO) << "Loading model: " << word_topic_path;
LOG(INFO) << "Loading vocab: " << vocab_path;
// 加载词表
_vocab.load(vocab_path);
_beta_sum = _beta * _vocab.size();
_word_topic = std::vector (_vocab.size());
load_word_topic(word_topic_path);
LOG(INFO) << "Model Info: #num_topics = " << num_topics() << " #vocab_size = " << vocab_size()
<< " alpha = " << alpha() << " beta = " << beta();
}
void TopicModel::load_word_topic(const std::string& word_topic_path) {
LOG(INFO) << "Loading word topic from " << word_topic_path;
std::ifstream fin(word_topic_path.c_str(), std::ios::in);
CHECK(fin) << "Failed to open word topic file!";
std::string line;
while (getline(fin, line)) {
std::vector<std::string> fields;
split(fields, line, ' ');
CHECK_GT(fields.size(), 0) << "Model file format error!";
int term_id = std::stoi(fields[0]);
CHECK_LT(term_id, vocab_size()) << "Term id out of range!";
CHECK_GE(term_id, 0) << "Term id out of range!";
for (size_t i = 1; i < fields.size(); ++i) {
std::vector<std::string> topic_count;
split(topic_count, fields[i], ':');
CHECK_EQ(topic_count.size(), 2) << "Topic count format error!";
int topic_id = std::stoi(topic_count[0]);
CHECK_GE(topic_id, 0) << "Topic out of range!";
CHECK_LT(topic_id, _num_topics) << "Topic out of range!";
int count = std::stoi(topic_count[1]);
CHECK_GT(count, 0) << "Topic count error!";
_word_topic[term_id].emplace_back(topic_id, count);
_topic_sum[topic_id] += count;
}
// 按照主题下标进行排序
std::sort(_word_topic[term_id].begin(), _word_topic[term_id].end());
}
fin.close();
LOG(INFO) << "Word topic load successfully!";
}
Gibbs和MH采样器的实现。
// 采样器的接口
class Sampler {
public:
virtual ~Sampler() = default;
// 对文档进行LDA主题采样
virtual void sample_doc(LDADoc& doc) = 0;
// 对文档进行SentenceLDA主题采样
virtual void sample_doc(SLDADoc& doc) = 0;
};
Gibbs采样器:
// 吉布斯采样器,实现了LDA和SentenceLDA两种模型的采样算法,返回LDA模型
class GibbsSampler : public Sampler {
public:
GibbsSampler(std::shared_ptr model) : _model(model) {
}
// 对文档输入进行LDA主题采样,主题结果保存在doc中
void sample_doc(LDADoc& doc) override;
// 使用SentenceLDA模型对文档每个句子进行采样, 结果保存在doc中
// 其中SentenceLDA采样算法考虑了数值计算的精度问题,对公式进行了采样
void sample_doc(SLDADoc& doc) override;
// no copying allowed
GibbsSampler(const GibbsSampler&) = delete;
GibbsSampler& operator=(const GibbsSampler&) = delete;
private:
int sample_token(LDADoc& doc, Token& token);
int sample_sentence(SLDADoc& doc, Sentence& sent);
std::shared_ptr _model;
};
//文档采样法
void GibbsSampler::sample_doc(LDADoc& doc) {
int new_topic = -1;
//对文档中的每个词都进行一次词采样
for (size_t i = 0; i < doc.size(); ++i) {
new_topic = sample_token(doc, doc.token(i));
doc.set_topic(i, new_topic);
}
}
//词采样
int GibbsSampler::sample_token(LDADoc& doc, Token& token) {
//拿到原先的主题id
int old_topic = token.topic;
//拿到模型的主题数目
int num_topics = _model->num_topics();
//每个主题的累计概率和
std::vector<float> accum_prob(num_topics, 0.0);
//每个主题的概率
std::vector<float> prob(num_topics, 0.0);
float sum = 0.0;
float dt_alpha = 0.0;
float wt_beta = 0.0;
float t_sum_beta_sum = 0.0;
//对每个主题,更新文档在不同主题上的alpha的值
for (int t = 0; t < num_topics; ++t) {
dt_alpha = doc.topic_sum(t) + _model->alpha();
wt_beta = _model->word_topic(token.id, t) + _model->beta();
t_sum_beta_sum = _model->topic_sum(t) + _model->beta_sum();
if (t == old_topic && wt_beta > 1) {
if (dt_alpha > 1) {
dt_alpha -= 1;
}
wt_beta -= 1;
t_sum_beta_sum -= 1;
}
prob[t] = dt_alpha * wt_beta / t_sum_beta_sum;
sum += prob[t];
accum_prob[t] = (t == 0 ? prob[t] : accum_prob[t - 1] + prob[t]);
}
double dart = rand() * sum;
if (dart <= accum_prob[0]) {
return 0;
}
//Gibbs采样收敛,返回收敛到的当前主题t
for (int t = 1; t < num_topics; ++t) {
if (dart > accum_prob[t - 1] && dart <= accum_prob[t]) {
return t;
}
}
return num_topics - 1; // 返回最后一个主题id
}
Metropolis-Hastings采样器:
// 基于Metropolis-Hastings的采样器实现,包含LDA和SentenceLDA两个模型的实现
class MHSampler : public Sampler {
public:
MHSampler(std::shared_ptr model) : _model(model) {
construct_alias_table();
}
void sample_doc(LDADoc& doc) override;
void sample_doc(SLDADoc& doc) override;
// no copying allowed
MHSampler(const MHSampler&) = delete;
MHSampler& operator=(const MHSampler&) = delete;
private:
// 根据LDA模型参数构建alias table
int construct_alias_table();
// 对文档中的一个词进行主题采样, 返回采样结果对应的主题ID
int sample_token(LDADoc& doc, Token& token);
// 对文档中的一个句子进行主题采样, 返回采样结果对应的主题ID
int sample_sentence(SLDADoc& doc, Sentence& sent);
// doc proposal for LDA
int doc_proposal(LDADoc& doc, Token& token);
// doc proposal for Sentence-LDA
int doc_proposal(SLDADoc& doc, Sentence& sent);
// word proposal for LDA
int word_proposal(LDADoc& doc, Token& token, int old_topic);
// word proposal for Sentence-LDA
int word_proposal(SLDADoc& doc, Sentence& sent, int old_topic);
// propotional function for LDA model
float proportional_funtion(LDADoc& doc, Token& token, int new_topic);
// propotional function for SLDA model
float proportional_funtion(SLDADoc& doc, Sentence& sent, int new_topic);
// word proposal distribuiton for LDA and Sentence-LDA
float word_proposal_distribution(int word_id, int topic);
// doc proposal distribution for LDA and Sentence-LDA
float doc_proposal_distribution(LDADoc& doc, int topic);
// 对当前词id的单词使用Metroplis-Hastings方法proprose一个主题id
int propose(int word_id);
// LDA model pointer, shared by sampler and inference engine
std::shared_ptr _model;
// 主题的下标映射
std::vector _topic_indexes;
// 存放每个单词使用VoseAlias Method构建的alias结果(word-proposal无先验参数部分)
std::vector _alias_tables;
// 存放每个单词各个主题下概率之和(word-proposal无先验参数部分)
std::vector<double> _prob_sum;
// 存放先验参数部分使用VoseAlias Method构建的alias结果(word-proposal先验参数部分)
VoseAlias _beta_alias;
// 存放先验参数各个主题下概率之和(word-proposal先验参数部分)
double _beta_prior_sum;
// Metropolis-Hastings steps, 默认值为2
static constexpr int _mh_steps = 2;
};
实现语义匹配计算。
一个简单的英文文本分词器。输入文本串,输出所有的单词,存储在一个字符串向量中。
LDA采用的是词袋模型,把一篇文档拆解成词,不考虑词的顺序。
一些工具性的方法
定义主题模型词表数据结构,主要负责单词到id的映射。
namespace familia {
// OOV: out of vocabulary, 表示单词不在词表中
constexpr int OOV = -1;
// 主题模型词表数据结构
// 主要负责明文单词到词id之间的映射, 若单词不在词表中,则范围OOV(-1)
class Vocab {
public:
Vocab() = default;
// 范围给定明文单词的词id
int get_id(const std::string& word) const;
// 加载词表
void load(const std::string& vocab_file);
// 返回词表大小
size_t size() const;
// no copying alowed
Vocab(const Vocab&) = delete;
Vocab& operator=(const Vocab&) = delete;
private:
// 明文到id的映射
std::unordered_map<std::string, int> _term2id;
};
} // familia
实现一种离散采样的方法。