iOS中文近似度的算法及中文分词(结巴分词)的集成

引言

技术无关, 可跳过.

最近在写一个独立项目,
基于斗鱼直播平台的开放接口, 对斗鱼的弹幕进行实时的分析,
最近抽空记录一下其中一些我个人觉得值得分享的技术.

在写这个项目的时候我一直在思考, 弹幕这种形式已经出来了很久,
而且被广大网友热爱, 确实增强了参与者之间的沟通,
但近年弹幕的形式却没什么很大的创新, 而问题却有许多,
其中有一条弹幕非常多的时候, 其实很多是重复的, 非常影响观感.

于是我提出了一个需求: 实时采集弹幕, 并相互之间对比,
合并相近的弹幕, 这里的"相近"是个什么样的标准就是值得去思考的一个东西了.

在查阅了很多资料之后, 发现这里已经到了一个对自然语言处理的问题,
说大一点属于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(用)] (同上)

先看下面公式

分子就是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;
}
结论

我知道很多人觉得这个挺没有意义的,毕竟没有人在前端上做这些事情..
但实际效果确实不错, 在高峰弹幕期间弹幕合并大于1000+.
这里用的iphone6测试, 30秒1500条弹幕, 分词就可以分成6000+,
再进行各种分析(活跃度, 等级, 词频, 句子, 礼物统计, 筛选等等等),
这种强度下的计算, iphone完全无问题, 多线程处理好之后如下图:

相对于服务器高度依赖于数据库计算, 受制于数据库与硬盘性能来说,
内存中的读写显然更有优势, 问题其实在ARC的情况下内存的释放不太受控制,
非常多弹幕的情况下可能会告警, 不过也只能这样了.
毕竟海量弹幕模式PC打开浏览器仅作展示都会卡死...

另一方面AI计算放在移动设备上可能也是一种趋势,
苹果推出CoreML希望在兼顾隐私的同时,让随身设备更智能,
想象一下全球的手机都有AI系统独立计算各种数据, 数据存在云中再一次处理,
这会是一个很近而且很爆炸的未来.

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

以上.
题外话:App已上架, 名字叫:直播伴侣, 功能点还挺多的
其中绘图(quartz2D),动画(CoreAnimation/lottie)运用的都挺多的.
感觉大家会有兴趣, 有需要可以写写经验.
App大家可以下下来看看, 顺便给个好评, 3Q!

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,222评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,455评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,720评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,568评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,696评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,879评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,028评论 3 409
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,773评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,220评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,550评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,697评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,360评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,002评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,782评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,010评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,433评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,587评论 2 350

推荐阅读更多精彩内容

  • 转载请注明:终小南 » 中文分词算法总结 什么是中文分词众所周知,英文是以 词为单位的,词和词之间是靠空格隔开,而...
    kirai阅读 9,812评论 3 24
  • 前面的文章主要从理论的角度介绍了自然语言人机对话系统所可能涉及到的多个领域的经典模型和基础知识。这篇文章,甚至之后...
    我偏笑_NSNirvana阅读 13,883评论 2 64
  • 常用概念: 自然语言处理(NLP) 数据挖掘 推荐算法 用户画像 知识图谱 信息检索 文本分类 常用技术: 词级别...
    御风之星阅读 9,167评论 1 25
  • “春天在哪里啊,春天在哪里,春天在那小朋友的眼睛里~” 当凌晨2点的手机中传出这首儿歌时,猛然惊醒的我心中一紧,又...
    红老师阅读 412评论 4 8
  • 风徘徊 雨缠绵 路寂寞 人伶俜 白昼彷徨 黑夜辗转 山朦胧 水氤氲 情缱绻 意惆怅 旧事涟漪 新愁悱恻
    晚晴风竹阅读 504评论 0 1