軟工實踐|結對第二次—文獻摘要熱詞統計及進階需求

班級:軟件工程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. 因爲對數據在圖上的分佈位置沒有合理規劃,可能會致使部分點重疊或沒法看清(仍是由於數據未清洗)。
相關文章
相關標籤/搜索