https://github.com/zsiothsu/FZU2021SE
一、PSP表格
PSP2.1 | Personal Software Process Stages | 預估耗時(分鍾) | 實際耗時(分鍾) |
---|---|---|---|
Planning | 計划 | ||
· Estimate | · 估計這個任務需要多少時間 | 5 | 5 |
Development | 開發 | ||
· Analysis | · 需求分析 (包括學習新技術) | 540 | 120 |
· Design Spec | · 生成設計文檔 | 30 | 30 |
· Design Review | · 設計復審 | 60 | 20 |
· Coding Standard | · 代碼規范 (為目前的開發制定合適的規范) | 30 | 5 |
· Design | · 具體設計 | 190 | 540 |
· Coding | · 具體編碼 | 360 | 180 |
· Code Review | · 代碼復審 | 60 | 30 |
· Test | · 測試(自我測試,修改代碼,提交修改) | 360 | 60 |
Reporting | 報告 | ||
· Test Repor | · 測試報告 | 10 | 5 |
· Size Measurement | · 計算工作量 | 10 | 5 |
· Postmortem & Process Improvement Plan | · 事后總結, 並提出過程改進計划 | 30 | 30 |
· 合計 | 1685 | 1030 |
二、計算模塊接口
2.1 計算模塊接口的設計與實現過程
2.1.1 概述
本項目實現了一個敏感詞過濾算法。對於識別詞匯,自然而然能想到的是使用字典樹或AC自動機進行分類,而單詞的各種混淆,使用枚舉算法可以實現。
而計算機識別漢字是比較困難的事,所以本人參考了一下機器學習中識別英文垃圾郵件的算法,利用詞庫把單詞映射為唯一的數字。那么類比一下,先把漢字轉為“英文單詞”,即拼音,然后再映射為唯一數字,這樣也直接解決了諧音字和拼音縮寫的問題
而漢字拆解,可以直接使用現成的字庫,利用一個 map 儲存漢字及對應的組成部分,編碼可以直接將漢字映射為數字,而不經過拼音。
而怎樣統一拼音編碼和字形編碼,將在編碼設計的部分細說
2.1.2 類設計
依照以上思路,就可以把項目分成兩個類:
Filter
類作為整個項目的主干,用於構建字典樹和進行查找Word
類用於實現混淆算法,是模糊識別的關鍵
Word 類
word
類只有一個 confuse
方法,用於生成單詞的變形,如“你好”這個單詞,可以混淆為
-
純拼音編碼
['ni', 'hao']
['n', 'i', 'hao']
['n', 'hao']
['ni', 'h', 'a', 'o']
['n', 'i', 'h', 'a', 'o']
['n', 'h', 'a', 'o']
['ni', 'h']
['n', 'i', 'h']
['n', 'h'] -
純字形編碼
['亻', '爾', '女', '子']
-
混合編碼
['亻', '爾', 'hao']
['亻', '爾', 'h', 'a', 'o']
['亻', '爾', 'h']
['ni', '女', '子']
['n', 'i', '女', '子']
['n', '女', '子']
Filter 類
Filter
類作為項目的主干需要實現以下功能
-
read_sensitive_words()
讀入敏感詞 -
build_sensitive_word_tree()
構建敏感詞字典 -
filter()
第一次過濾:清除與識別無關的字符,如標點符號,空格等 -
filter_line()
第二次過濾:利用字典識別敏感詞 -
logger()
和output()
輸出和統計結果
2.1.3 編碼設計
項目的編碼是識別的基礎,不同於純拉丁字母,漢字可以表示成“漢字“,”拼音“,”偏旁“,甚至把漢字當音節,如 ”漢字“ 的 ”漢“/xan/ 字也可以寫成 ”喝暗“/xɤan/ ,當然這種極端例子不在本文的討論范圍內。所以這部分做的越精細,識別的類型也會越多(但也可能出現誤報)。
題外話:更有甚者可以用典,其實英文也可以
項目采用兩種編碼,一個漢字可能會同時擁有以下兩個流程,這邊以‘你’為例:
-
拼音編碼
你 --> ['ni'] -> [56]
-
字形編碼
你 --> [‘亻’ , ‘爾’] --> [439, 440]
英文(直接使用漢字全拼也會被當英文處理)采用直接拆分映射
ni --> ['n', 'i'] --> [14, 9]
編碼后可以直接輸入字典樹

