软工之词频统计器及基于sketch在大数据下的词频统计设计

Github项目地址

摘要

  • 本词频统计器包括行数统计、字符数统计、单词数统计、词频统计功能。基于红8黑树算法稳定排序实现,其中红黑树算法为本词频统计器提供良好的效率提供性能下限保证提供词频统计的高性能提供较小的资源开销,而稳定排序算法提供了排序的稳定性,保证了词频统计结果按照字典序生成。本词频统计器基于C++实现,如下图所示,统计器作为对象,具有五个成员函数,分别实现统计器的五个功能,而功能函数提供了稳定排序等功能。并设计了异常处理函数,解决一定场景下的异常问题。

算法关键

红黑树

红黑树是一种自平衡的二叉查找树,是一种高效的查找树。
本词频统计器基于红黑树算法,具有以下优点:

  • 提供良好的效率:可在O(logN)时间内完成查找、增加、删除等操作,能保证在最坏情况下,基本的动态几何操作的时间均为O(lgn)。只要求部分地达到平衡要求,降低了对旋转的要求,任何不平衡都会在三次旋转之内解决,从而提高了效率。本词频统计器设计大量增删改查操作,红黑树算法提供了良好的效率支撑。

  • 提供性能下限保证:相比于BST,红黑树可以能确保树的最长路径不大于两倍的最短路径的长度,可见其查找效果的最低保证。最坏的情况下也可以保证O(logN)的复杂度,好于二叉查找树O(N)复杂度。在大数据量情况下,红黑树算法为词频统计器提供良好的性能保证。

  • 提供词频统计的高性能:红黑树的算法时间复杂度和AVL树相同,但统计性能更高。插入 AVL树和红黑树的速度取决于所插入的数据。在数据比较杂乱的情况,则红黑树的统计性能优于AVL树。在词频统计时,数据分布较为杂乱,在此应用场景下,红黑树算法与词频统计器契合。

  • 提供较小的资源开销:与基于哈希的算法相比,基于红黑树方法带来更小的资源开销,程序消耗内存较少。哈希的算法占用大量资源,需要维护大量的计数器,并且在哈希过程中消耗了大量的计算资源。本词频统计器消耗的资源较少

稳定排序

假定在待排序的记录序列中,存在多个具有相同的关键字的记录,若经过排序,这些记录的相对次序保持不变,则称这种排序算法是稳定的。本词频统计器利用了稳定排序。

  • 稳定性;词频统计后,要求按字典序输出,而经过红黑树处理后,以达到字典序的要求,若使用非稳定排序,虽性能较高,但打乱了原先的字典序。经过稳定排序后,相对次序保持不变,仍为字典序,满足要求
  • 达到一定性能:本词频统计器利用了STL库中的stable_sort()函数,避免重复制造车轮。其复杂度是O(N (log N)^2)。在有足够内存时,可以达到O( N log N)。

代码框架

本程序代码为如上结构,分为两个部分:

  • .h
  • .cpp

.h文件:

定义:

  • 宏定义:用于设定存储空间大小
  • 类定义:定义了统计器的类
  • 结构体定义:定义了存放词频统计的结构体

声明

  • 成员函数声明:声明了包括行统计、字符统计、词数统计、词频统计功能的成员函数
  • 功能函数声明:声明了包括字符类型转换、结构体比较的功能函数
  • 异常处理函数声明:声明了三种的异常处理函数

.cpp文件

  • 主函数定义:定义了主函数
  • 成员函数定义:定义了包括行统计、字符统计、词数统计、词频统计功能的成员函数
  • 功能函数定义:定义了包括字符类型转换、结构体比较的功能函数
  • 异常处理函数定义:定义了三种的异常处理函数

频率统计器的实现

下列过程中,从上到下为词频统计的实现大致过程:

  • 打开文件
  • 异常检测
  • 文件流按行读取到字符串数组
  • 特殊符号处理
  • 大写字母处理
  • 行数组单词化
  • 单词筛选
  • 构造红黑树
  • 提取键值对至结构体数组
  • 稳定排序
  • 重构字符流

