iOS中文近似度的演算法及中文分詞(結巴分詞)的整合

syik發表於2017-12-14

引言

技術無關, 可跳過.

最近在寫一個獨立專案, 基於鬥魚直播平臺的開放介面, 對鬥魚的彈幕進行實時的分析, 最近抽空記錄一下其中一些我個人覺得值得分享的技術.

在寫這個專案的時候我一直在思考, 彈幕這種形式已經出來了很久, 而且被廣大網友熱愛, 確實增強了參與者之間的溝通, 但近年彈幕的形式卻沒什麼很大的創新, 而問題卻有許多, 其中有一條彈幕非常多的時候, 其實很多是重複的, 非常影響觀感.

於是我提出了一個需求: 實時採集彈幕, 並相互之間對比, 合併相近的彈幕, 這裡的"相近"是個什麼樣的標準就是值得去思考的一個東西了.

在查閱了很多資料之後, 發現這裡已經到了一個對自然語言處理的問題, 說大一點屬於AI的範疇了, 各大雲平臺例如騰訊雲都有這方面的功能, 蘋果最近WWDC釋出的CoreML就可以使用訓練好的自然語言識別模型. 在還不能用到CoreML(效能問題有待斟酌)之前, 連線雲平臺在瞬間高併發的使用場景下是不太現實的, 所以需要本地算出兩個中文句子的"語義近似度".

理論

編輯距離演算法:

編輯距離,又稱Levenshtein距離,是指兩個字串之間, 由一個轉成另一個所需的最少編輯操作次數。 許可的編輯操作包括將一個字元替換成另一個字元,插入一個字元,刪除一個字元。 每個操作成本不同, 最終可以得到一個編輯距離. 編輯距離越短, 句子就越相似, 編輯距離越長, 句子相似度就越低.

這種演算法很早就被提出來了, 而且網上資料非常齊全, 先看演算法:

#import "NSString+Distance.h"
static inline int min(int a, int b) {
    return a < b ? a : b;
}

@implementation NSString (Distance)
- (float)SimilarPercentWithStringA:(NSString *)stringA andStringB:(NSString *)stringB{
    NSInteger n = stringA.length;
    NSInteger m = stringB.length;
    if (m == 0 || n == 0) return 0;
    
    //Construct a matrix, need C99 support
    NSInteger matrix[n + 1][m + 1];
    memset(&matrix[0], 0, m + 1);
    for(NSInteger i=1; i<=n; i++) {
        memset(&matrix[i], 0, m + 1);
        matrix[i][0] = i;
    }
    for(NSInteger i = 1; i <= m; i++) {
        matrix[0][i] = i;
    }
    for(NSInteger i = 1; i <= n; i++) {
        unichar si = [stringA characterAtIndex:i - 1];
        for(NSInteger j = 1; j <= m; j++) {
            unichar dj = [stringB characterAtIndex:j-1];
            NSInteger cost;
            if(si == dj){
                cost = 0;
            } else {
                cost = 1;
            }
            const NSInteger above = matrix[i - 1][j] + 1;
            const NSInteger left = matrix[i][j - 1] + 1;
            const NSInteger diag = matrix[i - 1][j - 1] + cost;
            matrix[i][j] = MIN(above, MIN(left, diag));
        }
    }
    return 100.0 - 100.0 * matrix[n][m] / stringA.length;
}
@end
複製程式碼

實際測試起來, 這種演算法由於對中文的適應性不好, 會有各種問題, 不細說了. 繼續查資料, 看到另一種演算法.

詞頻向量餘弦夾角演算法:

這種演算法思想也挺簡單的, 將兩個句子構造成兩個向量, 並計算這兩個向量的餘弦夾角cos(θ), 夾角為0°, 則代表兩個句子意思完全相同, 夾角為180°, 則代表兩個句子相似度為零.

下一個問題, 怎樣將句子構造成向量? 這裡就引入"詞頻向量", 簡單的說就是先將兩個句子分詞, 通過詞第一次出現的位置以及詞出現的頻率組成向量, 再計算夾角.

舉個例子: 句子A: 鬥魚伴侶真是有意思,支援鬥魚直播 句子B: 鬥魚伴侶挺有意思,鬥魚直播可以用

分詞之後: 句子A: 鬥魚/伴侶/真是/有意思/支援/鬥魚/直播 句子B: 鬥魚/伴侶/挺/有意思/鬥魚/直播/可以/用

