分詞是自然語言處理中最基本的一個任務,這篇小文章不介紹相關的理論,而是介紹一個電子病歷分詞的小實踐。
開源的分詞工具中,我用過的有jieba、hnlp和stanfordnlp,感覺jieba無論安裝和使用都比較便捷,拓展性也比較好。是不是直接調用開源的分詞工具,就可以得到比較好的分詞效果呢?答案當然是否定的。尤其是在專業性較強的領域,比如醫療行業,往往需要通過加載相關領域的字典、自定義字典和正則表達式匹配等方式,才能得到較好的分詞效果。
這次我就通過一個電子病歷分詞的小實踐,分析在具體的分詞任務中會踩哪些坑,然后如何運用jieba的動態加載字典功能和正則表達式,得到更好的分詞結果。
代碼和數據可以去我的Github主頁下載:https://github.com/DengYangyong/medical_segment
一、原始文本數據
原始文本是100篇txt格式的電子病歷文檔,其中一篇文檔的內容是這樣的:
醫療文本中有一些文言文,比如神志清、神清、情況可等,對分詞任務造成一定的困難。
二、使用jieba直接分詞
首先使用jieba直接對100篇電子病歷進行分詞(jieba的基本使用可以看它的github頁面:https://github.com/fxsjy/jieba )。在jieba.cut()中,選擇精准模式(cut_all=False),同時不使用HMM模型進行分詞,因為我在嘗試使用HMM模式時,切出了一些沒見過也不合理的新詞。
1 #-*- coding=utf8 -*- 2 import jieba,os 3 #定義一個分詞的函數 4 def word_seg(sentence): 5 #使用jieba進行分詞,選擇精准模式,關閉HMM模式,返回一個list:['患者','精神',...] 6 str_list = list(jieba.cut(sentence, cut_all=False, HMM=False)) 7 result = " ".join(str_list) 8 return result 9 10 if __name__=="__main__": 11 # 100篇電子病歷文檔所在的目錄 12 c_root = os.getcwd()+os.sep+"source_data"+os.sep 13 fout = open("cut_direct.txt","w",encoding="utf8") 14 15 for file in os.listdir(path=c_root): 16 if "txtoriginal.txt" in file: 17 fp = open(c_root+file,"r",encoding="utf8") 18 for line in fp.readlines(): 19 if line.strip() : 20 result = word_seg(line) 21 fout.write(result+'\n') 22 fp.close() 23 24 fout.close()
下面是其中兩篇電子病歷的分詞結果,可以看到,有很多醫療行業的詞語沒有切分正確,比如“雙肺呼吸音清”、“濕性羅音”、“尿管”、“胃腸型及蠕動波” 、“反跳痛”等,所以下一步加載醫療行業的字典,希望能改善這個情況。
三、加載行業專屬字典
最近在看一個電子病歷命名實體識別項目的代碼,這個項目使用加載字典的方式進行命名實體識別,里面有一個字典,恰好可以用在這里做分詞。
這個字典是一個csv文件,有17713個醫療行業的命名實體,主要包括疾病名稱、診斷方法、症狀、治療措施這幾類,字典里每一行有兩個元素,一個是命名實體(如腎性高血壓);第二個是相應的符號標注(如DIS,即disease),類似於詞性標注。
1 import csv 2 dic = csv.reader(open("DICT_NOW.csv","r",encoding='utf8')) 3 dic_list = list(dic) 4 5 # 打印出字典的前五行內容 6 print("字典的內容是這樣的:\n\n",a[:5]) 7 print("\n字典的長度為:",len(dic_list))
字典的內容是這樣的: [['腎抗針', 'DRU'], ['腎囊腫', 'DIS'], ['腎區', 'REG'], ['腎上腺皮質功能減退症', 'DIS'], ['腎性高血壓', 'DIS']] 字典的長度為: 17713
然后把這個字典加載到jieba里:
1 #-*- coding=utf8 -*- 2 import jieba,os,csv 3 4 def add_dict(): 5 dic = csv.reader(open("DICT_NOW.csv","r",encoding='utf8')) 6 # row: ['腎抗針', 'DRU'] 7 for row in dic: 8 if len(row) ==2: 9 #把字典加進去 10 jieba.add_word(row[0].strip(),tag=row[1].strip()) 11 #需要調整詞頻,確保它的詞頻足夠高,能夠被分出來。 12 #比如雙腎區,如果在jiaba原有的字典中,雙腎的頻率是400,區的頻率是500,而雙腎區的頻率是100,那么即使加入字典,也會被分成“雙腎/區” 13 jieba.suggest_freq(row[0].strip(),tune=True) 14 15 def word_seg(sentence): 16 17 str_list = list(jieba.cut(sentence, cut_all=False, HMM=False)) 18 result = " ".join(str_list) 19 return result 20 21 if __name__=="__main__": 22 23 add_dict() 24 c_root = os.getcwd()+os.sep+"source_data"+os.sep 25 fout = open("cut_dict.txt","w",encoding="utf8") 26 27 for file in os.listdir(path=c_root): 28 if "txtoriginal.txt" in file: 29 fp = open(c_root+file,"r",encoding="utf8") 30 for line in fp.readlines(): 32 if line.strip(): 33 result = word_seg(line) 34 fout.write(result+'\n') 35 fp.close() 36 37 fout.close()
分詞完畢后,再看看前兩篇病歷的分詞結果。可以看到,“雙肺呼吸音清”,“濕性羅音”分詞正確,可是還有一大堆詞沒有切分正確:尿管、胃腸型及蠕動波、反跳痛、肌緊張、皮牽引等等。可見一方面這個字典太小,只有17713個醫療詞語,另一方面這是命名實體的字典,傾向於識別疾病名詞、症狀、檢查手段和治療措施這類命名實體,一般的身體部位、醫療用具並沒有包含在內。
當然,盡管從這兩篇病歷來看,加載命名實體識別字典的效果不是太明顯,但是查看100篇病歷的分詞結果,效果還是有較大的提升。既然命名實體識別的字典太小,那沒辦法,對於一些在病歷中經常出現的詞,可以通過自定義字典的方式進行正確分詞。
四、自定義字典、正則表達式匹配和停用詞
自定義字典就是把腸鳴音、皮牽引、胃腸型及蠕動波等沒有正確切分的詞語匯總,做成一個文件,然后加載到jieba當中去。代碼實現起來比較簡單,打開一個txt文件,每一行放一個詞,然后保存文件即可,麻煩在於要手動挑選出這些專業詞匯。100篇病歷太多了,我簡單做個嘗試,從前10篇病歷中大致找了些沒有被正確切分的詞做成字典,有下面這些:
聽診區 胃腸型及蠕動波 膀胱區 干濕性羅音 移動性濁音 皮牽引 反跳痛 肌緊張 腸鳴音 肋脊角 雙腎區 查體 二便 痂皮 律齊 聽診區 發紺 濕羅音 外口
膝腱反射 巴彬斯基氏征 克尼格氏征 氣過水聲
我在查看100篇病歷的分詞結果時,發現其實除了這些比較規范的詞外,還有一些與數字相關的詞沒有被正確切分(當然這也帶有一定主觀性,我有些強迫症),比如小數被 “.” 號分開,“%” 號與數字分開,* ^ 與數字分開。所以我想構造正則表達式,把正確的詞匹配出來,然后作為字典加載到jieba中去。下面是一些我認為沒有切分正確的詞:
心率 62 次 / 分 ;右 下肢 直 腿 抬高 試驗 陽性 50 度; 右手 拇指 可見 一 約 1 * 1cm 皮膚 挫傷 ;血常規 : 紅細胞 6 . 05 * 10 ^ 12 / L , 血紅蛋白 180 . 00g / L;
尿蛋白 + 2 紅細胞 + 2;查 體 : T : 36 . 8 ℃ , 精神 可;中性 細胞 比率 77 . 6 % , 淋巴細胞 比率 19 . 6 % , 中 值 細胞 比率 2 . 8 % 。
正則表達式的代碼如下,使用python的re模塊。
先看p1,r'\d+[次度]'用來匹配“80次”、“50度” 這兩種表示心率、溫度的常見的詞,\d+表示匹配一個或多個數字,[次度]表示數字后的字是次或者度。
然后看p2,用來匹配6.05,10^12,77.6%這類的數據,[a-zA-Z0-9+]+ 表示字母數字或者+號,然后匹配一次或多次。[\.^]* 中,表示匹配.或^號0次或1次,\是轉義符號,因為后面的.本身就是正則表達式中的特殊字符,而在這里是表示小數點。[A-Za-z0-9%(℃)]+含義比較明確,然后(?![次度])是負前向查找,當后面的字不是次或度時就匹配,避免去匹配p1應該匹配的內容。當然其實這個表達式有很多問題,但是暫時想不出其他的了。
1 p1 = re.compile(r'\d+[次度]').findall(line) 2 p2 = re.compile(r'([a-zA-Z0-9+]+[\.^]*[A-Za-z0-9%(℃)]+(?![次度]))').findall(line)
所有匹配到的詞如下所示,正則表達式剛入門還是小弱,費了老大的勁,還是匹配到了很多不想要的東西。先這樣,看到的人歡迎拍磚。
" ".join([word.strip()for word in open("regex_dict.txt",'r',encoding='utf8').readlines()])
'80次 4次 62次 1cm 78次 BP130 80mmhg 80次 4次 4700ml CT 80次 4次 6.05 10^12 180.00g 0.550L 6.4fL +2 +2 25 38 HP 62.0U 100.00U 50度 76次 36.8C 84次 20次 74次 4次 3000ml 36.8C Fr20 3次
36.8℃ 84次 4次 140 90mmHg 4次 3cm 4X3cm CT 64次 4次 CT 90次 3mm 76次 4次 2.0cm 3.0 2.5cm 1.5 1.0cm 4次 36.4℃ 64次 3次 98次 300ml 12cm 74次 36.3C 80次 18次 80次 4次 37.0C 4次 12cm
76次 4次 Murphy CT 7.40 10^9 77.6% 19.6% 2.8% 3.49 10^12 99.00g 0.302L 78次 18次 36.5℃ BP130 80 mmHg 36.4℃ 80次 4次 4次 37℃ 82次 22次 82次 4次 3250ml 37℃ BP 149 97mmHg 100% 6cm
10cm 15cm 5cm 3cm 3cm 1.5 8cm 92次 Bp129 75mmHg 4次 72 4次 80次 4次 76次 4次 12 42 12 42 12cm 79次 37.0C 80次 4次 70次 4次 3次 72次 5度 100度 5度 105度 10 10 75次 18次 72次 T36.4℃
P7 R1 BP130 85mmhg'
第三步是去掉以標點符號為主的停用詞(停用詞是諸如“的、地、得”這些沒有實際含義的詞以及標的符號特殊符號等)。我嘗試使用從網上下載的中文停用詞表,挺齊全,有2000多個停用詞,可是使用后發現結果不好,因為它會把 類似“無”,“可”這種詞去掉,於是“無頭暈”只剩下了“頭暈”,“精神可”只剩下了“精神”,要么意思反了,要么不知所雲。
所以我自己弄了個簡單的停用詞表,只去掉 “ ,。!?:、)( ” 這些符號。
完整的代碼如下:
1 #-*- coding=utf8 -*- 2 import jieba,os,csv,re 3 import jieba.posseg as pseg 4 5 def add_dict(): 6 # 導入自定義字典,這是在檢查分詞結果后自己創建的字典 7 jieba.load_userdict("userdict.txt") 8 dict1 = open("userdict.txt","r",encoding='utf8') 9 #需要調整自定義詞的詞頻,確保它的詞頻足夠高,能夠被分出來。 10 #比如雙腎區,如果在jiaba原有的字典中,雙腎的頻率是400,區的頻率是500,而雙腎區的頻率是100,那么即使加入字典,也會被分成“雙腎/區” 11 [jieba.suggest_freq(line.strip(), tune=True) for line in dict1] 12 13 #加載命名實體識別字典 14 dic2 = csv.reader(open("DICT_NOW.csv","r",encoding='utf8')) 15 for row in dic2: 16 if len(row) ==2: 17 jieba.add_word(row[0].strip(),tag=row[1].strip()) 18 jieba.suggest_freq(row[0].strip(),tune=True) 19 20 # 用正則表達式匹配到的詞,作為字典 21 fout_regex = open('regex_dict.txt','w',encoding='utf8') 22 for file in os.listdir(path=c_root): 23 if "txtoriginal.txt" in file: 24 fp = open(c_root+file,"r",encoding="utf8") 25 for line in fp.readlines(): 26 if line.strip() : 27 #正則表達式匹配 28 p1 = re.compile(r'\d+[次度]').findall(line) 29 p2 = re.compile(r'([a-zA-Z0-9+]+[\.^]*[A-Za-z0-9%(℃)]+(?![次度]))').findall(line) 30 p_merge = p1+p2 31 for word in p_merge: 32 jieba.add_word(word.strip()) 33 jieba.suggest_freq(word.strip(),tune=True) 34 fout_regex.write(word+'\n')
35 fp.close() 36 fout_regex.close() 36 37 # 用停用詞表過濾掉停用詞 38 def stop_words(): 39 # "ChineseStopWords.txt"是非常全的停用詞表,然后效果不好。 40 #stopwords = [word.strip() for word in open("ChineseStopWords.txt","r",encoding='utf-8').readlines()] 41 stopwords = [word.strip() for word in open("stop_words.txt","r",encoding='utf-8').readlines()] 42 return stopwords 43 44 # 進行分詞 45 def word_seg(sentence): 46 47 str_list = list(jieba.cut(sentence, cut_all=False, HMM=False)) 48 str_list = [word.strip() for word in str_list if word not in stopwords] 49 result = " ".join(str_list) 50 return result 51 52 if __name__=="__main__": 53 54 add_dict() 55 stopwords = stop_words() 56 c_root = os.getcwd()+os.sep+"source_data"+os.sep 57 fout = open("cut_stopwords.txt","w",encoding="utf8") 58 59 for file in os.listdir(path=c_root): 60 if "txtoriginal.txt" in file: 61 fp = open(c_root+file,"r",encoding="utf8") 62 for line in fp.readlines(): 63 if line.strip() : 64 result = word_seg(line) 65 fout.write(result+'\n\n') 66 fp.close() 67 68 fout.close()
部分分詞結果如下,可以看到,像80次、6.05,+2這種格式的數據分詞正確,但是我發現盡管能已經匹配到了1*1,10^12,36.4℃這些詞,但是加載進去后用jieba分詞還是不能正確划分,看來在jieba中,* ^ / 這些符號是必須要被切開的。
五、小結
這一個簡單的電子病歷分詞實踐到此告一段落,說說兩點體會吧。
一是分詞有基於字典的方法,基於規則的方法和基於機器學習的方法,盡管機器學習的方法聽起來高大上,但可能會切出一些沒見過的詞,而基於字典的方法看上去比較土,其實更好用,准確性更高。
二是醫療行業的自然語言處理非常不好做,這些電子病歷、醫療文獻是英文、現代文和文言文的混合體,醫生寫病歷、處方有時喜歡文言體(簡潔省事),各種疾病、治療手段的專業術語又非常多,每種疾病又有很多主流和非主流的名稱,給分詞、實體識別這些任務造成了很大麻煩。