接口设计与实现

接口设计

  • 设计了一个Counter类,和构造函数。构造函数从外部获取的源文件名和目的文件名,进行文件流操作。
  • 设计了四个具有统计功能的成员函数,通过获取的源、目的文件名,对文件进行读写,统计行数、字符、单词、频率。不在函数中直接输出,或者直接写入文件,而是返回一个整型值,将输出与功能解耦,降低了函数之间的耦合度。
  • 设计了一个Write()函数直接用于文件读写,专门完成该功能。
class Counter
{
private:int Line;
		int Ch;
		int Words;
		string Freq;
		string sfn, dfn;
public:Counter(){}
	   Counter(string sfn,string dfn)
	   {
		   this->sfn = sfn;
		   this->dfn = dfn;
		   Line = 0;
		   Ch = 0;
		   Words = 0;
		   Freq = "\0";
	   }
	   int LineCount();
	   int CharCount();
	   int WordCount();
	   string WordFreq();
	   void Write();
};
  • 定义了宏,便于更改内存空间使用大小:
#define Linethreshold 5000
#define Charthreshold 50000
#define Wordthreshold 20000
  • 设计了异常处理函数,便于在需要检验之处加入检验功能:
  • 设计了类型转换函数,本统计器多处涉及类型转换,简化了实现:
int DetectFileOpen(ifstream &infile);
int DetectOutfileOpen(ofstream &outfile);
string Conventor(int src);

核心功能词频统计器流程

  • 打开文件
  • 异常检测
  • 文件流按行读取到字符串数组
  • 特殊符号处理:处理非字母和数字字符。
  • 大写字母处理:使用函数将大写字母处理为小写。
  • 行数组单词化:提取单词。
  • 单词筛选:将单词筛选进一个字符串数组。
  • 构造红黑树:将单词和频数构成键值对,
  • 提取键值对至结构体数组:将键值对提取到自设计的结构图数组。
  • 稳定排序:对数组进行稳定排序,保持字典序。
  • 重构字符流:将排序后的前十位输出到字符流中。
string Counter::WordFreq()

  • 特殊符号处理

	for (int i = 0; i<line; i++)//特殊符号处理
	{
		int j = 0;
		while (str[i][j] != '\0')
		{
			if (ispunct(str[i][j]))str[i][j] = ' ';//特殊符号处理为空格
			else
			{
				str[i][j] = tolower(str[i][j]);//化为小写
			}
			j++;
		}
	}

  • 单词提取
	
	for (int i = 0; i<line; i++)//将空格处理后的文档转化为单词 
	{
		if (str[i]!="\0") {
			istringstream stream(str[i]);
			while (stream)stream >> str1[j];
			j++;
		}
	}
  • 单词筛选
	for (int i = 0; i<j; i++)//单词筛选
	{
		isword = true;
		for (k = 0; k<4; k++)//除去数字开头
		{
			if (str1[i][0] == '\0')
			{
				isword = false;
				break;
			}
			if (str1[i][k] == '\0')break;
			else if (!isalpha(str1[i][k])) {
				isword = false;
				break;
			}
		}
		if (!isword) {
			str1[i] = '\0';
		}
	}
  • 构造红黑树
	map<string, int> mymap;
	map<string, int>::iterator it;

	for (int i = 0; i<j ; i++)
	{
		//查找 是否有key 有的话 value++
		//否则加入这个key 
		it = mymap.find(str1[i]);
		if (it == mymap.end())
		{
			mymap.insert(map<string, int> ::value_type(str1[i], 1));
		}
		else
		{
			mymap[str1[i]]++;
		}
	}	

	it = mymap.begin();

	string temps = "\0";
	stringstream ss;
	int i = 0;
	
	WF a[100];

	for (i = 0; it != mymap.end(); it++, i++)
	{	
		a[i].key = it->first;
		a[i].value = it->second;
	}
	
	stable_sort(&a[0], &a[i+1], cmp);
	for (j = 0; j<i;  j++)
	{
		ss.clear();
		temps = "\0";
		str[j] = "\0";
		ss << a[j].value;
		ss >> temps;
		str[j] = "<" + a[j].key + "" + ">: " + temps;
	}
  • 重构字符流
	for (i = 0; str[i] != "\0"; i++)
	{
		if (i >= 10)break;
		if(str[i][0]=='<')
			result += str[i] + "\n";
		else break;
	}
	//cout << result;
	infile.close();
	return result;
}

