- Introduce
- Clean Data
- Text Process
- Pipeline
- Word Embedding
- Classification
- Result
- Discussion
- Reference
在期中以前教利用各式演算法依據資料特性做分群或分類。 那我想利用大量的已標記文本,同樣做出分類效果。
利用 Sklearn 完成能依字詞分類的分類器,將醫療問題分類為內科與外科中的子科目。
資料集選用了使用github上的簡體中文資料集
Dataset from Toyhom Chinese-medical-dialogue-data
repo: https://github.com/Toyhom/Chinese-medical-dialogue-data
裡面總共有五種科別,對應到每一個 csv,分別為內科、婦產科、腫瘤科、兒科、外科。內容為醫療相關的問題與其回答,欄位有以下
department | title | ask | answer |
---|---|---|---|
科別名稱 | 標題 | 問題 | 回答 |
例子
department | title | ask | answer |
---|---|---|---|
心血管科 | 高血压患者能吃党参吗? | 我有高血压这两天女婿来的时候给我拿了些党参泡水喝,您好高血压可以吃党参吗? | 高血压病人可以口服党参的。党参有降血脂,降血压的作用,可以彻底消除血液中的垃圾,从而对冠心病以及心血管疾病的患者都有一定的稳定预防工作作用,因此平时口服党参能远离三高的危害。另外党参除了益气养血,降低中枢神经作用,调整消化系统功能,健脾补肺的功能。感谢您的进行咨询,期望我的解释对你有所帮助。 |
心血管科 | 高血压该治疗什么? | 我是一位中学教师,平时身体健康,最近学校组织健康检查,结果发觉我是高血压,去年还没有这种情况,我很担心,这边我主要想进行咨询一下高血压应当怎样治疗?麻烦医生指导一下,谢谢。 | 高血压患者首先要注意控制食盐摄入量,每天不超过六克,注意不要吃太油腻的食物,多吃新鲜的绿色蔬菜水果,多吃有机食物,注意增强体育锻炼,增加身体素质,同时压力不要过大,精神不要紧张,效果不佳的话,可以积极配合以降压药物控制血压治疗,情绪平时保持平和,不易激动。 |
既然已有標記所有的科別名稱,那就讓文句以科別分類,在這次專案內的資料選擇上僅挑了內科與外科 csv 和其子類別。 另外提醒,這篇資料集是簡體字用的gb2312編碼,在讀取時須注意。
在這資料集中有非常多地方多了換行等等不符合 csv 格式的問題,通常是發生在 answer
的欄位中有出現標號格式的地方,如下圖
查了一下,沒有看到能解決類似情況的工具使用,所以我依資料特性採取了字串暴力法將這些多出來的換行去除。
如果在前七個字內,沒有出現「科」字跟「,」符號,代表開頭內沒有 department
欄位,不應該是一筆新的資料,而是上一筆多了換行,如此一來,便能將大部分的換行去除。
但有些 department
欄位不是以「科」結尾的,如「肝腸」。沒想到更聰明的方法(qq),單純以窮舉的方式將所有例外做區別,也能達到想要的效果。
dep_lst=['Surgical', 'IM']
df = pd.DataFrame
col_lst= ['精神疾病', '计划生育', '体检', '减肥', '生活疾病', '结核病', '美容', '复杂先心病', '精神心理', '传染病', '健身', '动脉导管未闭', '皮肤顽症', '肛肠', '科']
for csv_path in glob.glob(os.path.join(data_dir, '*', '*.csv'), recursive=True):
isStart = True
last_para = ''
csv_name = csv_path.split('\\')[-2]
print(csv_name)
if csv_name in dep_lst:
with open(csv_path, 'r', encoding='gb2312', errors='ignore') as ori_file:
with open(os.path.join(saving_dir, csv_name + '.csv'), 'w+') as cln_file:
# 依行分隔
for para in ori_file:
is_col= False
# 取出前七個字, maximum_size_of_dep=7
front = para[:maximum_size_of_dep if len(para) > maximum_size_of_dep else len(para)]
# 前七個字是否有科別名稱
for col in col_lst:
if col in front:
is_col= True
if is_col and ',' in front:
# 為了讓第一行也能拿到用的
if isStart:
last_para = para
isStart = False
continue
# 如果有科別名稱的話,把前面那段寫進text,並開始新的一行
cln_file.write(last_para + '\n')
last_para = para
else:
# 若不是,則把這次的內容加到上一段內
last_para = last_para + para
在預設上,也會存為習慣的utf-8編碼
內科跟外科有些 department
有相同的情況發生,為了讓兩個 dataframe 接在一起也能有區別,加了一個欄位 cat_dep
,是內科或外加+department。例如內科 csv 內的风湿免疫科,則 cat_dep= 內科风湿免疫科
。
df_i = pd.read_csv(I_DATA_PATH, encoding=ENCODING)
df_s = pd.read_csv(S_DATA_PATH, encoding=ENCODING)
df_i.insert(0, 'category', '內科')
df_s.insert(0, 'category', '外科')
df = pd.concat([df_i, df_s], ignore_index=True)
df.insert(0, 'cat_dep', df['category']+df['department'])
原本內科跟外科不同的科別加起來總共有69種,在總共67萬多筆的資料中,有些僅出現一兩次。篩選掉出現次數小於1000的資料。最後剩下20種。以下是過濾後的結果
cat_dep | amounts |
---|---|
內科神经科 | 46844 |
內科消化科 | 32245 |
內科呼吸科 | 27931 |
外科肛肠 | 24016 |
外科神经脑外科 | 23620 |
內科心血管科 | 22841 |
內科内分泌科 | 21745 |
外科普通外科 | 21179 |
內科肝病科 | 20888 |
外科泌尿科 | 18422 |
內科肾内科 | 14010 |
內科普通内科 | 13447 |
內科血液科 | 9968 |
外科肝胆科 | 8831 |
外科乳腺科 | 8823 |
外科血管科 | 6404 |
內科风湿免疫科 | 5486 |
內科感染科 | 4035 |
外科胸外科 | 2913 |
外科心外科 | 1777 |
再將它們加上 0~19 的id
,之後用來訓練會用到的欄位。最後的欄位總共是這些
cat_dep | dep_id |
---|---|
內科内分泌科 | 0 |
內科呼吸科 | 1 |
內科心血管科 | 2 |
內科感染科 | 3 |
內科普通内科 | 4 |
內科消化科 | 5 |
內科神经科 | 6 |
內科肝病科 | 7 |
內科肾内科 | 8 |
內科血液科 | 9 |
內科风湿免疫科 | 10 |
外科乳腺科 | 11 |
外科心外科 | 12 |
外科普通外科 | 13 |
外科泌尿科 | 14 |
外科神经脑外科 | 15 |
外科肛肠 | 16 |
外科肝胆科 | 17 |
外科胸外科 | 18 |
外科血管科 | 19 |
那最後欄位有這些
cat_dep | dep_id | department | title | ask | answer |
---|---|---|---|---|---|
內科心血管科 | 2 | 內科 | 心血管科 | 高血压患者能吃党参吗? | 我有高血压这两天女婿来的时候给我拿了些党参泡水喝,您好高血压可以吃党参吗? |
內科心血管科 | 2 | 內科 | 心血管科 | 高血压该治疗什么? | 我是一位中学教师,平时身体健康,最近学校组织健康检查,结果发觉我是高血压,去年还没有这种情况,我很担心,这边我主要想进行咨询一下高血压应当怎样治疗?麻烦医生指导一下,谢谢。 |
內科心血管科 | 2 | 內科 | 心血管科 | 老年人高血压一般如何治疗? | 我爷爷今年68了,年纪大了,高血压这些也领着来了,这些病让老人很痛苦,每次都要按时喝药,才能控制住,还得时不时去医院检查一下身体,想进行咨询一下医生,老年人高血压如何治疗? |
內科内分泌科 | 0 | 內科 | 内分泌科 | 糖尿病还会进行遗传吗? | 糖尿病有隔代遗传吗?我妈是糖尿病,很多年了,也没养好,我现在也是,我妹子也是,我儿子现在二十岁,没什么问题,但是以后会不会也得糖尿病啊,真是难过,我现在就已经开始让他控制点吃东西。 |
除了上述以外,在這個資料集中,ask 欄位中有 大約四萬筆資料是「无」的,就是有標題沒內文的那種形式。如果要用 ask 來做訓練的話可能就會出狀況。這邊的處理就是把沒內文的部分,把 title 複製到 ask 中。
import pandas as pd
df.loc[df['ask']== '无', 'ask']= df[df['ask']== '无']['title']
準備好資料以後,就能做文字處理了。在這部分流程圖如以下
graph LR
raw((Raw Ask Text)) -->seg[Word Segment]
seg --> rm[Remove Stopwords]
rm --> w2v[Word Embedding]
subgraph Pipeline
w2v --> cls[Classifier]
end
cls --> out((Output Class))
graph LR
raw['糖尿病一般需要怎么治疗?'] -->|jieba|re['糖尿病', '一般', '需要', '怎么', '治疗', '?']
文本進來時,會是一長串的字串,必須要先經過分詞,將字句分開。資料集是簡體中文,那採用簡單的 jieba 做分詞
這部分沒有改任何的參數,原本的分詞效果就很不錯了。
import jieba
def word_segment(sentence, stopwords_path):
'''Word segment and remove stopwords
Parameters
sentence: str
Raw text
stopwords_path: str
Path of stopwords text file
Returns
list<str>
A list that after segment and remove stopwords
'''
words = jieba.cut(sentence)
words = _remove_stop_words(words, stopwords_path)
return words
分完詞以後,再接著做去除停用詞
graph LR
re['糖尿病', '一般', '需要', '怎么', '治疗', '?']-->|Remove Stopwords|rmstp['糖尿病', '治疗']
在一段文字的組成中,介係詞、代名詞等等不具有關鍵意義的詞,會被視為是停用詞(stopwords)。再經過斷詞以後,這些詞同樣也會被切出,但它們不太能表達句子意思,故在這裡的NLP任務去除
並非所有的NLP任務都需要去除停用詞,比如翻譯,停用詞可以表達文句因果關係等等非常重要。
停用詞字典採用學長推薦的中文NLP工具包 HarvestText 。包好的 get_baidu_stop_words(),可以取得 set of 百度停用詞字典。
HarvestText
repo: https://github.com/blmoistawinde/HarvestText
不過,除了使用裡面的停用字以外,分詞完以後我發現在原有的資料中,還有很多符號沒有被清除,一樣大多是發生在 answer
的序列 ① ② ③ 等等。若沒有清除,在做 word embedding 可能會有問題,因為他們在分詞完後也會站一格詞,這並不是我們想看到的。所以在裡面新增了一些字,包含各類數字、標點符號、單位、全型空白等。期望能讓資料更乾淨。
’!.""##$$%%&&\'()()**++×,-./:;<=>?@@,。★、…【】《》?“”‘’![]^︿__`{{||}}-~~
01234567891234567890⓵⓶⓷⓸⓹⓺⓻⓼⓽⓾⒈⒉⒊⒋⒌⒍⒎⒏⒐⒑⒒⒓⒔⒕⒖⒗⒘⒙⒚⒛⓪①②③④⑤⑥⑦⑧⑨⑩➀➁➂➃➄➅➆➇➈➉⑪⑫⑬⑭⑮⑯⑰⑱⑲⑳⓿❶❷❸❹❺❻❼❽❾❿➊➋➌➍➎➏➐➑➒➓⓫⓬⓭⓮⓯⓰⓱⓲⓳⓴⑴⑵⑶⑷⑸⑹⑺⑻⑼⑽⑾⑿⒀⒁⒂⒃⒄⒅⒆⒇
℃ mg MG kg KG um UM mm MM cm CM nm NM km KM ml ML
abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ
def _remove_stop_words(words, stopwords_path):
'''Remove stopwords
Parameters
words: list<str>
list of word segmentation
stopwords_path: str
Path of stopwords text file
Returns
list<str>
A list that after remove stopwords
'''
result = []
stopwords = set(line.strip() for line in open(stopwords_path))
for word in words:
if word not in stopwords:
result.append(word)
return result
分詞跟刪除停用字一起做,每次刪完記得回去看一下資料是不是如預期一樣分詞!
df['answer_clean'] = df['answer'].apply(lambda x: ws.word_segment(x, STOP_WORDS_PATH))
df['ask_clean'] = df['ask'].apply(lambda x: ws.word_segment(x, STOP_WORDS_PATH))
df['title_clean'] = df['title'].apply(lambda x: ws.word_segment(x, STOP_WORDS_PATH))
應用在 dataframe 可以用 lambda function 來呼叫我們已做好的分詞函式,也能達到每個欄位都有分詞到的效果。
在執行完上述步驟以後,再一次存成 csv file 方便之後讀取,畢竟分詞需要花上不少時間
上面執行完以後,csv 除了有上面的欄位以外
title | ask | answer | answer_clean | ask_clean | title_clean |
---|---|---|---|---|---|
高血压患者能吃党参吗? | 我有高血压这两天女婿来的时候给我拿了些党参泡水喝,您好高血压可以吃党参吗? | 高血压病人可以口服党参的。党参有降血脂,降血压的作用,可以彻底消除血液中的垃圾,从而对冠心病以及心血管疾病的患者都有一定的稳定预防工作作用,因此平时口服党参能远离三高的危害。另外党参除了益气养血,降低中枢神经作用,调整消化系统功能,健脾补肺的功能。感谢您的进行咨询,期望我的解释对你有所帮助。 | "['高血压', '病人', '口服', '党参', '党参', '降血脂', '降血压', '作用', '彻底消除', '血液', '中', '垃圾', '冠心病', '心血管', '疾病', '患者', '都', '稳定', '预防', '工作', '作用', '平时', '口服', '党参', '远离', '三高', '危害', '党参', '益气养血', '降低', '中枢神经', '作用', '调整', '消化系统', '功能', '健脾', '补肺', '功能', '感谢您', '咨询', '期望', '解释']" | "['高血压', '两天', '女婿', '些', '党参', '泡水', '喝', '您好', '高血压', '吃', '党参']" | "['高血压', '患者', '吃', '党参']" |
高血压该治疗什么? | 我是一位中学教师,平时身体健康,最近学校组织健康检查,结果发觉我是高血压,去年还没有这种情况,我很担心,这边我主要想进行咨询一下高血压应当怎样治疗?麻烦医生指导一下,谢谢。 | 高血压患者首先要注意控制食盐摄入量,每天不超过六克,注意不要吃太油腻的食物,多吃新鲜的绿色蔬菜水果,多吃有机食物,注意增强体育锻炼,增加身体素质,同时压力不要过大,精神不要紧张,效果不佳的话,可以积极配合以降压药物控制血压治疗,情绪平时保持平和,不易激动。 | "['高血压', '患者', '控制', '食盐', '摄入量', '不', '超过', '六克', '吃', '太', '油腻', '食物', '吃', '新鲜', '绿色', '蔬菜水果', '吃', '有机', '食物', '增强', '体育锻炼', '增加', '身体素质', '压力', '过大', '精神', '紧张', '效果', '不佳', '配合', '降压', '药物', '控制', '血压', '治疗', '情绪', '平时', '平和', '不易', '激动']" | "['一位', '中学教师', '平时', '身体健康', '学校', '组织', '健康检查', '发觉', '高血压', '去年', '还', '情况', '很', '担心', '想', '咨询', '高血压', '治疗', '麻烦', '医生', '指导', '谢谢']" | "['高血压', '治疗']" |
後面的流程會由 sklearn 的 pipeline 包起來
from sklearn.model_selection import train_test_split
x_train, x_test, y_train, y_test= train_test_split(df['ask_clean'], df['dep_id'], test_size=0.2, shuffle=True)
原本儲存在 csv 裡的 string 轉回 list,輸出[[str], [str], [str]]
的結構
from ast import literal_eval
x_train= [literal_eval(lst) for lst in x_train]
x_test= [literal_eval(lst) for lst in x_test]
為了要讓機器看得懂自然語言,會將文字以陣列等方式表達。目前我知道有以下作法 Bag of Words、TF-IDF Vectorize、Word2Vec 等等。在這篇文章將使用 Gensim Word2Vec 完成 word embedding
不過,由於我們要使用到 sklearn pipeline,而 gensimi 在 4.0.0 以後的版本不再支援第三方 wrapper。這邊可以選擇將 3.8.13 的 wrapper 複製過來或者直接降低 gensim 版本
我這裡是選擇繼續使用 gensim 4.1.2 但配合別人寫的 Word2Vec Vectorizer。
解釋一下會什麼是用別人做好的 Transformer,而不是從 gensim 3.8.13 repo 複製。其實我有試過,但在訓練時會跳出 Key error 無法進行,原因是 gensim 原本在 sklearn_api 裡面的 vectorizer 沒有支援新增文字的功能,遇到新的字詞就會跳出錯誤,如下圖。
這部份有人做出來了,換用他的就能解決了。
ethen81 machine-learning GensimWord2VecVectorizer
repo: https://github.com/ethen8181/machine-learning/blob/master/keras/text_classification/transformers/gensim_word2vec.py
另外,如果還是繼續使用 gensim 4.0.0 以後的版本的話,有些地方需要修改
Migrating from Gensim 3.x to 4
link: https://github.com/RaRe-Technologies/gensim/wiki/Migrating-from-Gensim-3.x-to-4
且根據上面連結的說明,有兩處參數名稱需要更改 iter => epochs, size => vector_size
還有
valid_words = [word for word in words if word in self.model_.wv.vocab]
改成
valid_words = [word for word in words if word in self.model_.wv]
如此一來就能正常使用了
在分類器的選擇上,也有非常多種,本次使用的是 Xgboost
可以由最前面的表格看到,我們的資料多達20種,數量最多4萬筆至最少的1千7,調整權重看來十分重要。雖然有人很熱心地分享了多分類的權重程式碼,不過可惜的是,權重參數在 XGBoost Classifier 僅用於二分類。
Set Weights in Multi-class Classfication in Xgboost for Imbalanced Data
link: https://stackoverflow.com/questions/45811201/how-to-set-weights-in-multi-class-classification-in-xgboost-for-imbalanced-data
from sklearn.metrics import accuracy_score
xgb_model.fit(x_train, y_train)
y_test_pred = xgb_model.predict(x_test)
test_score = accuracy_score(y_test, y_test_pred)
accuracy = accuracy_score(y_test,y_test_pred)
print("accuarcy: %.5f%%" % (accuracy*100.0))
最後用 test set 簡單的 print 出準確率
光是 xgboost 就有超多種超參數可以做調整。第一次選用的參數,跑了一個半小時,在測試資料集僅能到達 56% 左右的準確率。透過不斷測試找到更好的超參數。目前最好參數僅能在測試資料集中有 71% 的準確率,我認為還有很大的進步空間。
# w2v params
SIZE= 100,
MIN_COUNT= 3,
WORKERS= 4,
WINDOW= 5,
# xgb params
LEARNING_RATE= 0.01,
OBJECTIVE='muli:softmax',
EVAL_METRIC='mlogloss',
MAX_DEPTH= 10,
COLSAMPLE_BYTREE=0.8,
SUBSAMPLE=0.8,
其實原本應該要用,RandmizedSearchCV 調整參數,他可以達到 wandb sweep那樣的效果,但跑一次實在是太久了。
上面的 sklearn pipeline 能夠用 joblib 儲存。
import joblib
import os
joblib.dump(xgb_model, os.path.join(SAVING_DIR, 'xgb_model.joblib'))
xgb_pipeline = joblib.load(model_path)
w2v_layer = xgb_pipeline.named_steps["w2v"]
xgb_layer = xgb_pipeline.named_steps["xgb"]
可以分別取出兩層,取出以後就可以印出裡面的內容了
以下是使用 plot_importance()
印出的圖表
結果測試
x= '我腰痛头痛肋骨也痛'
xgb_pipeline.predict(ws.word_segment(x, STOP_WORDS_PATH))
>>> array([ 4, 9, 18, 13, 16], dtype=int64)
可以看到輸出最多的是 13 的一般外科
老實說我認為這種算法還有很多地方可以改進,往往決定整句是哪個分類的止會有一兩個詞而已,但是一些比較籠統的表達句子存在時,如「肚子痛」、「人不舒服」等,便會影響輸出的準確度。
x= '我今天测高血压,胸闷又胸痛要怎么缓解'
xgb_pipeline.predict(ws.word_segment(x, STOP_WORDS_PATH))
>>> array([ 4, 9, 6, 13, 16], dtype=int64)
從想法到最後推測花了一個禮拜多,不過最後結果不是很滿意,就算換成了內、外兩科,最高也只能有 80% 左右的準確率,而且原本是打算依據最重要的 dimension plot 出 word2vec 圖,但結果都是黑漆漆的一團。雖說想要往NLP的方向前進,但說來慚愧,這是筆者第一支完成的NLP project,目前還在執行的其他專案還面臨著大方向的問題,還在尋找適合的解決方法。往往會花上一整周的時間查資料、看論文,不過都沒有太大的幫助。期望往後能在這支專案中找到什麼靈感。
https://github.com/Toyhom/Chinese-medical-dialogue-data https://github.com/blmoistawinde/HarvestText https://github.com/ethen8181/machine-learning/blob/master/keras/text_classification/transformers/gensim_word2vec.py https://github.com/RaRe-Technologies/gensim/wiki/Migrating-from-Gensim-3.x-to-4 https://stackoverflow.com/questions/45811201/how-to-set-weights-in-multi-class-classification-in-xgboost-for-imbalanced-dat http://ethen8181.github.io/machine-learning/keras/text_classification/word2vec_text_classification.html https://towardsdatascience.com/text-classification-with-nlp-tf-idf-vs-word2vec-vs-bert-41ff868d1794 https://xgboost.readthedocs.io/en/stable/parameter.html