向量: 句子A:[2(鬥魚),1(伴侶),1(真是),1(有意思),1(支援),1(直播)] (鬥魚出現2次, 其他出現1次) 句子B:[2(鬥魚),1(伴侶),1(挺),1(有意思),1(直播),1(可以),1(用)] (同上)

先看下面公式

iOS中文近似度的演算法及中文分詞(結巴分詞)的整合

分子就是2個向量的內積 ab = 2x2(鬥魚) + 1x1(伴侶) + 1x0(真是) + 1x0(挺) + 1x1(有意思) + 1x0(支援) + 1x1(直播) + 1x0(可以) + 1x0(用) = 7

分母是兩個向量的模長乘積 ||a|| = sqrt(2x2(鬥魚) + 1x1(伴侶) + 1x1(真是) + 1x1(有意思) + 1x1(支援) + 1x1(直播)) = 3

||b|| = 2x2(鬥魚) + 1x1(伴侶) + 1x1(挺) + 1x1(有意思) + 1x1(直播) + 1x1(可以) + 1x1(用) = 3.16....

最終可以得出來 cos θ = 0.737865

其實到此為止基本上可以判斷出這兩個句子的相似度了, 換算成角度其實更精確 similarity = arccos(0.737865) / M_PI = 0.764166

參考文章: https://mp.weixin.qq.com/s/dohbdkQvHIGnAWR_uPZPuA

實際

下面具體說說這套演算法思想的實現 這裡面實際用起來有兩個難點: 1.分詞: iOS系統其實自帶分詞Api, 只是對中文的支援並不是那麼友好, 而且在高併發的情況下效能也堪憂, 自定義詞庫那是更加不能實現的了. 2.構造向量並計算: 這個其實在iOS中直接構造向量也是不那麼好實現的, 因為涉及到兩個句子詞的對比, 需要補0.

分詞

這裡感謝開源的分詞庫 結巴分詞 這個庫有各個語言的版本 其中iOS的版本地址: https://github.com/yanyiwu/iosjieba

整合以及使用起來也非常簡單, 效能也非常不錯(蘋果自帶甩分詞不見了) 庫的底層是C++, 所以只是要注意的是用到庫的檔案改為.mm字尾名.

結巴分詞支援自定義詞庫 直接將詞寫入下面檔案 注意不能空行 否則會報錯 iosjieba.bundle/dict/user.dict.utf8

具體詞哪裡來... 用抓包軟體在某些輸入法中抓的= =..

//初始化後直接使用
- (void)loadJieba{
    NSString *dictPath = [[[NSBundle mainBundle] resourcePath] stringByAppendingPathComponent:@"iosjieba.bundle/dict/jieba.dict.small.utf8"];
    NSString *hmmPath = [[[NSBundle mainBundle] resourcePath] stringByAppendingPathComponent:@"iosjieba.bundle/dict/hmm_model.utf8"];
    NSString *userDictPath = [[[NSBundle mainBundle] resourcePath] stringByAppendingPathComponent:@"iosjieba.bundle/dict/user.dict.utf8"];
    
    const char *cDictPath = [dictPath UTF8String];
    const char *cHmmPath = [hmmPath UTF8String];
    const char *cUserDictPath = [userDictPath UTF8String];
    
    JiebaInit(cDictPath, cHmmPath, cUserDictPath);
}


//字串轉詞陣列
- (NSArray *)stringCutByJieba:(NSString *)string{
    
    //結巴分詞, 轉為詞陣列
    const char* sentence = [string UTF8String];
    std::vector<std::string> words;
    JiebaCut(sentence, words);
    std::string result;
    result << words;
    
    NSString *relustString = [NSString stringWithUTF8String:result.c_str()].copy;
    
    relustString = [relustString stringByReplacingOccurrencesOfString:@"[" withString:@""];
    relustString = [relustString stringByReplacingOccurrencesOfString:@"]" withString:@""];
    relustString = [relustString stringByReplacingOccurrencesOfString:@" " withString:@""];
    relustString = [relustString stringByReplacingOccurrencesOfString:@"\"" withString:@""];
    NSArray *wordsArray = [relustString componentsSeparatedByString:@","];
    
    return wordsArray;
}
複製程式碼

計算

上面已經解決了分詞的問題, 下面說說具體怎麼算, 這裡我沒有直接構造向量解決, 並沒有太好的思路. 但是利用演算法的思路和麵向物件的思想我是這樣解決的:

我們需要得到的是向量的內積和模長乘積, 先說模長乘積, 這個數字是固定的, 跟對比的句子無關, 比較好得到. 我們發現向量的內積其實在這裡跟詞的位置無關, 所以可以用字典來構造, key為詞, value為詞頻, 遍歷陣列對比, 可以得到每個詞的詞頻, 構造詞頻字典, 再將兩個字典相同key的value相乘即為模長乘積.