效果

  • 对一段论文的摘要进行测试:
  • 效果如下

单元测试

  • 单元测试应该在最低的功能/参数上验证程序的正确性。
  • 单元测试必须由最熟悉代码的人(程序的作者)来写。
  • 单元测试过后,机器状态保持不变。
  • 单元测试要快(一个测试运行时间是几秒钟,而不是几分钟)。
  • 单元测试应该产生可重复、一致的结果。
  • 单元测试应该覆盖所有代码路径,包括错误处理路径,为了保证单元测试的代码覆盖率,单元测试必须测试公开的和私有的函数/方法。

基于以上要求,设计了以下单元测试:

序号 测试用例 测试对象 测试结果
1 有空白字符的行 LineCount() 通过
2 存在各种样式的字符 CharCount() 通过
3 数字和单词正确判断 WordCount() 少算一个,更改后通过
4 处理特殊字符 WordCount() 通过
5 处理大写字母 WordFreq() 通过
6 单词种类超过十个 WordFreq() 通过
7 只有字母 WordCount() 迭代器崩溃,更改后通过
8 单词频率是否正确排序 WordFreq() 符合字典序
9 无空行 LineCount() 通过
10 空白文本 LineCount() 输出为0,通过

  • 测试覆盖率,可以看出,覆盖率极高,分析后主要未覆盖部分为异常检测函数,损失部分覆盖率。

以下列出序号1的单元测试代码:

namespace UnitTest1
{		
	TEST_CLASS(UnitTest1)
	{
	public:
		
		TEST_METHOD(TestMethod1)
		{
			// TODO: 在此输入测试代码
			Counter C("F:\\软工\\WordCount\\TEST\\1.txt", "F:\\软工\\WordCount\\TEST\\result.txt");
			int re = C.LineCount();
			Assert::AreEqual(re, 2);

		}

	};
}

性能分析

性能分析图

  • 可以看出,红框内的词频统计功能,占用了最多的CPU资源:

问题发现

  • 从以上两图可以看出,在申请内存空间和返回字符串时,性能开销最大。所以考虑从内存分配入手,优化性能。

解决方案

法一

  • 为了更加灵活高效申请内存空间,设计了宏。
  • 可以方便定义内存空间使用,并且节省CPU消耗。
  • 修改时,只需要修改宏即可。
#define Linethreshold 5000
#define Charthreshold 50000
#define Wordthreshold 20000

法二

  • 当数据量非常大的时候,内存将成为性能瓶颈,提出基于sketch在大数据下的词频统计设计,利用算法Count-min sketch解决内存消耗过大的问题。(具体见下文)

异常处理

  • 设计了三个应用场景的异常处理,包括:
  • 读文件未正常开启:
int DetectFileOpen(ifstream &infile)
{
    if (!infile.is_open())
	{
		cout << "Cannot open the file, please input right filename!";
		system("pause");
		return 0;
	}
}

  • 写文件未能正常开启:

int DetectOutfileOpen(ofstream &outfile)
{
    if (outfile.is_open())
	{
		cout << "Cannot open the file!";
		system("pause");
		return 0;
	}
}

  • 输入错误的文件名:

if (argc != 2)
	{
		cout << "Uncorrect parameters, Please attach .exe and .txt file in order.";
		system("pause");
	}
  • 文件名错误时,报错,并提供解决方案:

PSP表格记录

