iT邦幫忙

2018 iT 邦幫忙鐵人賽
DAY 29
0

LineBot專案概述

這個專案是我去新竹黑客松比賽時,拿到第二名的作品,這個作品主要是將新竹市政府社會處的常見問答集爬下來,希望可以建置一個聊天機器人幫助市民快速取得問題的答案。
這個作品主要分成四個部分:

  1. 資料爬取: 因為很多人寫爬蟲城市相關文章,這裡就不做示範了,如果有興趣可以參考我的gitub
  2. 直接用文件檢所技術,把可能的答案回應給使用者: 這部分前面文章已經介紹過了。
  3. 用分類技術,把無法回應的問題丟到對應的窗口: 今天會講解的部分
  4. 建立LineBot的Server: 由於跟資料技術比較沒有關係,就不會在這一系列文章出現。

其他比較精彩的介紹可以參考我的gitub

實作

Import

# 基礎
import pandas as pd
import numpy as np
import math
import json
import os
import jieba  ## 斷字
jieba.set_dictionary('dict.txt.big')
stopwords = [ line.rstrip() for line in open('stop_words.txt' , encoding='utf8') ] ## 這個中文停用字典可以自己找

# 視覺化
import matplotlib.pyplot as plt  ## python最常見的繪圖工具
import matplotlib
zhfont1 = matplotlib.font_manager.FontProperties(fname='simsun.ttf')  ## plt中文字
from mpl_toolkits.mplot3d import Axes3D
from sklearn.decomposition import PCA  ## 降維視覺化常用工具
from pylab import rcParams  ## 調整畫布比例

# 訓練模型
from sklearn.model_selection import train_test_split  ## 切分鍊與測試資料集
from sklearn.neighbors import KNeighborsClassifier  ## KNN
from sklearn.svm import SVC  ## SVM
from  xgboost import XGBClassifier  ## xgboost
import xgboost

# 從新竹社會局常見問答集爬取
def getdata():
    with open('RawData.json', 'r', encoding='utf8') as f:
        data = json.load(f)
    df = pd.DataFrame(data)
    return df

Load Data

這是預先爬下的資料,爬蟲我們就先省略了,大家記得要先去github clone資料下來。

df_train = getdata()
print("Rows=", len(df_train))
df_train.head()

https://ithelp.ithome.com.tw/upload/images/20180101/20107576UakOWJl7O1.jpg

Preprocess

斷字

這個地方斷字模式的選擇非常重要,因為中文斷字採用精準模式可以方便人們閱讀,但是如果要讓機器去認識相關性,最好可以讓句子當中所有可能的資訊都被包含進去,而採用全斷詞模式可以得到所有的斷字可能,如此一來,便能增加斷詞後句子的資訊含量。這部分之所以會檢查出來之後,下面的PCA圖(比較下面)也可以明顯看出,每個句字的資訊含量增多了之後,有平滑資料的功能。

# 斷字
def preprocess(question):
#     words = list(jieba.cut(question)) ## 精準模式
    words = list(jieba.cut(question, cut_all=True)) ## 全斷詞模式
    return words
df_train['words'] = df_train['question'].apply(preprocess)
df_train.head()

https://ithelp.ithome.com.tw/upload/images/20180101/20107576XvzYlf2VzT.jpg

準備把文字類別轉換成數字類別

# 為了訓練需要,將類別轉化為數字類別(1~11類)
cat_mapping = {}
for num, cat in enumerate(list(set(df_train['cat']))):
    cat_mapping[cat] = num
    
# 預測時,要將預測出的結果翻譯為原先的類別所使用
inversed_cat_mapping = {}
for cat, idx in cat_mapping.items():
    inversed_cat_mapping[idx] = cat

print(cat_mapping)
print(inversed_cat_mapping)

# {'國民年金': 1, '社區發展服務': 2, '社工專業服務': 11, '兒童少年及家庭福利': 3, '身心障礙者福利': 4, '家庭暴力及性侵害防治服務': 8, '婦女福利': 6, '幼兒福利及托育服務': 7, '人民團體及合作社場服務': 9, '社會救助': 5, '老人福利': 10, '志願服務': 0}
# {0: '志願服務', 1: '國民年金', 2: '社區發展服務', 3: '兒童少年及家庭福利', 4: '身心障礙者福利', 5: '社會救助', 6: '婦女福利', 7: '幼兒福利及托育服務', 8: '家庭暴力及性侵害防治服務', 9: '人民團體及合作社場服務', 10: '老人福利', 11: '社工專業服務'}

