班级:软件工程1916|W
做业:结对第二次—文献摘要热词统计及进阶需求
结对学号:221600418 黄少勇、 221600420 黄种鑫
课程目标:学会使用Git、提升团队协做能力
Github地址:基础需求、进阶需求
分工:javascript
- 黄少勇--词频统计代码实现,性能优化
- 黄种鑫--爬虫代码实现,可视化实现,单元测试
基本需求:
进阶需求:
html
PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
---|---|---|---|
Planning | 计划 | 10 | 10 |
• Estimate | • 估计这个任务须要多少时间 | 10 | 10 |
Development | 开发 | 750 | 980 |
• Analysis | • 需求分析 (包括学习新技术) | 120 | 150 |
• Design Spec | • 生成设计文档 | 20 | 20 |
• Design Review | • 设计复审 | 10 | 10 |
• Coding Standard | • 代码规范 (为目前的开发制定合适的规范) | 10 | 10 |
• Design | • 具体设计 | 30 | 40 |
• Coding | • 具体编码 | 400 | 550 |
• Code Review | • 代码复审 | 100 | 120 |
• Test | • 测试(自我测试,修改代码,提交修改) | 60 | 80 |
Reporting | 报告 | 50 | 50 |
• Test Report | • 测试报告 | 20 | 20 |
• Size Measurement | • 计算工做量 | 10 | 10 |
• Postmortem & Process Improvement Plan | • 过后总结, 并提出过程改进计划 | 20 | 20 |
合计 | 810 | 1040 |
在拿到这个题目后,咱们俩经过分析,肯定基本需求即为进阶需求的特例(进阶需求中, -m
参数的值取为 1
, -n
参数的值取为 10
, -w
参数的值取为 0
即为基本需求的要求),因此咱们一开始就肯定直接开发进阶需求,经过给定参数默认值的方式,完成基本需求的任务。java
本次需求包含三部分:字符统计、单词(词组)统计、行数统计,而这三个能够当作一个连续的过程,因此咱们的思路一开始也是想把三部分整合在一块儿完成,即:打开一次文件,按行读取(实现了行数统计),将读取出来的行,进行字符统计,而且使用 String.split()
方法将整行切割为单词,实现单词的统计,进而按照给定的 -m
参数,将分隔完的单词进行拼装,统计出长度为m的词组。可是后来详细分析需求后,发现题目要求将三个功能独立出来,因此最终决定把三个功能分开实现,即:实现三个方法,分别实现打开文件并统计字符、单词(词组)、行数。node
另外,为了使处理命令行参数和词频统计等功能分隔开,而且实现相对独立,咱们决定实现一个 Signal
类。该类主要完成对命令行的分析,为词频统计提供参数。jquery
由以上思路能够获得,咱们须要两个类—— WordCount
类及 Signal
类。WordCount
类中至少实现三个方法:字符统计、单词(词组)统计、行数统计, Signal
类至少实现一个方法:命令行分析。类图设计以下:
词频统计流程图:
行数、字符数统计流程图:
git
如下测试均使用爬取获得的2018年CVPR论文数据,使用参数 -m 2
,使用工具JProfiler获得。
概览:
优化前各方法耗时:
经过分析各个方法的耗时状况,咱们获得,程序的瓶颈主要在 WordCount.setWordNumber()
方法上,所以咱们着重对该方法优化。github
private void setWordNumber() { String line; try (BufferedReader br = new BufferedReader(new FileReader(inFile))) { while ((line = br.readLine()) != null) { line = line.toLowerCase(); // 按行读取,并判断是否为Title行 if (line.indexOf("title:") == 0) { weight = signal.getwValue(); line = line.replaceFirst("title:", ""); } if (line.indexOf("abstract:") == 0) { weight = 1; line = line.replaceFirst("abstract:", ""); } splitLine(line); // 将每一行进行拆分处理 } } catch (IOException e) { e.printStackTrace(); } } // 判断一个单词是不是题目要求的合法单词 private boolean isWord(String s) { return s.length() >= 4 && Character.isLetter(s.charAt(0)) && Character.isLetter(s.charAt(1)) && Character.isLetter(s.charAt(2)) && Character.isLetter(s.charAt(3)); } private void splitLine(String line) { if(line.isEmpty()){ return; } if(Character.isLetterOrDigit(line.charAt(0))) { deviation = -1; } else { deviation = 0; } String[] str = line.split("[^a-z0-9]"); // 切割获得每一个单词 for (String aStr : str) { if (!"".equals(aStr)) { // 去掉切割产生的空白后,将单词加入arrayList待后续组词使用 arrayList.add(aStr); if (isWord(aStr)) { // 判断单词单词是否合法 wordNumber += 1; } } } str = line.split("[a-z0-9]"); // 切割获得单词间的分隔符 for(String aStr : str) { if(!"".equals(aStr)) { separator.add(aStr); } } setMap(); // 拼接单词,组成长度为m的词组 arrayList.clear(); separator.clear(); word.clear(); }
经过观察JProfiler的数据,咱们发现, WordCount.setWordNumber()
中,耗时最多的为 WordCount.splitLine()
中的 String.split()
耗时最多。正则表达式
查阅资料后,网上对于 split()
方法的优化,主要是使用 String.indexOf()
方法或 StringTokenizer()
方法实现,可是这两个方法一次仅能查找一个分隔符,而本次做业中分隔符种类较多,自行实现起来可能较为麻烦,并且咱们查阅了JAVA中的 split()
源码,发现其也是采用的 indexOf()
实现,故咱们放弃了手动使用 indexOf()
实现分割。数组
最后,咱们尝试将 split()
的调用,改成使用正则实现:性能优化
private void splitLine(String line) { // ... Pattern r = Pattern.compile("[a-z0-9]+"); Matcher m = r.matcher(line); while (m.find()){ arrayList.add(m.group(0)); if (isWord(m.group(0))) { wordNumber += 1; } Pattern r2 = Pattern.compile("[^a-z0-9]+"); Matcher m2 = r2.matcher(line); while (m2.find()){ separator.add(m2.group(0)); } // ... }
改用正则优化第一次后各方法耗时:
使用正则后,时间少了 500ms 左右,算是有些许优化,可是效果并不明显,因此咱们针对耗时第二多的 isWord()
方法进行改进。 isWord()
虽然简单,可是因为调用次数过多(每一个单词都得调用两次),因此总耗时过大。咱们经过加入一个辅助数组,用来记录每一个单词是否合法,这样子能够把 isWord()
的调用次数变为原来的一半。修改以下:
private void splitLine(String line) { // ... while (m.find()){ arrayList.add(m.group(0)); if (isWord(m.group(0))) { wordNumber += 1; isLegalWord.add(true); // 若是是合法单词,就把对应位置为true } else{ isLegalWord.add(false); } } // ... setMap(); // ... } private void setMap() { combineWord(); // ... } // 将单词拼接为长度为m的词组 private void combineWord() { for (int i = 0; i < arrayList.size() - signal.getmValue() + 1; i++) { boolean flag = true; for (int j = 0; j < signal.getmValue() && flag; j++) { if (!isLegalWord.get(i+j)) { flag = false; } } // ... } }
优化第二次后各方法耗时:
加入辅助数组后,时间又减小了 500ms 左右。
至此,在两次优化后,运行时间可以减小 1s 左右。
// 提供一个获取行数的接口,供外部调用 public int getLineNumber() { return lineNumber; } // 生成行数,为私有方法,供构造函数调用 private void setLineNumber() { String line; try (BufferedReader br = new BufferedReader(new FileReader(inFile))) { while ((line = br.readLine()) != null) { // 按行读取文件 if(!line.trim().isEmpty()){ // 去掉行首行尾的空白后,判断是否为空行 lineNumber++; } } } catch (IOException e) { e.printStackTrace(); } }
// 提供一个获取字符数的接口,供外部调用 public int getCharacterNumber() { return characterNumber; } // 生成字符数,为私有方法,供构造函数调用 private void setCharacterNumber() { int ch; try (FileReader fr = new FileReader(inFile)) { while ((ch = fr.read()) != -1) { // 逐字节读取文本 if(ch != 13){ // 若是读取到的字符不为 \r 时字符数加1 // 由于题目要求的换行符为\r\n(为一个总体), // 若是不跳过其中一个,会致使最终字符数会比实际字符数多出一倍行数, // 因此选择其中的一个便可,咱们选择了 \r。 characterNumber++; } } } catch (IOException e) { e.printStackTrace(); } }
// 提供一个获取单词数的接口,供外部调用 public int getWordNumber() { return wordNumber; } // 生成单词数,为私有方法,供构造函数调用 private void setWordNumber() { String line; try (BufferedReader br = new BufferedReader(new FileReader(inFile))) { while ((line = br.readLine()) != null) { // 逐行读取,并将每行的内容转为小写,便于后续处理 line = line.toLowerCase(); // 判断是否为Title行或者Abstract行,调整权重,用于统计词频(若是为基础需求,默认的权重为1) if (line.indexOf("title:") == 0) { weight = signal.getwValue(); line = line.replaceFirst("title:", ""); } if (line.indexOf("abstract:") == 0) { weight = 1; line = line.replaceFirst("abstract:", ""); } splitLine(line); // 将每一行进行拆分处理 } } catch (IOException e) { e.printStackTrace(); } } // 判断一个单词是不是题目要求的合法单词 private boolean isWord(String s) { return s.length() >= 4 && Character.isLetter(s.charAt(0)) && Character.isLetter(s.charAt(1)) && Character.isLetter(s.charAt(2)) && Character.isLetter(s.charAt(3)); } private void splitLine(String line) { if(line.isEmpty()){ return; } // 判断首字母是否为字母,用于后续拼接词组时,将单词与分隔符正确拼接 if(Character.isLetterOrDigit(line.charAt(0))) { deviation = -1; } else { deviation = 0; } // 正则匹配单词 Pattern r = Pattern.compile("[a-z0-9]+"); Matcher m = r.matcher(line); while (m.find()){ arrayList.add(m.group(0)); // 将单词加入arrayList待后续组词使用 if (isWord(m.group(0))) { wordNumber += 1; isLegalWord.add(true); // 若是是合法单词,就把对应位置为true } else{ isLegalWord.add(false); } } // 正则匹配分隔符 Pattern r2 = Pattern.compile("[^a-z0-9]+"); Matcher m2 = r2.matcher(line); while (m2.find()){ separator.add(m2.group(0)); } setMap(); // 拼接单词,组成长度为m的词组 // 清空本行的内容,等待处理下一行 arrayList.clear(); separator.clear(); word.clear(); isLegalWord.clear(); } // 将单词拼接为长度为m的词组,并统计词组词频 private void setMap() { combineWord(); for (String aWord : word) { if (map.containsKey(aWord)) { map.put(aWord, map.get(aWord) + weight); } else { map.put(aWord, weight); } } } // 将单词拼接为长度为m的词组 private void combineWord() { for (int i = 0; i < arrayList.size() - signal.getmValue() + 1; i++) { boolean flag = true; // 判断从i开始的m个单词是否均为合法单词,如果,则可组成一个长度为m的词组 for (int j = 0; j < signal.getmValue() && flag; j++) { if (!isLegalWord.get(i+j)) { flag = false; } } if (flag) { // 若是可拼接成长度为m的词组,则将这m个单词与其中的分隔符进行拼接 StringBuilder s = new StringBuilder(arrayList.get(i)); for(int j = 1; j < signal.getmValue(); j++) { s.append(separator.get(i + j + deviation)).append(arrayList.get(i + j)); } word.add(s.toString()); } } }
爬虫使用的是JAVA实现,因为对于一些爬虫框架并不熟悉,因此使用的是JAVA原生的URL类请求网页内容,使用正则进行数据匹配。代码以下:
// 封装的请求函数,用于发起请求并返回页面HTML内容 private static String getHtmlContent(String uri) { StringBuilder result = new StringBuilder(); try { String baseURL = "http://openaccess.thecvf.com"; URL url = new URL(baseURL + "/" + uri); URLConnection connection = url.openConnection(); connection.connect(); BufferedReader in = new BufferedReader(new InputStreamReader(connection.getInputStream())); String line; while ((line = in.readLine()) != null) { result.append(line); } } catch (IOException e) { e.printStackTrace(); } return String.valueOf(result); } public static void main(String[] args) { // 加载论文类型的 CSV 表格 // 因为未在官网上找到论文类型,所以使用在Github(https://github.com/amusi/daily-paper-computer-vision/blob/master/2018/cvpr2018-paper-list.csv)上其余人搜集好的数据 Map<String, String> map = new HashMap<>(); try (BufferedReader bufferedReader = new BufferedReader(new FileReader(new File("cvpr/cvpr2018-paper-list.csv")))) { String line; // 逐行读取CSV表格,并切割获得其论文类型及论文名,并以论文名为键,论文类型为值,生成一个HashMap while ((line = bufferedReader.readLine()) != null) { String[] list = line.split(","); map.put(list[2].toLowerCase(), list[1]); } } catch (IOException e) { e.printStackTrace(); } // 获取CVPR官网首页内容 String result = getHtmlContent("CVPR2018.py"); // 获取到首页后,使用正则匹配,获得论文详细内容的连接,并逐个发起请求,继续使用正则匹配详情页,获得论文题目、摘要、做者信息等内容 /* 首页HTML样式 <dt class="ptitle"><br><a href="content_cvpr_2018/html/Das_Embodied_Question_Answering_CVPR_2018_paper.html">Embodied Question Answering</a></dt> */ /* 详情页HTML样式 <div id="papertitle">title</div><div id="authors"><br><b><i>authors</i></b>; where</div><font size="5"><br><b>Abstract</b></font><br><br><div id="abstract" >abstract</div><font size="5"><br><b>Related Material</b></font><br><br>[<a href="url">pdf</a>] */ String pattern = "<dt class=\"ptitle\"><br><a href=\"(.*?)\">(.*?)</a></dt>"; String pattern2 = "<div id=\"papertitle\">(.*?)</div><div id=\"authors\"><br><b><i>(.*?)</i></b>; (.*?)</div><font size=\"5\"><br><b>Abstract</b></font><br><br><div id=\"abstract\" >(.*?)</div><font size=\"5\"><br><b>Related Material</b></font><br><br>\\[<a href=\"(.*?)\">pdf</a>]"; Pattern r = Pattern.compile(pattern); Pattern r2 = Pattern.compile(pattern2); Matcher m = r.matcher(result); StringBuilder res = new StringBuilder(); int i = 0; while (m.find()) { String html = getHtmlContent(m.group(1)); System.out.println(i); System.out.println("URL: " + m.group(1)); Matcher m2 = r2.matcher(html); if (m2.find()) { // 输出详细内容 System.out.println("Title: " + m2.group(1)); System.out.println("authors: " + m2.group(2)); System.out.println("Type: " + map.get(m2.group(1).split(",")[0].toLowerCase())); System.out.println("Where: " + m2.group(3)); System.out.println("Abstract: " + m2.group(4)); System.out.println("PDF: " + m2.group(5).replace("../../", "http://openaccess.thecvf.com/")); res.append(i).append("\r\n"); // res.append("Type: ").append(map.get(m2.group(1).split(",")[0].toLowerCase())).append("\r\n"); res.append("Title: ").append(m2.group(1)).append("\r\n"); // res.append("Authors: ").append(m2.group(2)).append("\r\n"); res.append("Abstract: ").append(m2.group(4)).append("\r\n"); // res.append("PDF: ").append(m2.group(5).replace("../../", "http://openaccess.thecvf.com/")).append("\r\n\r\n\r\n"); } System.out.println(); System.out.println(); i++; } // 将内容写入文件 try (BufferedWriter bufferedWriter = new BufferedWriter(new FileWriter(new File("cvpr/result.txt")))) { bufferedWriter.write(String.valueOf(res)); } catch (IOException e) { e.printStackTrace(); } }
爬虫结果展现:
使用 JUnit4
进行单元测试,测试了词频统计部分主要的四个函数,即:
WordCount.getCharacterNumber()
:字符数统计WordCount.getLineNumber()
:行数统计WordCount.getWordNumber()
:单词数统计WordCount.getList()
:词频统计测试数据集为十个文本,分别为:空文本、全为空行的文本、字母数字分隔符随机交替出现的文本、混有空行的文本、混有非单词的文本、正常的单词文本等。
单元测试结果及代码覆盖率以下图:
覆盖率中,WordCount
类因为存在比较多的异常处理分支,故覆盖率只有86%,而命令行处理类 Signal
类因为在单元测试时未指定全部参数,故覆盖率也较低。
测试数据: file123&&&file123 123file aaa AAA aaaa AAAA AaAa 测试结果: charactors: 49 words: 5 lines: 7 <file123&&&file123>: 1
测试数据: as!@#$!@$rwehhhkk***---===++==++ \t\n \r\n mmm&^&&&^^^ 测试结果: charactors: 38 words: 3 lines: 3 <aaaa>: 1 <fefeffffff>: 1 <file1daa>: 1
测试数据: abcdefghijklmnopqrstuvwxyz 1234567890 ,./;'[]\<>?:"{}|`-=~!@#$%^&*()_+ 测试结果: charactors: 76 words: 1 lines: 3 <abcdefghijklmnopqrstuvwxyz>: 1
查阅网上博客及相关教程
正则相关已解决,爬虫框架因为时间问题没有深刻了解
对于正则表达式,有了进一步的认识,同时对于爬虫相关的东西也有了初步的印象,后续有时间可多多接触下。
个人队友很nice,思路清晰,代码能力也不错
从网站综合爬取论文的除题目、摘要外其余信息。
因为未在官网上找到论文类型,所以使用在 Github 上其余人搜集好的数据。最终结果以下:
实现了论文做者的关系图。关系图使用 ECharts
框架实现可视化效果。数据来自爬虫获得的论文数据集,使用Java将原始数据生成ECharts所须要的XML数据格式。HTML代码以下:
<html> <head> <meta charset="utf-8"> <script src="echarts.js"></script> <script src="dataTool.min.js"></script> <script src="jquery-3.3.1.js"></script> </head> <body> <div id="main" style="width: 100%;height:110%;"></div> <script type="text/javascript"> var myChart = echarts.init(document.getElementById('main')); myChart.showLoading(); $.get('cvpr_authors.gexf', function (xml) { myChart.hideLoading(); var graph = echarts.dataTool.gexf.parse(xml); var categories = []; for (var i = 0; i < 30; i++) { categories[i] = { name: '' + i }; } graph.nodes.forEach(function (node) { node.itemStyle = null; node.value = node.symbolSize; node.symbolSize *= 1; node.label = { normal: { show: node.symbolSize > 30 } }; node.category = node.attributes.modularity_class; }); option = { title: { text: '做者关系图', subtext: 'Default layout', top: 'bottom', left: 'right' }, tooltip: {}, legend: [{ data: categories.map(function (a) { return a.name; }) }], animationDuration: 1500, animationEasingUpdate: 'quinticInOut', series : [ { name: '论文数', type: 'graph', layout: 'circular', circular: { rotateLabel: true }, data: graph.nodes, links: graph.links, categories: categories, roam: true, focusNodeAdjacency: true, itemStyle: { normal: { borderColor: '#fff', borderWidth: 1, shadowBlur: 10, shadowColor: 'rgba(0, 0, 0, 0.3)' } }, label: { position: 'right', formatter: '{b}' }, lineStyle: { color: 'source', curveness: 0.3 }, emphasis: { lineStyle: { width: 5 } } } ] }; myChart.setOption(option); }, 'xml'); </script> </body> </html>
效果图以下:
因为做者间关系过于复杂,全部数据生成的图会过密。
筛选部分可得下图:
鼠标移动到某个点上可查看这个做者的论文数及与其余做者的关系: