【手撕 - 自然語言處理】手撕 TextRank(02)大佬是怎麼實現 C++ 版的

作者:LogM

本文原載於 https://segmentfault.com/u/logm/articles ,不允許轉載~

1. 源碼來源

comoody 大佬的源碼:https://github.com/comoody/TextRank.git

本文對應的源碼版本:Commits on Oct 23, 2018, 9736be10593b99adc1ea614c5d83f1bfeca17b94

lostfish 大佬的源碼:https://github.com/lostfish/textrank.git

本文對應的源碼版本:Commits on Sep 29, 2016, e89084374ae0e08490c9cc0fa79f8ae4bb10ad5b

TextRank 論文地址:https://www.aclweb.org/anthology/W04-3252

2. 概述

C++ 版本的 TextRank 還沒有發現點贊超級多的代碼,這裏我找了兩個不同的實現來分析。

在上一篇博客:TextRank Python 版本,我們知道,看 TextRank 的源碼有兩個重點需要看,重點1:句子與句子的相似度是如何計算的;重點2:PageRank的實現。

這裏,考慮到篇幅,我直接給出對應的函數所在的位置。

3. comoody 大佬的源碼

先看看大佬是怎麼計算句子與句子之間的相似度的。配合我寫的那幾行中文註釋,應該很容易看懂。大致和論文裏的公式是一致的,但是分母和論文的公式不一樣。具體爲什麼用這個分母,我就不得而知了。

// 文件:src/TextRanker.cpp
// 行數:153
float TextRanker::getSimilarity(std::string a, std::string b) const
{
    // no two equivalent sentences should ever be compared, but this logic is included just in case
    if(a == b)
        return 0.f;

    // 大小寫轉換 -> 分詞 -> 把詞放到 set 裏
    std::transform(a.begin(), a.end(), a.begin(), ::tolower);
    std::vector<std::string> aWords = stringSplit(a, ' ');  
    std::set<std::string> aWordSet;
    for(auto word : aWords)
        aWordSet.insert(word);

    std::transform(b.begin(), b.end(), b.begin(), ::tolower);
    std::vector<std::string> bWords = stringSplit(b, ' ');
    std::set<std::string> bWordSet;
    for(auto word : bWords)
        bWordSet.insert(word);

    // 求兩個 set 的交集,相當於兩個句子共有的詞語
    std::vector<std::string> commonWords;
    std::set_intersection
    (
        aWordSet.begin(),
        aWordSet.end(),
        bWordSet.begin(),
        bWordSet.end(),
        std::back_inserter(commonWords)
    );

    // 這個分母和論文的公式不一樣,論文裏是帶 log 的
    float avgWords = (aWords.size() + bWords.size()) / 2;
    return commonWords.size() / avgWords;
}

然後,我們來看看 PageRank 的實現。哦!看來 comoody 大佬沒有使用 PageRank 的公式,而是在圖中隨機遊走,某個點遊走經過的次數越多,這個點得分越高。

講道理,這種隨機遊走的效果和用 PageRank 的公式求得的效果是差不多的。瞭解 PageRank 的同學應該清楚,PageRank 這個算法的本質,就是模擬用戶在網頁間的隨機遊走。

// 文件:src/TextRanker.cpp
// 行數:77

// does a single walk that visits sentences around the graph according to probabilities defined in the similarity matrix
// after each iteration inside the walk, there is a 1 - kNewWalkThreshold probability that the walk will end and this
// method will return
// during the walk, the visits map is updated accoridingly
void TextRanker::doSentenceGraphWalk
(
    const FloatMatrix& similarityMatrix,
    const std::vector<std::string>& sentences,
    std::map<std::string, int>& visits
) const
{
    const int kDim = sentences.size();
    bool continueWalk = true;
    // start walk at a random node in the sentence graph
    int curSentenceIndex = rand() % kDim;
    while (continueWalk)
    {
        // visit the curSentence
        std::string curSentence = sentences[curSentenceIndex];
        visits[curSentence]++;

        // the row of the similarity matrix corresponding to the current sentence represents the probabilites
        // of transferring to all ofther sentences from the current sentence
        std::vector<float> probabilites;
        std::copy_if
        (
            similarityMatrix[curSentenceIndex].begin(),
            similarityMatrix[curSentenceIndex].end(),
            std::back_inserter(probabilites),
            [](float f) { return f != 0.f; }
        );

        if (probabilites.size() == 0)
            break; // no possible neighbor to visit

        // normalize probabilites
        float sum = std::accumulate(probabilites.begin(), probabilites.end(), 0.f);
        std::transform(probabilites.begin(), probabilites.end(), probabilites.begin(), [sum](float probability)
        {
            return probability / sum;
        });

        // stack probabilites
        std::vector<float> probabilityDistribution;
        for(std::vector<float>::iterator j = probabilites.begin(); j < probabilites.end(); j++)
            probabilityDistribution.push_back(std::accumulate(probabilites.begin(), j+1, 0.f));

        // get a random number betweeon 0 and 1
        float selector = (rand() % 1000) / 1000.f;

        int selectedIndex = 0;
        while(probabilityDistribution[selectedIndex] <= selector)
            selectedIndex++;

        // the selected index maps to a probability from a distribution with all 0 entries removed
        // iterate through the original probabilites to map back the selected index to its true index from the list
        int trueIndex = 0;
        int nonZeroCount = 0;
        do
        {
            if(similarityMatrix[curSentenceIndex][trueIndex] != 0)
                nonZeroCount++;
        }
        while((nonZeroCount < selectedIndex + 1) && ++trueIndex);

        // update the curSentence index so that it can be visited in the next iter of the current walk
        curSentenceIndex = trueIndex;

        // randomly test for the end of a walk, if the random number is above kNkNewWalkThreshold, start a new walk
        float newWalkSelector = (rand() % 1000) / 1000.f;
        if(newWalkSelector > kNewWalkThreshold)
            continueWalk = false;
    }
}