添加類別本身當作問題,以增強簡短問題的回答能力

我後來在測試預測能力時,發現如果使用者只透過幾個簡單的字詢問,將有可能導致預測失準,因為從社會處爬下來的每個問題都是完整的問句,因此增加這樣的資料,將有助於使用者使用簡短的特定議題標題,作為詢問問題。

print('Preprocess Length = ', len(df_train))

addedrow = []
for key, value in cat_mapping.items():
    row = {}
    words = list(jieba.cut(key, cut_all=False))
    row['words'] = words
    
    row = {'ans': None,
            'cat': key,
            'cat_num': value,
            'id': None,
            'question': key,
            'words':words
          }
    addedrow.append(row)
addeddf = pd.DataFrame(addedrow)
df_train = pd.concat([df_train, addeddf])
print('Processed Length = ', len(df_train))

# Preprocess Length =  327
# Processed Length =  339

資料描述

每個類別的問題個數

這個部分很重要,因為訓練出來的類別分配比例,與訓練資料集內部的類別分配比例會相同,因此如果未來使用者詢問的問題的比例與這個比例不同,將很有可能導致預測的失準,因此這部分要特別注意。如果知道真實的使用者詢問比例,可以複製特定比例的資料以平衡比例。

main_series = df_train.groupby('cat').count()['ans']
main_series.plot(kind='bar', use_index=False)
plt.savefig(os.path.join('pic', 'nums_of_queries_each_cat'))
plt.show()

for i in range(len(list(main_series.index))):
    label = 'ABCDEFGHIJKLMNOPQRSTU'
    print(label[i], main_series.index[i], list(main_series)[i])

https://ithelp.ithome.com.tw/upload/images/20180101/20107576fUe7GwajOU.png

每個問題的長度

df_train['question'].apply(len).plot(kind='hist', bins=30)
plt.savefig(os.path.join('pic', 'length_of_queries'))
plt.show()

https://ithelp.ithome.com.tw/upload/images/20180101/20107576VcbtrJTgGm.png

每個類別的常用字

這個步驟可以看出,每個類別比較常用的一些字,如果可以很明顯區辨,那麼訓練起來也就會比較容易,我剛開始拿到這個資料集準備要訓練時,看到這麼鮮明的類別詞彙,我就有滿高的把握,可以訓練起來的。

# 每個類別常出現的字
from wordcloud import WordCloud
from collections import Counter

freqs = []
for cat in list(set(df_train['cat'])):
    words_Li = list(df_train[df_train['cat'] == cat]['words'])
    total_words_in_cat = []
    for words in words_Li:
        total_words_in_cat.extend(words)
    freq = Counter(total_words_in_cat)
    print(cat)
    wordcloud = WordCloud(font_path="simsun.ttf")
    wordcloud.generate_from_frequencies(frequencies=freq)
    plt.figure()
    plt.imshow(wordcloud, interpolation="bilinear")
    plt.axis("off")
    plt.title(cat, fontproperties=zhfont1)
    plt.savefig(os.path.join('pic', 'wordcloud_' + cat), bbox_inches='tight', pad_inches=0)
    plt.show()

https://ithelp.ithome.com.tw/upload/images/20180101/20107576af8YE7WCxg.jpg

準備訓練資料

把句子向量化

這個步驟首先要先建立一個一致的詞彙索引list,如此將可以讓每一個問題,在同樣的向量下,增加出現的字所在的索引值的維度的值,以向量化所有問題。

# 準備排序的文字list(keywordindex)
total_li = []
for li in list(df_train['words']):
    total_li += li
vectorterms = list(set(total_li))

## 轉化每個問題變成向量
def vectorize(words):
    self_main_list = [0] * len(vectorterms)
    for term in words:
        if term in vectorterms:  ## 測試資料集當中的字不一訂有出現在訓練資料集中
            idx = vectorterms.index(term)
            self_main_list[idx] += 1
    return np.array(self_main_list)

