LDA主题模型-Familia源码解读

1. 定义:

关于LDA有两种含义,一种是线性判别分析(Linear Discriminant Analysis),一种是概率主题模型:隐含狄利克雷分布(Latent Dirichlet Allocation,简称LDA),2003年提出,我们这里讲的是后者。
知乎上有篇帖子关于LDA解释的非常详细:一文详解LDA主题模型

2. LDA模型中重要的概念

传统判断两个文档相似性的方法是通过查看两个文档共同出现的单词的多少,如TF-IDF,但是这种方法忽略了对语义的深层次挖掘,比如两篇文档可能用到的词汇不一样,但是却在说一个事情,或者说是一个主题的。主题模型就是对文档进行语义挖掘,LDA是很经典的方法。三个重要的概念是文档、主题、词语。我们把三者关系进行建模,利用一个生成模型。何谓生成模型?

生成模型:不同的文档选择主题的方法不一样,满足一种分布 α 。我们认为产生一篇文档,首先要确定选择主题的方法,也就是确定一套分布参数 θ ,按照 θ 分布,要从N个主题中以某个概率选择某个主题,也就是确定了主题向量z,然后针对这个主题,按照分布 β ,确定选词方法,选择词语w,最后构成一个文档来表达该主题。我们用以下图,一个联合概率分布来表示此生成过程:
这里写图片描述

  1. 多项式分布:
    • 文章和主题的分布是一个多项式分布 α
    • 主题和词汇的分布是一个多项式分布 β :不同的词汇组合可以用来表达同一个主题,所以对于某个主题,它的词汇分布是一个多项式分布。
  2. 共轭分布
    在贝叶斯概率中,如果先验分布和后验分布属于同类,则先验分布和后验分布成为共轭分布。多项式分布和Dirichlet分布就是共轭分布。关于先验分布、后验分布的详细解释可见博客《先验概率、后验概率以及共轭先验》
    这里写图片描述
    我们之所以在LDA中引入Dirichlet,是为了计算上的方便。在近似模拟以上两个多项式分布时,先由先验概率分布(这里采用Dirichlet分布)随机生成文档->主题的多项式分布的参数 α ,参数确定后我们可以得到一个主题分布 θ ,进而确定主题向量z,然后再由先验概率分布(Dirichlet分布)随机生成一个计算主题->词汇的多项式概率分布参数 β ,也就是确定了主题->词汇的生成方法,确定选词,然后用EM算法训练。E-steps输入 α β ,计算似然函数,M-step最大化这个似然函数,算出 α β ,不断迭代知道收敛。

3. LDA的训练过程

1. 数据预处理:分词
2. 训练:
    1.  对语料库中的每篇文档中的每个词汇$\omega$,随机的赋予一个topic编号z
    2.  重新扫描语料库,对每个词\omega,使用Gibbs Sampling公式对其采样,求出它的topic,在语料中更新
    3.  重复步骤2,直到Gibbs Sampling收敛
    4.  统计语料库的topic-word共现频率矩阵,该矩阵就是LDA的模型

4. 百度开源代码Familia解读

百度开源的LDA工业级主题模型源码:github源码。该应用目前有两大功能,语义表示和语义匹配。该源码因为是面向应用的,不包含LDA主题模型的训练代码,只有应用已有模型进行主题推理的源码。

  • 语义表示(semantic representation) 对文档进行主题划分进而达到降维表示的目的,从而获得文档的语义表示,这些语义可用于文本分类、文本内容分析、CTR预估等下游应用。
  • 语义匹配(Semantic Matching) 计算文本间的语义匹配度,也就是相似度。
    • 短文本与长文本的相似度: 使用场景包括文档关键词提取,计算搜索引擎查询和网页相似度
    • 长文本-长文本相似度: 使用场景包括计算两篇文本相似度,计算用户画像和新闻的相似度

4.1 源码组织结构

familia目录结构

4.1.1 document

在头文件/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.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>& 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---------------

4.1.2 inference engine

推理引擎类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();
        }
    }
}

4.1.3 model

在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<uint64_t>(_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<TopicDist>(_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!";
}

4.1.4 sampler

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<TopicModel> 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<TopicModel> _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<TopicModel> 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<TopicModel> _model;

    // 主题的下标映射
    std::vector<TopicIndex> _topic_indexes;

    // 存放每个单词使用VoseAlias Method构建的alias结果(word-proposal无先验参数部分)
    std::vector<VoseAlias> _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;
};

2.1.5 semantic matching

实现语义匹配计算。

2.1.6 tokenizer

一个简单的英文文本分词器。输入文本串,输出所有的单词,存储在一个字符串向量中。
LDA采用的是词袋模型,把一篇文档拆解成词,不考虑词的顺序。

2.1.7 util

一些工具性的方法

2.1.8 vocab

定义主题模型词表数据结构,主要负责单词到id的映射。

2.1.9 vose alias

实现一种离散采样的方法。

猜你喜欢

转载自blog.csdn.net/fegnkuang/article/details/81327262