4. lostfish 大佬的源碼

我們再來看看 lostfish 大佬的源碼。同樣的,先看看是如何計算句子與句子之間相似度的。

emmm. 也是和論文公式有些不一樣:統計兩個句子共同出現的詞語時,沒有對每個句子先做一次去重。

// 文件:src/sentence_rank.cpp
// 行數:47
double SentenceRank::CalcDist(const vector<string> &token_vec1, const vector<string> &token_vec2) 
{
    size_t both_num = 0;
    size_t n1 = token_vec1.size();
    size_t n2 = token_vec2.size();
    if (n1 < 2 || n2 < 2)
        return 0;

    // 統計兩句話共同出現的詞語數
    for (size_t i = 0; i < n1; ++i)
    {
        const string &token = token_vec1[i];
        for(size_t j = 0; j < n2; ++j)
        {
            if (token == token_vec2[j])
            {
                both_num++;
                break;
            }
        }
    }

    double dist = both_num / ( log(n1) + log(n2) );
    return dist;
}

再來看看 PageRank 的實現吧。和論文裏給出的公式一樣。我寫了一些註釋,應該還是容易看懂的。

void SentenceRank::CalcSentenceScore(map<size_t, double> &score_map)
{
    score_map.clear();

    // initialize
    size_t n = m_sentence_vec.size();
    for (size_t id = 0; id < n; ++id)
        score_map.insert(make_pair(id, 1.0));

    // iterate
    for (size_t i = 0; i < m_max_iter_num; ++i)
    {
        double max_delta = 0;
        map<size_t, double> new_score_map; // 這裏記錄了每個句子的得分

        for (size_t id1 = 0; id1 < n; ++id1)
        {
            double new_score = 1 - m_d; // 當前迭代輪次中,每個句子的得分,這裏先把論文公式的左邊部分加上

            // 計算論文公式的右邊部分
            double sum_weight = 0.0;
            for (size_t id2 = 0; id2 < n; ++id2)
            {
                if (id1 == id2 || m_out_sum_map[id2] < 1e-6)
                    continue;
                double weight = GetWeight(id2, id1);    // 節點2 -> 節點1 的權重
                sum_weight += weight/m_out_sum_map.at(id2)*score_map[id2];
            }
            new_score += m_d * sum_weight;  // 把論文公式的右邊部分加上
            new_score_map.insert(make_pair(id1, new_score));

            // 監測每兩輪迭代之間score的差值,差值足夠小就不用繼續迭代
            double delta = fabs(new_score - score_map[id1]);
            max_delta = max(max_delta, delta);
        }
        score_map = new_score_map;
        if (max_delta < m_least_delta)
        {
            break;
        }
    }
}

5. 總結

兩位大佬實現的 TextRank C++ 版本,還是有一定的改進空間:

comoody 大佬使用的節點隨機遊走的方式可行,用在個人項目上是可以的;但是工程上來說,rand() 函數導致每次結果都是隨機的,無法復現,是比較致命的。

lostfish 大佬的 PageRank 部分,和論文是一致的,但是運算過程還有一些優化空間。

另外,兩位大佬計算句子與句子之間相似度的函數與論文有點不同,當然這個計算相似度的方法不是定死的,是可以自己創作的,比如用word2vec等等。

我打算自己也實現一個 C++ 的版本。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章