X = np.concatenate(df_train['words'].apply(vectorize).values).reshape(-1, len(vectorterms))
Y = df_train['cat'].apply(cat_mapping.get)
print(X.shape)
print(Y.shape)

# (339, 1017)
# (339,)

查看訓練資料的資料分布

這個步驟很重要,我原本在jeiba斷字的階段,開的是精準模式,而不是全斷字模式,那麼會得到右邊的結果,而這樣資料的極端分布,將會導致不好的訓練效果,因此這個步驟還是很必要的。一旦發現資料的分佈有問題,可能要平滑處理一下特定的欄位,或是思考一下其他處理方式。

fig = plt.figure(1, figsize=(8, 6))
ax = Axes3D(fig, elev=-150, azim=110)

X_reduced = PCA(n_components=3).fit_transform(X)
ax.scatter(X_reduced[:, 0], X_reduced[:, 1], X_reduced[:, 2], c=Y,
           cmap=plt.cm.Set1, edgecolor='k', s=40)
ax.set_title("PCA_cutall")
ax.set_xlabel("1st eigenvector")
ax.w_xaxis.set_ticklabels([])
ax.set_ylabel("2nd eigenvector")
ax.w_yaxis.set_ticklabels([])
ax.set_zlabel("3rd eigenvector")
ax.w_zaxis.set_ticklabels([])

plt.savefig(os.path.join('pic', 'PCA_cutall'))
plt.show()

https://ithelp.ithome.com.tw/upload/images/20180101/20107576Y4K4K6THqs.jpg

切分訓練與測試資料

為了避免overfitting,因此最好只拿一部分資料進去訓練模型,另一部分拿來驗證模型。

# Finally, we split some of the data off for validation
X_train, X_valid, Y_train, Y_valid = train_test_split(X, Y, test_size=0.2, random_state=123)

print(X_train.shape)
print(X_valid.shape)
print(Y_train.shape)
print(Y_valid.shape)

開始訓練

KNN, SVM(rbf), SVM(linear), XGB(non CV)

先用幾個簡單的分類模型試試,其中你會發現,KNN在這種文字次數累積的稀疏矩陣(有很多零的向量)效果其實已經還不錯;在SVM上因為rbf的函數無法有效解釋這些類別的差異,linear的效果則特別好;另外XGB上的效果也不錯,主要原因是XGB是非常暴力的工具,某種曾度上撇除了參數調整的因素,不管怎麼用效果都特別好。不過因為訓練資料量其實不大,因此有可能各位執行的時候,結果跟我並不太一樣。

# 定義函式,輸入分類器,輸出準確率
def get_accuracy(clf, *args):
    if args:
        clf = clf(kernel=args[0])  ## SVM在這邊用Llinear比較準
    else:
        clf = clf()
    clf = clf.fit(X_train, Y_train)
    y_pred = clf.predict(X_valid)
    return (str(sum(Y_valid == y_pred)/Y_valid.shape[0]))

print('KNN: ', get_accuracy(KNeighborsClassifier))
print('SVM_rbf: ', get_accuracy(SVC))
print('SVM_linear: ', get_accuracy(SVC, 'linear'))
print('XGB: ', get_accuracy(XGBClassifier))

# KNN:  0.602941176471
# SVM_rbf:  0.220588235294
# SVM_linear:  0.838235294118
# XGB:  0.897058823529

XGB + CV

最後,我最喜歡使用的分類王者XGB+CV,由於可以自動開啟訓練的過程,使用上整個非常方便,也得到了最好的效果。

params = {}
params['objective'] = 'multi:softmax'  ## 因為是多個類別,這邊跟前面兩個類別的使用不太一樣
params['eta'] = 0.1
params['max_depth'] = 3
params['silent'] = 1
params['nthread'] = 4
params['num_class'] = len(set(Y))  ## 多個類別,記得要告訴她有幾個類別

d_train = xgboost.DMatrix(X_train, label=Y_train)
d_valid = xgboost.DMatrix(X_valid, label=Y_valid)
watchlist = [(d_train, 'train'), (d_valid, 'valid')]

bst = xgboost.train(params, d_train, 1000, watchlist, early_stopping_rounds=100, verbose_eval=10)

https://ithelp.ithome.com.tw/upload/images/20180101/20107576h6TeY1HZnZ.jpg

