Recurrent neural network language modeling toolkit 源码剖析(三)

系列前言
参考文献:
  1. RNNLM - Recurrent Neural Network  Language Modeling Toolkit(点此阅读)
  2. Recurrent neural network based language model(点此阅读)
  3. EXTENSIONS OF RECURRENT NEURAL NETWORK LANGUAGE MODEL(点此阅读)
  4. Strategies for Training Large Scale Neural Network  Language Models(点此阅读)
  5. STATISTICAL LANGUAGE MODELS BASED ON NEURAL  NETWORKS(点此阅读)
  6. A guide to recurrent neural networks and backpropagation(点此阅读)
  7. A Neural Probabilistic Language Model(点此阅读)
  8. Learning Long-Term Dependencies with Gradient Descent is Difficult(点此阅读)
  9. Can Artificial Neural Networks Learn Language Models?(点此阅读)

这一篇开始介绍函数实现,对.cpp文件的函数内部语句分别进行走读,函数的顺序我没去组织,就按照文件的顺序进行。如果需要对某个函数配图说明,我会在下面注明,由于自己知识面比较窄,难免很多错误之处,欢迎看到的朋友指出~

好了,我们看一下开头的这部分,内容如下:
#ifdef USE_BLAS
extern "C" {
#include <cblas.h>
}
#endif

其中有一个cblas.h的头文件,blas的全称是basic linear algebra subprograms,用于向量和矩阵计算的高性能数学库, blas本身是Fortran写的,cblas是blas的c语言接口库,rnnlmlib.cpp文件本身是用c++写的,需要调用c语言的cblas,所以需要用extern "C"来表明{}里面的内容需要按c语言的规范进行编译和链接,这是因为C++和C程序编译完成后在目标代码中命名规则不同,extern "C"实现了c和c++的混合编程。更详细的可以参考这篇博文C++中extern “C”含义深层探索,以及CBLAS的安装与使用,通过这两篇文章能了解更多。

下面继续看第一个函数,一个生成随机小数的函数,如下:
real CRnnLM::random(real min, real max)
{
    return rand()/(real)RAND_MAX*(max-min)+min;
}
这里RAND_MAX是VC中stdlib.h中宏定义的一个字符常量,#define RAND_MAX 0x7FFF,其值为32767,通常在产生随机小数时可以使用RAND_MAX。里面的rand()返回值在[0, RAND_MAX],[]表示闭区间,即能取到边界值。这样return返回值范围在[min, max]。如果我们返回[min, max)之间数,可以用下面语句:
return rand() / (real)(RAND_MAX+1) * (max - min) + min;
若要返回随机的整数,可以用rand() % 整数 来获取。

下面几个设置文件名的函数,很容易理解。为了完整性,还是贴出来,如下
//设置训练数据的文件名
void CRnnLM::setTrainFile(char *str)
{	
    strcpy(train_file, str);
}

//设置验证数据集的文件名
void CRnnLM::setValidFile(char *str)
{	
    strcpy(valid_file, str);
}

//设置测试集的文件名
void CRnnLM::setTestFile(char *str)
{	
    strcpy(test_file, str);
}

