作業呈上
- GitHub鏈接🔗點擊鏈接進入我的GitHub倉庫
- 點擊博客左上角的那只貓進入我的GitHub首頁
- 可運行的Jar包已發布至release
- 開發平台、環境、工具以及開發者日志等詳情在倉庫中的README.md中查看
- 博客寫的很潦草,我后續或許還會更新,代碼也是
計算模塊接口的設計與實現過程
整體流程
- 首先呢,我先用土的不能再土的方式展現我的設計思路及流程
WTF???什么是黑...
咱先不管這個黑魔法是什么,總之就是我的核心算法,我們應該先集中精力把不屬於算法的部分(也就是框架)先搞定,不是嗎?這樣才有精力專心來搞算法。
emm...這樣或許真的太大白話了,那我具體一點,把它換上我的類和具體實現方法,也順便展示出我代碼中各個類、方法的關系
(0)先展示一下我的類(沒有屬性,主要還是靜態方法,當工具用了)
- TextProcessor:文本處理器,輸入文本文件,轉字符串
- AlgorithmProcessor:算法處理器,集中用來寫算法
- AnswerProcessor:答案處理器,也是最大塊的,通過調用TextProcessor和AlgorithmProcessor中的方法,以及自己的一些私有靜態方法,集中處理各種字符串和集合,最后輸出
(1)輸入硬盤中的.txt → 內存中的String → 分詞后的List < String > 集合
(2)分詞后的List < String > 集合 → int結果 → 計算出查重率float → 輸出到硬盤中的.txt
我不知道我這一通狂說有沒有說清楚具體流程(除了算法),希望我的圖文能說明白,那我下面要說最關鍵的算法了
核心算法
我先亮方法了,我使用的算法主要是兩個的結合體:
- LCS,也就是基於動態規划的最長公共子串
- Tokenizer,是HanLP家的,用來做分詞操作
- Jaro-Whinkle距離,是一個計算文本相似度的算法,是Apache家的
嗯,沒錯,我聽從柯老板,我選擇了擁抱開源
我們按順序來說吧!
- 首先是LCS
LCS的基本實現思想是,取兩個字符串的字符一一比對,若兩者相同則令S [i] [j] = S [i] [j] + 1,否則S [i] [j] = max(S [i-1] [j] , S [i] [j-1]),這也是符合LCS的推導公式的。而得到最長相同子串的方法是通過數據結構的棧實現的,因為一般的實現會記錄字符的方向,而輸出字符串是要從S矩陣的右下角逆序輸出,因此很契合棧的結構,其規則是:若S1[i] = S2[j],將字符入棧,否則對比S [i] [j-1]和S [i-1] [j]的大小,若前者不大於后者則遍歷后者。(具體詳說可以參考這篇博文)
- 再來是HanLP家的Tokenizer,和jieba類似。
這個東西我不想說太多,因為這個不是算法關鍵所在,屬於核心處理前的預處理,分詞,大概呢就是基於字符串匹配、理解、統計、規則一系列的方式給他切好,我們這邊只是把它當工具來用,並不打算講太多。
- 最后就是Jaro-Whinkle距離,也是我們的核心所在,雖然它是我調用的工具包,但是它是核心的一部分!所以我會多說點!
我們先說定義:
Jaro Distance也是字符串相似性的一種度量方式,也是一種編輯距離,Jaro 距離越高本文相似性越高;而Jaro–Winkler distance是Jaro Distance的一個變種。其定義如下:
其中
- m是匹配數目(保證順序相同)
- |s|是字符串長度
- t是換位數目
其中t換位數目表示:兩個分別來自S1和S2的字符如果相距不超過
我們就認為這兩個字符串是匹配的;而這些相互匹配的字符則決定了換位的數目t,簡單來說就是不同順序的匹配字符的數目的一半即為換位的數目t,舉例來說,MARTHA與MARHTA的字符都是匹配的,但是這些匹配的字符中,T和H要換位才能把MARTHA變為MARHTA,那么T和H就是不同的順序的匹配字符,t=2/2=1。
而Jaro-Winkler則給予了起始部分就相同的字符串更高的分數,他定義了一個前綴p,給予兩個字符串,如果前綴部分有長度為 的部分相同,則Jaro-Winkler Distance為:
- dj是兩個字符串的Jaro Distance
- l是前綴的相同的長度,但是規定最大為4
- p則是調整分數的常數,規定不能超過0.25,不然可能出現dw大於1的情況,Winkler將這個常數定義為0.1
舉個簡單的例子:
計算的距離
我們利用可以得到一個匹配窗口距離為3,圖中黃色部分便是匹配窗口,其中1表示一個匹配,我們發現兩個X並沒有匹配,因為其超出了匹配窗口的距離3。我們可以得到:
其Jaro score為:
而計算Jaro–Winkler score,我們使用標准權重,其結果如下:
疑問
你可能會問,為什么這邊有兩個算法好像都是在算文本相似度的?不,我們可以把它們兩個結合,具體分析可以看下面的性能改進部分。我們把LCS判斷相等的條件,換成計算兩個詞的Jaro距離得分,按經驗分析,如果大於0.6,我們就認為它相似,執行原來LCS相等條件的部分。
小總結
我再總結一下吧,大概就是這樣:
- 讀進來,給他分詞,把標點抽掉,就留詞組
- 比較兩個詞組的集合,計算Jaro距離得分,大於0.6,認為他們相似,執行LCS的相等邏輯
- 計算結果,輸出去
計算模塊接口部分的性能改進
我們先談算法准確度,先不談性能嗷!
我們知道Jaro和LCS都是計算文本相似度一類的算法,那我們可以各單獨跑一遍看看,咱們先跑純LCS:
- 測試代碼
/**
* 測試全部樣例(純LCS)
*/
@Test
public void testForAllFilesOnlyLCS(){
File f = new File("tests");
String[] files = f.list();
int cnt = 1;
for(String file : files){
if(!file.equals("orig.txt")){
System.out.println("開始處理"+file);
AnswerProcessor.processJustByLCS("tests/orig.txt","tests/"+file,"result/ans"+cnt+".txt");
cnt++;
}
}
}
- 控制台輸出結果
開始處理orig_0.8_add.txt
查重率為:1.00
結果已寫入result/ans1.txt
開始處理orig_0.8_del.txt
查重率為:0.84
結果已寫入result/ans2.txt
開始處理orig_0.8_dis_1.txt
查重率為:0.97
結果已寫入result/ans3.txt
開始處理orig_0.8_dis_10.txt
查重率為:0.85
結果已寫入result/ans4.txt
開始處理orig_0.8_dis_15.txt
查重率為:0.70
結果已寫入result/ans5.txt
開始處理orig_0.8_dis_3.txt
查重率為:0.93
結果已寫入result/ans6.txt
開始處理orig_0.8_dis_7.txt
查重率為:0.90
結果已寫入result/ans7.txt
開始處理orig_0.8_mix.txt
查重率為:0.91
結果已寫入result/ans8.txt
開始處理orig_0.8_rep.txt
查重率為:0.83
結果已寫入result/ans9.txt
第一個抄襲文本是添加,因為其文本都是原文本存在的內容,結果竟然出來了1.00的結果,全抄??顯然不對,LCS的不足之處很快就體現出來了,它非常依賴原文本!我們再來試試純Jaro:
- 測試代碼
/**
* 測試全部樣例(純Jaro)
*/
@Test
public void testForAllFilesOnlyJaro(){
File f = new File("tests");
String[] files = f.list();
int cnt = 1;
for(String file : files){
if(!file.equals("orig.txt")){
System.out.println("開始處理"+file);
AnswerProcessor.processJustByJaro("tests/orig.txt","tests/"+file,"result/ans"+cnt+".txt");
cnt++;
}
}
}
- 控制台輸出結果
開始處理orig_0.8_add.txt
查重結果為:0.79
結果已寫入result/ans1.txt
開始處理orig_0.8_del.txt
查重結果為:0.78
結果已寫入result/ans2.txt
開始處理orig_0.8_dis_1.txt
查重結果為:0.99
結果已寫入result/ans3.txt
開始處理orig_0.8_dis_10.txt
查重結果為:0.97
結果已寫入result/ans4.txt
開始處理orig_0.8_dis_15.txt
查重結果為:0.95
結果已寫入result/ans5.txt
開始處理orig_0.8_dis_3.txt
查重結果為:0.99
結果已寫入result/ans6.txt
開始處理orig_0.8_dis_7.txt
查重結果為:0.98
結果已寫入result/ans7.txt
開始處理orig_0.8_mix.txt
查重結果為:0.82
結果已寫入result/ans8.txt
開始處理orig_0.8_rep.txt
查重結果為:0.74
結果已寫入result/ans9.txt
誒,這個看着還不錯啊,但中間這幾個文本...那幾個文本是調換順序,怎么說呢,文本內容其實沒變,就是換了一些詞的順序,但是非常接近全抄,其實這個算法這樣已經很好了,可我感覺總是差點意思,它不依賴原文本,它靠的是差異;如果文本內容調換順序,還能維持原來的意思嗎?不見得。在某些情況下,經過排列后,其實不算是抄,那是有技巧地推陳出新;還有第一個add的部分,讓我想起來小學時候寫作文,不會就嗯抄,然后自己添一點,就是自己的了,顯然不對,我認為這就是大抄!“天下文章一大抄”這話固然沒錯,但是好的抄文是懂得排列組合,而非單靠一篇文章推陳出新,基於此,我覺得add的抄襲比重還不夠,應該給予更多!
而且不知道你們有沒有發現,Jaro有個獎勵機制,就是它會獎勵前綴相同。正因如此,如果是長文本的話,也就是整篇文章直接來,那它至多只會被獎勵一次,就看文章開頭,這似乎不太准確;若是加上分詞將它切開,放如LCS中,就會獲得很多的獎勵,這樣豈不是更准確,拉開“貧富差距”!
綜上考慮,我改進的思路和想法就是:把兩者結合!!!結合兩者的優勢,中和掉二者的不足!
LCS+Jaro:
- 先看代碼
/**
* 基於最長公共子串算法,計算Jaro距離
* @param word_a org.txt文件
* @param word_b org_add.txt文件
* @return
*/
public static int JaroDisBasedOnLCS(List<String> word_a, List<String> word_b){
int[][] cell = new int[word_a.size()+1][word_b.size()+1];
int lena = word_a.size();
int lenb = word_b.size();
for(int i=0;i<lena;i++){
for(int j=0;j<lenb;j++){
if(StringUtils.getJaroWinklerDistance(word_a.get(i),word_b.get(j))>0.6){
cell[i+1][j+1] = cell[i][j] + 1;
}else{
cell[i+1][j+1] = Math.max(cell[i][j+1],cell[i+1][j]);
}
}
}
return cell[lena][lenb];
}
- 再來看看運行結果:
開始處理orig_0.8_add.txt
查重率為:0.98
結果已寫入result/ans1.txt
開始處理orig_0.8_del.txt
查重率為:0.79
結果已寫入result/ans2.txt
開始處理orig_0.8_dis_1.txt
查重率為:0.98
結果已寫入result/ans3.txt
開始處理orig_0.8_dis_10.txt
查重率為:0.85
結果已寫入result/ans4.txt
開始處理orig_0.8_dis_15.txt
查重率為:0.67
結果已寫入result/ans5.txt
開始處理orig_0.8_dis_3.txt
查重率為:0.94
結果已寫入result/ans6.txt
開始處理orig_0.8_dis_7.txt
查重率為:0.90
結果已寫入result/ans7.txt
開始處理orig_0.8_mix.txt
查重率為:0.90
結果已寫入result/ans8.txt
開始處理orig_0.8_rep.txt
查重率為:0.79
結果已寫入result/ans9.txt
蕪湖,起飛!✈️它已經很貼近我的心理預期了!
但這樣結合帶來的后果就是...跑得很慢...似乎不是性能改進啊這...
其實我唯一的性能改進的地方就是加了抽去標點符號,那使我速度快了40%,但是這似乎是步驟中必要的一步,也不算是改進啦,后來想起來加上去的,所以性能改進part的話,暫時是沒有解決的。
扯完皮了,該回答一下要求的問題了!
(接下來我以添加20%文本的那條為例)
性能分析圖展示
- 類的內存消耗
- CPU Load(運行時間:4.2s,滿足要求)
- 堆內存情況
程序中消耗最大的方法
毫無疑問,當然是Jaro啊,在LCS中,它作為每一步的判斷條件,不得累死,直接吃掉75%左右,最氣的是它沒辦法優化,因為是我調的別人的,嗚嗚嗚😢
記錄在改進計算模塊性能上所花費的時間
其實並不多,主要花時間在查找算法資料上,一開始我就是用的LCS,但發現了它的穩定性、效率、准確性,都不盡如人意,想到要結合,或許會好些;硬要算的話,maybe 2小時用來找Jaro了,找到就用了,測試結果雖然不算很好,但也不差
計算模塊部分單元測試展示
展示單元測試代碼
import org.junit.Test;
import java.io.File;
/**
* 測試
*/
public class TestCase {
/**
* 測試全部樣例(純Jaro)
*/
@Test
public void testForAllFilesOnlyJaro(){
File f = new File("tests");
String[] files = f.list();
int cnt = 1;
for(String file : files){
if(!file.equals("orig.txt")){
System.out.println("開始處理"+file);
AnswerProcessor.processJustByJaro("tests/orig.txt","tests/"+file,"result/ans"+cnt+".txt");
cnt++;
}
}
}
/**
* 測試全部樣例(純LCS)
*/
@Test
public void testForAllFilesOnlyLCS(){
File f = new File("tests");
String[] files = f.list();
int cnt = 1;
for(String file : files){
if(!file.equals("orig.txt")){
System.out.println("開始處理"+file);
AnswerProcessor.processJustByLCS("tests/orig.txt","tests/"+file,"result/ans"+cnt+".txt");
cnt++;
}
}
}
/**
* 測試全部樣例(Jaro+LCS)
*/
@Test
public void testAllFiles(){
File f = new File("tests");
String[] files = f.list();
int cnt = 1;
for(String file : files){
if(!file.equals("orig.txt")){
System.out.println("開始處理"+file);
AnswerProcessor.process("tests/orig.txt","tests/"+file,"result/ans"+cnt+".txt");
cnt++;
}
}
}
/**
* 測試20%文本添加情況:orig_0.8_add.txt
*/
@Test
public void testForAdd(){
AnswerProcessor.process("tests/orig.txt","tests/orig_0.8_add.txt","ans.txt");
}
/**
* 測試20%文本刪除情況:orig_0.8_del.txt
*/
@Test
public void testForDel(){
AnswerProcessor.process("tests/orig.txt","tests/orig_0.8_del.txt","ans.txt");
}
/**
* 測試20%文本亂序情況:orig_0.8_dis_1.txt
*/
@Test
public void testForDis1(){
AnswerProcessor.process("tests/orig.txt","tests/orig_0.8_dis_1.txt","ans.txt");
}
/**
* 測試20%文本亂序情況:orig_0.8_dis_3.txt
*/
@Test
public void testForDis3(){
AnswerProcessor.process("tests/orig.txt","tests/orig_0.8_dis_3.txt","ans.txt");
}
/**
* 測試20%文本亂序情況:orig_0.8_dis_7.txt
*/
@Test
public void testForDis7(){
AnswerProcessor.process("tests/orig.txt","tests/orig_0.8_dis_7.txt","ans.txt");
}
/**
* 測試20%文本亂序情況:orig_0.8_dis_10.txt
*/
@Test
public void testForDis10(){
AnswerProcessor.process("tests/orig.txt","tests/orig_0.8_dis_10.txt","ans.txt");
}
/**
* 測試20%文本亂序情況:orig_0.8_dis_15.txt
*/
@Test
public void testForDis15(){
AnswerProcessor.process("tests/orig.txt","tests/orig_0.8_dis_15.txt","ans.txt");
}
/**
* 測試20%文本格式錯亂情況:orig_0.8_mix.txt
*/
@Test
public void testForMix(){
AnswerProcessor.process("tests/orig.txt","tests/orig_0.8_mix.txt","ans.txt");
}
/**
* 測試20%文本錯別字情況:orig_0.8_rep.txt
*/
@Test
public void testForRep(){
AnswerProcessor.process("tests/orig.txt","tests/orig_0.8_rep.txt","ans.txt");
}
/**
* 測試相同文本:orig.txt
*/
@Test
public void testForSame(){
AnswerProcessor.process("tests/orig.txt","tests/orig.txt","ans.txt");
}
}
說明測試的方法,構造測試數據的思路
- 測試的方法就是分別跑LCS、Jaro、LCS+Jaro咯!就遍歷一下原文文本所在文件夾的所有文件,讀進來處理就行!其他就是分別對單個文件進行測試。為了防止出錯,我還加了個相同文件的,要是不為1就出事了!
- 至於測試數據,我覺得給的樣例就很好了,涵蓋了不同情況:添加、刪除、錯別字、打亂順序、格式錯亂等...
測試結果
測試覆蓋率截圖
評價一下自己的測試
- 沒有使用Assert斷言,不知是我還不太會用,還是確實用不上,感覺不需要啊我這個
- 沒有試驗大數據文本來跑,是個缺憾(我盲猜會非常非常慢)
計算模塊部分異常處理說明
我設計的異常
/**
* 非相同文本,查重率卻為1
*/
public class ResultEqualsOneException extends Exception{
public ResultEqualsOneException() {
}
public ResultEqualsOneException(String message) {
super(message);
}
@Override
public String getMessage() {
return super.getMessage();
}
}
// 具體使用
if(ans/standardLength==1.00 && !orgFileName.equals(orgAddFileName)){
try {
throw new ResultEqualsOneException("非相同文本,查重率怎么會為1呢?");
} catch (ResultEqualsOneException e) {
e.printStackTrace();
}
}
測試
我們試一下相同文本的情況,會不會報
再試一下LCS的add文本看看
和前文提到的1.00的結果一致
其實還可以寫一個與之對應的異常和測試方法,就是那兩個完全不同的文本來測是否為0;這個其實我有測過了,是對的,但我覺得意義真的不大,又刪去了,這樣真的不是一個好的異常和測試的設計。(空文本測試同理)
總結
- 接口的設計的話,我覺得這次其實看不出什么,因為只是個小小的小項目,寫的也基本上是工具類,體現不出設計模式一類的東西
- 性能改進方面真的很抱歉啊,沒辦法,擁抱開源的我,還把自己寫的垃圾LCS和Apache的庫結合了,結果就是淦慢,希望下次能自己設計算法吧,自己的東西才方便優化
- 單元測試的話,第一次使用,很遺憾沒能用上斷言(除了兩個完全相同和兩個完全不同的或許可以硬用上Assert的Equals,其他我真不知道怎么用,可能也和我的代碼有關系),希望下次有機會能用上,還有測試壓力部分,逃避了,下次盡量找大數據懟
- 異常處理部分,說實話,真的不知道能寫什么異常,絞盡腦汁,就想到一個,還是沒去Override的,直接就等於Exception改個名兒,希望下次能提升自身邏輯嚴謹性,考慮盡量周全(也可能是項目還不夠大吧)
PSP表格
PSP2.1 | Personal Software Process Stages | 預估耗時(分鍾) | 實際耗時(分鍾) |
---|---|---|---|
Planning | 計划 | 30 | 40 |
Estimate | 估計這個任務需要多少時間 | 20 | 25 |
Development | 開發 | 480 | 300 |
Analysis | 需求分析 (包括學習新技術) | 300 | 180 |
Design Spec | 生成設計文檔 | 60 | 20 |
Design Review | 設計復審 | 30 | 15 |
Coding Standard | 代碼規范 (為目前的開發制定合適的規范) | 30 | 10 |
Design | 具體設計 | 60 | 60 |
Coding | 具體編碼 | 390 | 210 |
Code Review | 代碼復審 | 30 | 30 |
Test | 測試(自我測試,修改代碼,提交修改) | 180 | 120 |
Reporting | 報告 | 90 | 120 |
Test Repor | 測試報告 | 60 | 100 |
Size Measurement | 計算工作量 | 20 | 15 |
Postmortem & Process Improvement Plan | 事后總結, 並提出過程改進計划 | 60 | 30 |
Total | 合計 | 1840 | 1275 |