從結果上來看,測試資料可以達到91%的準確率,整個資料集來看,可以達到97%的準確率,效果還不錯。

y_pred = bst.predict(xgboost.DMatrix(X_valid))
print("Accuracy_valid: ", str(sum(Y_valid == y_pred)/Y_valid.shape[0]))
y_pred = bst.predict(xgboost.DMatrix(X))
print("Accuracy_all: ", str(sum(Y == y_pred)/Y.shape[0]))

# Accuracy_valid:  0.911764705882
# Accuracy_all:  0.976401179941

這邊其實也可以看一下,他的樹狀圖是怎麼畫的,不過沒什麼特別的意義就是了。喔對了,要使用這功能要先安裝graphviz,要下載有點久,大家斟酌使用。

rcParams['figure.figsize'] = 10, 20
xgboost.plot_tree(bst, num_trees=2)
plt.savefig(os.path.join('pic', 'tree'))
plt.show()

https://ithelp.ithome.com.tw/upload/images/20180101/20107576Yc1CE5izsq.png

預測

Load Data

為了讓預測可以被檢驗,我們爬下台北市政府社會處的常見問答集來做測試。

# This is crawled from 台北社會局常見問答集
with open('Taipei_Society_Affiar_QA', 'r', encoding='utf8') as f:
    testing_questions = eval(f.read())
    testing_questions = np.random.choice(testing_questions, size=len(testing_questions))
    
df_test = pd.DataFrame(testing_questions, columns=['question'])
df_test.head()

預測

def predict(question):  ## 定義預測函數
    words = preprocess(question)
    vector = vectorize(words)
    cat_num = bst.predict(xgboost.DMatrix(vector.reshape(1, -1)))
    return inversed_cat_mapping.get(cat_num[0])
df_test['cat_pred'] = df_test['question'].apply(predict)
df_test[['question', 'cat_pred']]
id question cat_pred
0 我最近看到臺北扶老.軟硬兼施廣告,請問那是什麼服務?怎樣的對象可以申請? 身心障礙者福利
1 請問二代健保上路後會影響我的老人健保自付額補助資格嗎? 老人福利
2 敬老、愛心悠遊卡可以借人使用嗎? 婦女福利
3 從哪裡可以得到老人福利服務資訊? 老人福利
4 敬老、愛心及愛心陪伴悠遊卡,故障應備何文件重新辦理? 老人福利
5 身心障礙者專用停車位識別證新制申請應備文件。 身心障礙者福利
6 「馬上關懷急難救助」經申請(或通報)後,何時可獲得協助? 社會救助
7 長者預防走失手鍊是否有GPS功能? 身心障礙者福利
8 「馬上關懷急難救助」向居住所在地或戶籍所在地申請? 社會救助
9 申請失能者生活輔助器具及居家無障礙環境改善補助應備文件為何? 身心障礙者福利
10 敬老、愛心及愛心陪伴悠遊卡,故障應備何文件重新辦理? 老人福利
11 我是離婚單親,與前配偶共同監護1名4歲小孩,可否申請特殊境遇家庭扶助? 婦女福利
12 「行動不便身心障礙者」判定、評估結果及異議處理方式為何? 身心障礙者福利
13 辦理身心障礙者承租停車位租金補助的車輛種類? 身心障礙者福利
14 101年7月11日起身心障礙者專用停車位識別證新制上路!請說明新制的轉變。 身心障礙者福利

檢視訓練資料集的錯誤預測

df_train_error = df_train[['question', 'cat']]
df_train_error['cat_pred'] = df_train_error['question'].apply(predict)
df_train_error.loc[df_train_error['cat'] != df_train_error['cat_pred'], ['question', 'cat', 'cat_pred']]

https://ithelp.ithome.com.tw/upload/images/20180101/20107576rvqqGiuz79.jpg

自己輸入問題的預測

query = input("請輸入你的問題?\n")
predict(query)

https://ithelp.ithome.com.tw/upload/images/20180101/20107576di0Ylnc4av.jpg

code在這裡


上一篇
分類演算法理論-SVM及XGB+CV
下一篇
2017 資料分析師的練成之路
系列文
玩轉資料與機器學習-以自然語言處理為例31

尚未有邦友留言

立即登入留言