n元ngram模型本质上就是trie树的结构
,逐层状态转移。在sun拼音中是采用的是逐层按照顺序用vector表示,查找的时候逐层二分查找。sun拼音的建立ngram模型的方法也是以按照字典序排好序的<ngram元组,次数>序列作为输入建立起来的。
利用顺序存储+二分查找应该是最节省空间的了。但是效率要受一定影响。其余的trie树实现包括可以利用map(hash_map更耗费空间一点),sun拼音也有一个基于map的trie树实现,sirlm是利用自己的一个LHash实现的类似。另外利用double array trie对于这种预先已经排好序不需要动态添加删除的情况也比较适合但是空间占用较大。TODO 以后会尝试比较下,目前我也是按照sun拼音的顺序存储实现的。
核心数据结构表示
struct Leaf //底层ngram level元素 { WordID id; //词的编号 union { FREQ_TYPE freq; //建立的时候需要先统计次数,最后使用的时候只需要下面的概率值
PR_TYPE pr; }; }
struct Node : Leaf //非底层ngram level元素 { int child; //指向下一level的首地址 PR_TYPE bow; //回退时候需要用到的系数,最底层叶子不需要 }
typedef std::vector<Node> NodeLevel; typedef std::vector<Leaf> LeafLevel; /**核心数据结构*/ vector<NodeLevel > m_nodeLevel; ///非底层 0root, 1gram- (n-1) gram LeafLevel m_leafLevel; ///底层对应的ngram
为了节约内存空间,sun拼音的这种表示方法区别了底层的节点和上层节点,带来的麻烦是代码稍微烦了一点因为要区别处理了。
例如对于3元模型,m_nodeLevel[0]表示root m_nodeLevel[1], m_nodeLevel[2]表示第一二层,m_leafLevel表示第3层即底层。
注意这个模型是可以改进加速的,sunpinyin用了线索化的方法,P(ABC) P(BCD)那么P(ABC)到C的时候直接转移到P(B)省掉了去level1二分查找的过程,
其实吧我觉得一个简单的方法是直接把level1的存储改为大的vector然后直接按照id,下标索引O(1)访问即可了,因为level1占内存不多而其二分查找最耗时。
更好的优化以后再考虑吧,这个模型基本够用了,我用这样一个模型存储的占内存260M的ngram信息,进行简单的3元模型概率分词也能达到4-5/M每秒的速度。
ngram模型的建立算法
/** * 初始化构建n gram模型的资源 */ void init(int n) { //-------设置n元模型 m_nLevel = n; //-------初始化核心数据结构空间 m_nodeLevel.resize(n); for (size_t i = 1; i < n; i++) { m_nodeLevel[i].reserve(kMemInitAllocSize); } m_leafLevel.reserve(kMemInitAllocSize); // m_level.resize(n + 1, NULL); //添加根节点 m_nodeLevel[0].push_back(Node(0, 0, 0)); //-------初始化nr 二维数组 m_nr.resize(n + 1); for (size_t i = 0; i < n + 1; i++) { m_nr[i].resize(kNGramMaxCutR, 0); } //-------初始化剪枝数组 m_cut.resize(n + 1, 0); }
/** * 注意是按照word id字典排序输入的 * 如果ngram[i] (0<i<=n-1) 为排除在外的词,则只统计ngram[0..i-1]的出现次数; * 如果ngram[i] (0<=i<=n-1) 为句子分割词的ID(我们在参数中指定为10),则只统计ngram[0..i]的出现次数。 * 如果某个trigram为(9, x, y),则直接就跳过了; * 如果是(x, 9, y),则只统计unigram (x);如果是(10, x, y),则统计unigram (10),bigram(10,x),trigram(10,x,y); * 如果是(x, 10, y),则统计unigram(x)和bigram (x, 10)。 */ void addNGram(const WordID* ngram, FREQ_TYPE fr) { int ch; bool brk = isExcludeID(*ngram); //如果是需要忽略的位置,特别是指标明岐义的位置,则不统计任何信息 (9,x,y)否则统计一元信息先 if (!brk) { m_nodeLevel[0][0].freq += fr; //统计1gram的总数目 对应0gram } else return; //如果需要先手工增大空间 reallocMem(); bool branch = false; //i = 1 第1个level 对应 ngram[i - 1]即 ngram[0] 要求ngram[0]不是exclude id 9 //如果ngram <100,200,300> 是按顺序 –> <100> <100,200><100,200,300> for (int i = 1; (!brk && i < m_nLevel); i++) { NodeLevel & pv = m_nodeLevel[i - 1]; NodeLevel & v = m_nodeLevel[i]; //1.如果前面出现新状态则后面必定都是新状态 branch = branch || // 1,2,3 | 2,2,3 对于<2,2>来说这是一个新的状态需要push_back因为上一个level已经发现时新状态了 //2.第一次addNGram且i==1时判断用 branch = (pv.back().child >= v.size()) || //3.如果当前的 id变化了则必然是新状态 branch = (v.back().id != ngram[i - 1]) || // 1, 2, 3| 1, 4, 3对于 <1,4>来说是一个新状态因为本level中由 2 ->4 // 另外举例:1 2 3, 1 2 4, 2 2 4, 2 4 4 注意level 2 的 第3个 2是一个新的level 2的状态 而第2个2不是! branch = branch || (pv.back().child >= v.size()) || (v.back().id != ngram[i - 1]); if (branch) { if (i == m_nLevel - 1) ch = m_leafLevel.size(); else ch = m_nodeLevel[i + 1].size(); v.push_back(Node(ngram[i - 1], ch, fr)); } else { v.back().freq += fr; } brk = (i > 1 && isBreakID(ngram[i - 1])) || isExcludeID(ngram[i]); //判断下一个 i+1 level对应的ngram[i]是否有效, 注意开头是break id的所有unigram,bigram,trigram都还要统计 } //n元组加入到叶子level if (!brk) { if (fr > m_cut[m_nLevel]) { m_leafLevel.push_back(Leaf(ngram[m_nLevel - 1], fr)); } else //尽管没有添加次数少被剪掉的节点但是仍然统计其次数 { m_nr[m_nLevel][0] += fr; m_nr[m_nLevel][fr] += fr; } } }
这样我们就建立好了ngram模型了,注意最后需要添加tail方便二分查找。比如再第一层的状态A位置迭代器为iter,则查找A->C状态 即第二层中对应前面是A当前状态是C的状态,只需要在m_nodeLevel[2]的下标为[iter->child,(iter + 1)->child)的范围内查找。
ngram简单频率模型和概率模型测试结果:
pr对应相对概率P(END|AB..) pr2对应绝对概率P(AB…END),如果概率为0标示为-1。这是一个完全按照最大似然概率的简单ngram模型,没有任何光滑,这会造成很多无概率情况出现,后面会继续介绍模型的光滑方法。
testNgram/test_freq.input line ------- 美丽 的 一天 freq ------- 0 line ------- 最 伟大 的 freq ------- 0 line ------- 小红 读 了 freq ------- 1 line ------- 小明 读 了 freq ------- 1 line ------- 了 一本 书 freq ------- 0 line ------- b 美丽 的 freq ------- 3 line ------- 美丽 的 freq ------- 3 line ------- 美丽 的 世界 freq ------- 1 line ------- 美丽 的 心灵 freq ------- 2 line ------- 美丽 freq ------- 3 line ------- 本书 b freq ------- 2 line ------- 本书 freq ------- 2 line ------- 一 本书 b freq ------- 2 line ------- 的 心灵 b freq ------- 2 line ------- b 小明 读 freq ------- 1 line ------- b b b freq ------- 0 line ------- b b freq ------- 0 line ------- b freq ------- 11 //注意break id 10出现的次数统计为11,这里每个句子的结束和下一个句子的开始算作1个break id,另外最后一个句子的结束不统计,最终break id的个数其实=start pos的个数 i ------- 2 i ------- 1 i ------- 0 line ------- 美丽 的 一天 pr ------- -1 pr2 ------- -1 line ------- 最 伟大 的 pr ------- -1 pr2 ------- -1 line ------- 小红 读 了 pr ------- 1 pr2 ------- 0.025 line ------- 小明 读 了 pr ------- 1 pr2 ------- 0.025 line ------- 了 一本 书 pr ------- -1 pr2 ------- -1 line ------- b 美丽 的 pr ------- 1 pr2 ------- 0.075 line ------- 美丽 的 pr ------- 1 pr2 ------- 0.075 line ------- 美丽 的 世界 pr ------- 0.333333 pr2 ------- 0.025 line ------- 美丽 的 心灵 pr ------- 0.666667 pr2 ------- 0.05 line ------- 美丽 pr ------- 0.075 pr2 ------- 0.075 line ------- 本书 b pr ------- 1 pr2 ------- 0.05 line ------- 本书 pr ------- 0.05 pr2 ------- 0.05 line ------- 一 本书 b pr ------- 1 pr2 ------- 0.05 line ------- 的 心灵 b pr ------- 1 pr2 ------- 0.05 line ------- b 小明 读 pr ------- 1 pr2 ------- 0.025 line ------- b b b pr ------- -1 pr2 ------- -1 line ------- b b pr ------- -1 pr2 ------- -1 line ------- b pr ------- 0.275 pr2 ------- 0.275