软工实践|结对第二次—文献摘要热词统计及进阶需求

班级:软件工程1916|W
做业:结对第二次—文献摘要热词统计及进阶需求
结对学号:221600418 黄少勇221600420 黄种鑫
课程目标:学会使用Git、提升团队协做能力
Github地址:基础需求进阶需求
分工:javascript

  • 黄少勇--词频统计代码实现,性能优化
  • 黄种鑫--爬虫代码实现,可视化实现,单元测试

目录

Github 签入记录

基本需求:

进阶需求:
html

PSP

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

遇到的困难及解决方法

困难描述

  1. 对于正则表达式不够熟练,使用起来不够顺手
  2. 对于爬虫框架不熟,爬虫代码可能较为啰嗦

解决尝试

查阅网上博客及相关教程

是否解决

正则相关已解决,爬虫框架因为时间问题没有深刻了解

收获

对于正则表达式,有了进一步的认识,同时对于爬虫相关的东西也有了初步的印象,后续有时间可多多接触下。

评价你的队友

个人队友很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>

效果图以下:
因为做者间关系过于复杂,全部数据生成的图会过密。

筛选部分可得下图:

鼠标移动到某个点上可查看这个做者的论文数及与其余做者的关系:

不足

  1. 因为没有对原始数据进行清洗,获得的图过于复杂,可能不方便直观的看出做者关系。
  2. 因为对数据在图上的分布位置没有合理规划,可能会致使部分点重叠或没法看清(仍是由于数据未清洗)。
相关文章
相关标签/搜索