//设置模型保存文件,即该文件用来存储模型的信息,以及各类参数
void CRnnLM::setRnnLMFile(char *str)
{

下面这个函数是一个基本的函数,在其他函数里面会反复的用到,功能是从文件中读取一个单词到word,但要注意两点:
1.单词最长不能超过99(最后一个字符得为‘\0‘),否则会被截断
2.训练集中每个句子结尾都会自动生成</s>作为一个单独的词,被复制到word返回,这在后面也是用来判断一个句子是否结束的标志。
void CRnnLM::readWord(char *word, FILE *fin)
{
    int a=0, ch;
	
	//feof(FILE *stream)当到达文件末尾时返回一个非0数
    while (!feof(fin)) {
		
		//从流中读取一个字符到ch
		ch=fgetc(fin);
		
		//ascii为13表示回车,\r,即回到一行的开头
		//注意\r与\n不同,后者是换行
		// \r\n主要是在文本文件中出现的
		if (ch==13) continue;
		
		if ((ch==' ') || (ch=='\t') || (ch=='\n')) {
			
			
			if (a>0) {			
				//将'\n'送回到字符流中,下次读取时还会读取到,这里标记一个句子的结束
                if (ch=='\n') ungetc(ch, fin);		
                break;
            }
			
			//如果a=0的情况就遇到换行,即上一个句子的结束,这里把句子的结束标记为</s>单独作为一个word
            if (ch=='\n') {
                strcpy(word, (char *)"</s>");
                return;
            }
            else continue;
        }
		
        word[a]=ch;
        a++;
		
		//过长的单词会被截断,过长的结果word[99] = '\0'
        if (a>=MAX_STRING) {
            //printf("Too long word found!\n");   //truncate too long words
            a--;
        }
    }
	
	//字符串结尾用'\0',其ascii码为0
    word[a]=0;
}

下面这个函数查找word,找到返回word在vocab中的索引,没找到返回-1,有关前面变量只是做了简要的解释,这里简要的理解一下word,getWordHash(word),vocab_hash[],vocab[]的关系,见图。观察图,看到给定一个word,可以在O(1)的时间内得到word在vocab中的索引:vacab[vocab_hash[getWordHash(word)]],这里应用哈希映射是典型的用空间换时间的方法,但是哈希映射有个问题就是冲突,所以这里加了三层查找,如果发生了冲突,那么就在vocab中顺序查找,时间复杂度为O(vocab_size)。
技术分享
技术分享

//返回单词的哈希值
int CRnnLM::getWordHash(char *word)
{
    unsigned int hash, a;
    
    hash=0;
	
	//单词哈希值的计算方式
    for (a=0; a<strlen(word); a++) hash=hash*237+word[a];
	
	//vocab_hash_size在CRnnLm的构造函数初始化为1亿即100000000
    hash=hash%vocab_hash_size;
    
    return hash;
}

<span style="line-height: 36px;">int CRnnLM::searchVocab(char *word)
{
    int a;
    unsigned int hash;
    
    hash=getWordHash(word);
    
	//第一层查找,vocab_hash[hash]==-1表示当前word不在vocab中
    if (vocab_hash[hash]==-1) return -1;
	
	//第二层查找,这里确认当前word并未被其他word给冲突掉
    if (!strcmp(word, vocab[vocab_hash[hash]].word)) return vocab_hash[hash];
    
	//第三层查找,走到了这里,说明当前word与其他word的哈希值有冲突,直接线性查找
    for (a=0; a<vocab_size; a++) {				
        if (!strcmp(word, vocab[a].word)) {			
			//这里把查找到的当前词的哈希值覆盖,这样vocab_hash总是保持最近查找词的hash值
			//越是频繁查找的词,通过这种方式即便冲突了,下次也会在O(1)的时间内查找到!
			vocab_hash[hash]=a;
			return a;
		}
    }
	
	//没找到,即该词不在vocab内,即out-of-vocabulary
    return -1;							
}</span>

下面这个函数读取当前文件指针所指的单词,并返回该单词在vocab中的索引,注意无论是训练数据、验证数据、测试数据文件的格式都是文件末尾空行,所以按照文件内容顺序查找,查找到文件末尾一定是</s>,然后fin就到文件末尾了。
int CRnnLM::readWordIndex(FILE *fin)
{
    char word[MAX_STRING];
	
    readWord(word, fin);
    if (feof(fin)) return -1;
	
    return searchVocab(word);
}

接下来这个函数将word添加到vocab中,并且返回刚添加word在vocab中的索引,并将word与vocab_hash与vocab通过word哈希值关联起来,可以看到这的内存是动态管理的,代码注释如下:
int CRnnLM::addWordToVocab(char *word)
{
    unsigned int hash;
    
    strcpy(vocab[vocab_size].word, word);
    vocab[vocab_size].cn=0;
    vocab_size++;
	
	//vocab是动态管理的,当数组内存快不够了,再扩大数组内存,每次增加100个单位,每个单位是vocab_word类型
    if (vocab_size+2>=vocab_max_size) {        
        vocab_max_size+=100;
		
		//realloc是用来扩大或缩小内存的,扩大时原来的内容不变,系统直接
		//在后面找空闲内存,如果没找到,则会把前面的数据重新移动到一个够大的地方
		//即realloc可能会导致数据的移动,这算自己顺便看源码边复习一些c的知识吧
        vocab=(struct vocab_word *)realloc(vocab, vocab_max_size * sizeof(struct vocab_word));
    }
    
	//将word的哈希值作为vocab_hash的下标,下标所对应的整型值为vocab中对该word的索引
    hash=getWordHash(word);
    vocab_hash[hash]=vocab_size-1;
	
    return vocab_size-1;
}

下面是一个选择排序的算法,将vocab[1]到vocab[vocab_size-1]按照他们出现的频数从大到小排序。
void CRnnLM::sortVocab()
{
    int a, b, max;
    vocab_word swap;
    
	//注意这里下标是从1开始,并未把vocab[0]考虑进来
	//实际上vocab[0]是存放的</s>,从后面的learnVocabFromTrainFile()可以看到
    for (a=1; a<vocab_size; a++) {
        max=a;
        for (b=a+1; b<vocab_size; b++) if (vocab[max].cn<vocab[b].cn) max=b;
		
        swap=vocab[max];
        vocab[max]=vocab[a];
        vocab[a]=swap;
    }
}

然后这个函数是从train_file中读数据,相关数据会装入vocab,vocab_hash,这里假设vocab是空的。
void CRnnLM::learnVocabFromTrainFile()    
{
    char word[MAX_STRING];
    FILE *fin;
    int a, i, train_wcn;
    
	//这里对vocab_hash的初始化说明不在vocab中的word,其vocab_hash[getWordHash(word)]为-1
    for (a=0; a<vocab_hash_size; a++) vocab_hash[a]=-1;
	
	//以二进制模式读取文件
	//关于二进制和文本文件的区别,可以参考这篇博文:http://www.cnblogs.com/flying-roc/articles/1798817.html
	//当train_file是文本文件存储时,即句子结尾是\r\n,前面readWord()函数有一个条件语句if处理掉了\r
	//如果train_file是二进制存储时,句子结尾只有\n,所以对于字符组成的文件来说两者差别不大
    fin=fopen(train_file, "rb");
	
    vocab_size=0;
	
	//也就是vocab[0]是存放的</s>
    addWordToVocab((char *)"</s>");
	
	//记录train_file中tokens数量
    train_wcn=0;
    while (1) {
        readWord(word, fin);
        if (feof(fin)) break;
        
        train_wcn++;
		
		//vocab存放的word不会重复,重复的word让其词频加1
        i=searchVocab(word);
        if (i==-1) {
            a=addWordToVocab(word);
            vocab[a].cn=1;
        } else vocab[i].cn++;
    }
	
	//注意这里在读入train_file后,会将vocab排序,后面会看到对词语分类有帮助
    sortVocab();
    
    //select vocabulary size
    /*a=0;
    while (a<vocab_size) {
	a++;
	if (vocab[a].cn==0) break;
    }
    vocab_size=a;*/
	
    if (debug_mode>0) {
		printf("Vocab size: %d\n", vocab_size);
		printf("Words in train file: %d\n", train_wcn);
    }
    
	//train_words表示训练文件中的词数
    train_words=train_wcn;
	
    fclose(fin);
}
由于本篇长度差不多了,下一篇继续函数实现的分析

郑重声明:本站内容如果来自互联网及其他传播媒体,其版权均属原媒体及文章作者所有。转载目的在于传递更多信息及用于网络分享,并不代表本站赞同其观点和对其真实性负责,也不构成任何其他建议。