說起來有點繞, 看程式碼:

//這裡構造了兩個BASentenceModel用來存原來的文字,分詞後的詞陣列,以及詞頻字典.

在設定分詞陣列時候遍歷陣列得出詞頻
- (void)setWordsArray:(NSArray *)wordsArray{
    _wordsArray = wordsArray;
    
    //根據句子出現的頻率構造一個字典
    __block NSMutableDictionary *wordsDic = [NSMutableDictionary dictionary];
    [wordsArray enumerateObjectsUsingBlock:^(NSString *obj1, NSUInteger idx1, BOOL * _Nonnull stop1) {
        
        //若字典中已有這個詞的詞頻 +1
        if (![[wordsDic objectForKey:obj1] integerValue]) {
            __block NSInteger count = 1;
            [wordsArray enumerateObjectsUsingBlock:^(NSString *obj2, NSUInteger idx2, BOOL * _Nonnull stop2) {
                if ([obj1 isEqualToString:obj2] && idx1 != idx2) {
                    count += 1;
                }
            }];
            
            [wordsDic setObject:@(count) forKey:obj1];
        }
    }];
    _wordsDic = wordsDic;
}


//傳入兩個句子物件即可得出兩個句子之間的近似度

/**
 餘弦夾角演算法計算句子近似度
 */
- (CGFloat)similarityPercentWithSentenceA:(BASentenceModel *)sentenceA sentenceB:(BASentenceModel *)sentenceB{
    //計算餘弦角度
    //兩個向量內積
    //兩個向量模長乘積
    __block NSInteger A = 0; //兩個向量內積
    __block NSInteger B = 0; //第一個句子的模長乘積的平方
    __block NSInteger C = 0; //第二個句子的模長乘積的平方
    [sentenceA.wordsDic enumerateKeysAndObjectsUsingBlock:^(NSString *key1, NSNumber *value1, BOOL * _Nonnull stop) {
        
        NSNumber *value2 = [sentenceB.wordsDic objectForKey:key1];
        if (value2.integerValue) {
            A += (value1.integerValue * value2.integerValue);
        }
        
        B += value1.integerValue * value1.integerValue;
    }];
    
    [sentenceB.wordsDic enumerateKeysAndObjectsUsingBlock:^(NSString *key2, NSNumber *value2, BOOL * _Nonnull stop) {
        
        C += value2.integerValue * value2.integerValue;
    }];
    
    CGFloat percent = 1 - acos(A / (sqrt(B) * sqrt(C))) / M_PI;
    
    return percent;
}
複製程式碼
結論

iOS中文近似度的演算法及中文分詞(結巴分詞)的整合

我知道很多人覺得這個挺沒有意義的,畢竟沒有人在前端上做這些事情.. 但實際效果確實不錯, 在高峰彈幕期間彈幕合併大於1000+. 這裡用的iphone6測試, 30秒1500條彈幕, 分詞就可以分成6000+, 再進行各種分析(活躍度, 等級, 詞頻, 句子, 禮物統計, 篩選等等等), 這種強度下的計算, iphone完全無問題, 多執行緒處理好之後如下圖:

iOS中文近似度的演算法及中文分詞(結巴分詞)的整合

相對於伺服器高度依賴於資料庫計算, 受制於資料庫與硬碟效能來說, 記憶體中的讀寫顯然更有優勢, 問題其實在ARC的情況下記憶體的釋放不太受控制, 非常多彈幕的情況下可能會告警, 不過也只能這樣了. 畢竟海量彈幕模式PC開啟瀏覽器僅作展示都會卡死...

另一方面AI計算放在移動裝置上可能也是一種趨勢, 蘋果推出CoreML希望在兼顧隱私的同時,讓隨身裝置更智慧, 想象一下全球的手機都有AI系統獨立計算各種資料, 資料存在雲中再一次處理, 這會是一個很近而且很爆炸的未來.

Github:https://github.com/syik/ZJSentenceAnalyze/tree/master

以上. 題外話:App已上架, 名字叫:直播伴侶, 功能點還挺多的 其中繪圖(quartz2D),動畫(CoreAnimation/lottie)運用的都挺多的. 感覺大家會有興趣, 有需要可以寫寫經驗. App大家可以下下來看看, 順便給個好評, 3Q!

iOS中文近似度的演算法及中文分詞(結巴分詞)的整合

相關文章