但識別就沒那么容易了,傳統字典樹無法處理多匹配的情況,如 '亻' 可以走向 ‘ren’(322) 分支,也可以走向 ‘亻’(439) 分支,如果敏感詞是 [‘你好’, ‘人們’]
輸入是 [' 亻', '爾', '好']
,那將直接導致無法識別。所以這里引入失配指針的概念,將字典樹升級為AC自動機
2.1.4 關鍵算法
AC自動機
其實嚴格來講這里用的不是AC自動機,只是在改進字典樹的時候借用了AC自動機的失配指針思想,所以實現上是AC的一個簡化版。
總的來說失配指針只需要實現以下需求:當拼音編碼走不通的時候,回憶起剛剛還有一個字形碼的分支,然后改到字形碼的分支,且自動機回到分支之前的狀態再嘗試匹配。
所以失配指針最小需要儲存以下狀態:
- 被分支的字符在原始句子中的位置
- 另一個分支的指針
- 目前識別了一半的單詞的起始位置
因為本項目具體實現的自動機在失配后會立刻忘記剛剛在識別哪個單詞,所以加上了第三點,但如果設計的好是不用這點的。
具體來說的話,指針長這個樣子:

然后就可以讓自動機自動運行了:

2.2 計算模塊接口部分的性能改進
性能測試 按總時間排序:
性能測試 按函數實際運行時間排序:
可以看到,最耗時的其實是我采用的第三方拼音庫,點開源代碼發現拼音庫的初始化就耗費了總時長的10%,當然因為是常數時間,所以不是太大的問題,而且目前pypinyin
是我能找到的字庫最全面的庫,所以也暫時沒有替代方案。
就我自己的程序而言,耗時最大的是AC自動機的查找函數,但其實已經足夠精簡,每個分支執行的代碼都是固定的幾行,所以在性能分析表上也沒有顯得特別突出。(如果有改進辦法的話歡迎討論):
def filter_line(self, sentence):
""" filter a single line
filter a single line. cannot detect sensitive words in
two different lines at the same time
:arg
sentence[string]: text to be detected
:return -> set
the starting index of the answer
"""
current = self.sensitive_dict
word_begin_index = 0
# a set storing answer for unit test
ans_set = set()
# fail pointer:
# fail_pointer_stack(position, dict of glyph code branch, curren word_begin_index)
# When the Chinese character has both pinyin code and glyph code,
# pinyin code is preferred for matching.When it cannot be matched,
# the fail pointer is used to switch to the branch of glyph code.
fail_pointer_stack: (int, dict, int) = []
i = 0
while i < len(sentence):
c = sentence[i]
if c == '*':
i = i + 1
continue
# is a breakable hanzi
if c in glyph_code_map:
pinyin_code = pinyin_alpha_map[lazy_pinyin(c)[0]]
glyph_code = glyph_code_map[c]
is_pinyin_code_in_current = pinyin_code in current
is_glyph_code_in_current = glyph_code in current
# if not matched, try to return dict to root
if (not is_pinyin_code_in_current) and (not is_glyph_code_in_current):
current = self.sensitive_dict
word_begin_index = 0
if is_pinyin_code_in_current:
# append fail pointer for glyph code branch
if is_glyph_code_in_current:
fail_pointer_stack.append((i + 1, current[glyph_code], word_begin_index))
if current == self.sensitive_dict:
word_begin_index = i
current = current[pinyin_code]
if current['end']:
self.logger(word_begin_index, i + 1, current['word'])
ans_set.add(word_begin_index)
elif is_glyph_code_in_current:
if current == self.sensitive_dict:
word_begin_index = i
current = current[glyph_code]
if current['end']:
self.logger(word_begin_index, i + 1, current['word'])
ans_set.add(word_begin_index)
# failed to match
else:
# switch to last glyph code branch
if len(fail_pointer_stack) != 0:
i = fail_pointer_stack[-1][0]
current = fail_pointer_stack[-1][1]
word_begin_index = fail_pointer_stack[-1][2]
fail_pointer_stack.pop()
continue
else:
current = self.sensitive_dict
word_begin_index = 0
# is a unbreakable hanzi or a latin letter
else:
pinyin_code = pinyin_alpha_map[lazy_pinyin(c)[0]]
if pinyin_code not in current:
current = self.sensitive_dict
word_begin_index = 0
if pinyin_code in current:
if current == self.sensitive_dict:
word_begin_index = i
current = current[pinyin_code]
if current['end']:
self.logger(word_begin_index, i + 1, current['word'])
ans_set.add(word_begin_index)
i = i + 1
continue
# switch to last glyph code branch
if len(fail_pointer_stack) != 0:
i = (fail_pointer_stack[-1])[0]
current = (fail_pointer_stack[-1])[1]
word_begin_index = fail_pointer_stack[-1][2]
fail_pointer_stack.pop()
else:
current = self.sensitive_dict
word_begin_index = 0
i += 1
return ans_set
2.3 計算模塊部分單元測試展示
主要測試 Filter.filter_line()
和 Word.confuse()
兩個核心函數,用例根據代碼的各個if
分支構造(但最后發現有的分支基本不會進入,懷疑為冗余代碼)。
部分測試函數展示:
class MyTestCase(unittest.TestCase):
def test_filter_line_2(self):
main.init_pinyin_alpha_map()
f = Filter()
f.read_sensitive_words(file_words)
org = "nihao世界"
ans_set = {0}
org = re.sub(u'([^\u3400-\u4db5\u4e00-\u9fa5a-zA-Z])', '*', org)
org = org.lower()
ans = f.filter_line(org)
self.assertEqual(ans_set, ans)
def test_filter_line_3(self):
main.init_pinyin_alpha_map()
f = Filter()
f.read_sensitive_words(file_words)
org = "泥濠世界"
ans_set = {0}
org = re.sub(u'([^\u3400-\u4db5\u4e00-\u9fa5a-zA-Z])', '*', org)
org = org.lower()
ans = f.filter_line(org)
self.assertEqual(ans_set, ans)
def test_filter_line_4(self):
main.init_pinyin_alpha_map()
f = Filter()
f.read_sensitive_words(file_words)
org = "泥 h&*%^&世界he^&l^(&lo"
ans_set = {0, 11}
org = re.sub(u'([^\u3400-\u4db5\u4e00-\u9fa5a-zA-Z])', '*', org)
org = org.lower()
ans = f.filter_line(org)
self.assertEqual(ans_set, ans)
def test_confuse_1(self):
s = "你好"
w = Word(s)
ans = [
['ni', 'hao'],
['n', 'i', 'hao'],
['n', 'hao'],
['ni', 'h', 'a', 'o'],
['n', 'i', 'h', 'a', 'o'],
['n', 'h', 'a', 'o'],
['ni', 'h'],
['n', 'i', 'h'],
['n', 'h'],
['亻', '爾', '女', '子'],
['亻', '爾', 'hao'],
['亻', '爾', 'h', 'a', 'o'],
['亻', '爾', 'h'],
['ni', '女', '子'],
['n', 'i', '女', '子'],
['n', '女', '子']
]
result = w.confuse()
flag = True
for i in ans:
if i not in result:
flag = False
break
self.assertEqual(flag, True)
單元測試覆蓋率:

2.4 計算模塊部分異常處理說明
IOError: 當輸入文件不存在或沒有權限時拋出
單元測試代碼:
def test_file_read(self):
main.clear_status()
main.init_pinyin_alpha_map()
f = Filter()
try:
f.read_sensitive_words("./nofile.txt")
except IOError:
self.assertTrue(True)
else:
self.assertTrue(False)
三、心得
要說心得最多的時候,大概是看到自己檢測出的敏感詞比老師給的答案多吧(
左邊是我的程序,右邊是標答

我一開始覺的不可能檢測出來的東西也檢測出來了


這是一次很難得的大作業的機會,知道了如何進行單元測試,(之前一直以為單元測試要對每個函數都測試一遍。。。) 和進行性能分析。
PSP表格還是難以使用,因為題目沒寫出來的話,就算是睡覺也要想解決方法。
詞雲在補了