PSP2.1 header 2 预估耗时(分钟) 实际耗时(分钟)
Planning 计划 35 30
· Estimate ·估计这个任务需要多少时间 15 5
Development 开发 645 1220
· Analysis 需求分析(包括学习新技术) 40 50
· Design Spec · 生成设计文档 40 60
· Design Review · 设计复审 10 10
· Coding Standard · 代码规范 (为目前的开发制定合适的规范) 5 10
· Design · 具体设计 60 120
· Coding · 具体编码 240 600
· Code Review · 代码复审 10 10
· Test ·测试(自我测试,修改代码,提交修改) 240 360
Reporting 报告 245 145
· Test Repor · 测试报告 240 120
· Size Measurement · 计算工作量 5 5
· Postmortem & Process Improvement Plan · 事后总结, 并提出过程改进计划 20 20
|合计||935|1400

感想

  • 软工作业的量,真是超乎了我的想象,从PSP表中可以看出,我的预估严重不准。而且表中的记录只可能小于实际值,还不包括写博客的时间。这次统计器的实现,不算难,但也走了不少弯路,例如需求分析没有做到位,设计的大局观不足,导致后期代码反复迭代差错,浪费大量时间。其中从《构建之法》中,学到了单元测试要求,很好地帮我解决了设计问题,让我设计出的单元测试更加合理。最有意思的是单元测试还有强大的性能分析工具,在单元测试后,我查出了许多代码中的不足,并加以修改。在性能分析后,我可以准确找到我设计中性能的不足和导致不足相应的代码段,进行优化,应用于之后的程序设计及框架设计,将是一大利器。本次也是代码规范化的一大训练,可以体会一些,在实际开发中的方法和思想,回想到之前实际大型开源项目代码的阅读,对其中代码的规范化、接口的设计体会便更加深刻了。在设计过程中,也偶有灵感扇动,也一并记录下来。

基于sketch在大数据下的词频统计设计

引言

  • 海量数据的下,要对数据中的单词的词频进行统计分析,需要对数据存储、处理,并且维护大量的计数器,将消耗极大资源空间。在本设计中,需要对单词进行分析、处理、稳定排序,最后得出精确的统计结果,而在大数据下,消耗的时间和内存资源是不可接受的。在数据足够多的情况下,要了解单词出现的频率和频率排序,其实没有必要了解词频的精确值,只需提供单词出现的估计值和排序即可。对此,通过在网络测量中相关文献的阅读,提出基于sketch在大数据下词频统计,以达到**节省资源*的目的。

背景

  • 在网络测量中,常使用基于sketch的方法,在高速条件下对网络流的数据进行估计,达到节约资源的目的。在网络中存在各种各样的包,若按精确的分类方法,对每一种包都分配一个计数器储存,虽然测量准确,但存放计数器的空间开销会非常大。故使用哈希的方法,根据哈希值的范围确定的所需的存储空间,各种包根据哈希值再次归类,可以大大减少存储空间*。这样使用哈希来估计流的方法称为Sketch-based方法。
  • 相似的,在大数据下,如果对所有单词都进行准确存储统计,不仅没有必要,而且占用大量内存资源。对此,提出基于sketch在大数据下词频统计,在达到需求的条件下,达到节省资源目的。

解决方案

  • 使用哈希的方法会产生冲突,多个种类的单词哈希到同一个桶内,那么这个桶的计数值就会偏大,为了减少误差,可以使用count-min sketch*。
  • Count-min sketch:设置多个哈希函数,开辟一个二维地址空间,包经过不同哈希函数的处理,得到对应的哈希值,而这个哈希值就是sketch(概要)。这些哈希值可能产生冲突,多个种类单词可能有相同的哈希值,根据哈希值来确定单词出现的次数则会偏大,所以设置多个哈希函数,取最小的哈希值,则最接近实际包数据。
  • 得到每个种类包的估计值后,对sketch进行排序,取前十种类的包,即可得到词频统计结果。

总结

  • Sketch是使用哈希来进行估计网络流的一种测量方法,可以减少存储开销,相似地,应用于大数据下的词频统计,可以减少内存开销
  • 可以使用Count-Min Sketch对文本数据进行处理,多个哈希函数的最小哈希值作为词频的估计,实现简单,空间开销较少,

参考文献:

posted @ 2018-09-12 22:08  范加索尔拉  阅读(1001)  评论(4编辑  收藏  举报