MaDi's Blog

一個紀錄自己在轉職軟體工程師路上的學習小空間

0%

[專案實作] PTT爬蟲+應用機器學習預測板名

本文利用之前專案寫好的PTT爬蟲程式來抓取板上的資料,並將資料存進SQLite資料庫,再從中撈取資料作為分析,主要目的是想用機器學習的演算法來透過文章標題來預測這篇文章是出自於哪一板。

PTT爬蟲

先前因為某專案需求,有寫了一個可以自訂板名、截止日期的PTT爬蟲程式,並將它模組化。這裡就直接呼叫程式來做爬蟲,這篇文章爬取的板名有 HatePolitics(政黑板)Beauty(表特板)Soft_Job(軟體板)NBAStock(股票板)Tech_Job(科技板)等六個板。

因為不想花太多時間在等爬蟲,所以截止日期設定成2020/12/1,大約一星期的資料應該就足夠。廢話不多說,直接執行爬蟲:

程式會把爬下來的資料依照 文章ID、推噓數量、作者、文章標題、發文日期、內文、留言推噓、留言內容、留言者ID、爬取時間存入sqlite資料庫。

此外,為了不想一直待在電腦旁邊無法抽身,我在程式裡面額外串接line-notify的通知服務,當每個板爬蟲完畢後就透過line的訊息通知我,並記錄每個板的總爬取時間。爬完六個板之後,大約花了20分鐘。

到這裡,PTT爬蟲的資料就完成了。

接著,就是希望能夠簡單的應用機器學習來透過文章標題去預測該文章是出自於哪一板。

機器學習預測文章出處

首先,引入需要的套件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
# sqlite
import sqlite3 as DB
from sqlalchemy import create_engine

# Words Process
import jieba
import jieba.posseg as pseg

# ML/DL
import tensorflow
import keras

# Sklearn
from sklearn.model_selection import train_test_split
from sklearn.utils import shuffle
from sklearn.neighbors import KNeighborsClassifier ## KNN
from sklearn.svm import SVC ## SVM
from sklearn.ensemble import RandomForestClassifier ## RFC

# Xgboost
import xgboost
from xgboost import XGBClassifier ## xgboost
from xgboost import plot_importance

# imblearn
from imblearn.over_sampling import SMOTE

# analyze
from sklearn.metrics import classification_report
from sklearn.metrics import confusion_matrix

# plot
import seaborn as sns
import matplotlib.pyplot as plt

接著,建立讀取資料庫相關的函式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# 定義資料庫位置
def get_DB(DB_path):
conndb = DB.connect(DB_path) # 若有則讀取,沒有則建立
curr = conndb.cursor()
return [conndb,curr]

# 查詢資料
def queryData(sqlite_path,webName):
[conndb,curr] = get_DB(sqlite_path)
try:
results = curr.execute("SELECT * FROM {} ORDER BY Date DESC;".format(webName))
except DB.OperationalError:
return 'No Such Table'
return results

# 查詢各版名
def queryBoardName(sqlite_path):
[conndb,curr] = get_DB(sqlite_path)
boardList = []
try:
results = curr.execute("SELECT name FROM sqlite_master")
for row in results:
boardList.append(row[0])
except DB.OperationalError:
return 'No Tables'
return boardList

正式從sqlite資料庫撈取資料

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 建立sqlite的engine
sqlite_path = 'D:\ptt_flask.db'
engine = create_engine('sqlite:///'+sqlite_path)

# 查詢資料庫內有哪些板
boardName = queryBoardName(sqlite_path)

# 把所有板的資料彙整成一個dataframe
df = pd.DataFrame()
for each in boardName:
sql = f"SELECT ArticleID,Author,Title,Content,Comment_Content FROM {each} ORDER BY Date DESC;"
df_each = pd.read_sql(sql, con=engine)
df_each['boardName'] = each
df = df.append(df_each)

# 只需要板名、標題就好
df = df[['boardName','Title']]

要進行分類之前,需要先初步觀察一下資料的分布

1
2
3
# 觀察資料分布
groups = df.groupby('boardName')
groups.size().plot(kind='bar')

從圖裡面可以看出 Soft_Job的數量相比其他板來的少,依照機器學習的邏輯來說,可以透過SMOTE的方式去過採樣資料的數量,但因為Soft_Job的文章數量實在太少,若是硬要合成資料,會造成機器學習錯誤的機率更大,所以這邊就直接把Soft_Job剔除,以剩下的五個板作為分類。

1
2
3
# 去掉 Soft_Job
df = df[df['boardName']!='Soft_Job'].reset_index()
df.drop(columns='index',inplace=True)

用0.8的比例去拆分訓練跟測試的資料集

1
2
3
TRAIN_TEST_RATIO = 0.8 
df_train = df.sample(frac=TRAIN_TEST_RATIO, random_state=914)
df_test_origin = df.drop(df_train.index)

因為是採用文章標題去預測板名,所以先透過jieba套件來將文章標題做斷詞。

雖然在這裡文章標題都只有一句話,斷詞後可能幫助不大,但如果今天換成是用內文去預測板名,斷詞這步驟就顯得相當重要了。

1
2
3
4
5
6
def jieba_tokenizer(text):
words = pseg.cut(text)
return ' '.join([
word for word, flag in words if flag != 'x'])

df_train['Title Tokenized'] = df_train['Title'].apply(jieba_tokenizer)

用字典把板名轉換成index去代表,ex: Beauty:1, HatePolitics:3, NBA:2, Stock:0, Tech_Job:4,當預測完之後,會得到一串各類別的機率,就可以透過另外一組字典反向查index回來看看板名是什麼。

1
2
3
4
5
6
7
8
9
10
11
12
# 類別轉化為數字類別(1-N類)
kind_mapping = {}
for num, kind in enumerate(set(df_train['boardName'])):
kind_mapping[kind] = num

# 預測時,要將預測出的結果翻譯為原先的類別所使用
inversed_kind_mapping = {}
for kind, idx in kind_mapping.items():
inversed_kind_mapping[idx] = kind

print('kind_mapping: ',kind_mapping)
print('inversed_kind_mapping: ',inversed_kind_mapping)

kind_mapping: {‘Tech_Job’: 0, ‘Stock’: 1, ‘HatePolitics’: 2, ‘Beauty’: 3, ‘NBA’: 4}

inversed_kind_mapping: {0: ‘Tech_Job’, 1: ‘Stock’, 2: ‘HatePolitics’, 3: ‘Beauty’, 4: ‘NBA’}

將文章標題的所有出現過的詞作成一個大字典,並透過這個字典把所有詞彙都轉成向量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 準備排序的文字list(keywordindex)
total_words = ' '.join(df_train['Title Tokenized'])
vectorterms = list(set(total_words))

## 轉化每個文章標題的詞變成向量
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)

# Tokenized後的文章標題做向量化
X_train = np.concatenate(df_train['Title Tokenized'].apply(vectorize).values).reshape(-1, len(vectorterms))

# 板名用數字(index)代表
Y_train = df_train['boardName'].apply(kind_mapping.get)

print('Shape of X_train: ', X_train.shape)
print('Shape of Y_train: ', Y_train.shape)

Shape of X_train: (570, 1370)
Shape of Y_train: (570,)

訓練、驗證資料的切分

1
2
3
4
5
6
7
TRAIN_VALID_RATIO = 0.2
x_train, x_valid, y_train, y_valid = train_test_split(X_train, Y_train, test_size=TRAIN_VALID_RATIO, random_state=914)

print('Shape of X_train: ', x_train.shape)
print('Shape of X_Valid: ', x_valid.shape)
print('Shape of Y_train: ', y_train.shape)
print('Shape of Y_Valid: ', y_valid.shape)

Shape of X_train: (456, 1370)
Shape of X_Valid: (114, 1370)
Shape of Y_train: (456,)
Shape of Y_Valid: (114,)

建立計算預測後的準確率的函式

1
2
3
4
5
6
7
8
9
# 定義函式,input分類器,output準確率
def get_accuracy(clf, *args):
if args:
clf = clf(kernel=args[0])
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]))

有了函式之後,就可以分別得到四個分類器(RandomForest、SVC、KNN、XGboost)的準確度了

1
2
3
4
print('RandomForest: ', get_accuracy(RandomForestClassifier))
print('SVC(linear):', get_accuracy(SVC,'linear'))
print('KNN:', get_accuracy(KNeighborsClassifier))
print('XGboost', get_accuracy(XGBClassifier))

RandomForest: 0.8596491228070176
SVC(linear): 0.868421052631579
KNN: 0.7192982456140351
XGboost 0.8157894736842105

可以看到結果是 SVC(linear)準確度愈高,次之是RandomForest,第三名則是XGboost。最後選擇用第三名的XGboost來做最終預測的分類器。(因為XGboost一直是Kaggle上的霸主)

1
2
clf = XGBClassifier()
clf = clf.fit(X_train, Y_train)

定義好分類器之後,接著要有預測的函式才能將測試資料做預測

1
2
3
4
5
6
7
8
9
10
11
# 定義預測函式
def predict(review,clf):
words = jieba_tokenizer(review)
vector = vectorize(words)
prob = (clf.predict_proba(vector.reshape(1, -1)))*100
return prob

# 找predict_proba出來的機率array中最大的,反映射為kind
def find_maxProb(prob):
max_kind = np.argmax(prob)
return inversed_kind_mapping.get(max_kind)

正式將測試資料拿來做預測

1
2
3
4
5
6
7
8
df_test = df_test_origin
df_test['ML_Classify_prob'] = df_test['Title'].apply(predict,args=(clf,))
df_test['ML_Classify'] = df_test['ML_Classify_prob'].apply(find_maxProb)
df_test = df_test[['Title','boardName','ML_Classify','ML_Classify_prob']]

correct_cnt = df_test[df_test_origin['boardName']==df_test['ML_Classify']].count()[0]
acc = correct_cnt/len(df_test)
print('Accuracy: ',acc)

Accuracy: 0.823943661971831

預測完之後,準確率大概82%左右,跟驗證資料集的分數差不多。

接著,快速地用classification_report來看一下precisionrecallf1-scoreaccuracy等指標

1
print(classification_report(df_test['boardName'], df_test['ML_Classify']))

指標結果如下,Beauty(表特板) 的f1-score最高,可見precisionrecall都有不錯的表現,代表這個板分類不錯。

1
2
3
4
5
6
7
8
9
10
11
              precision    recall  f1-score   support

Beauty 1.00 0.89 0.94 18
HatePolitics 0.83 0.75 0.79 20
NBA 0.96 0.80 0.87 30
Stock 0.77 0.89 0.83 57
Tech_Job 0.71 0.71 0.71 17

accuracy 0.83 142
macro avg 0.85 0.81 0.83 142
weighted avg 0.84 0.83 0.83 142

接著,透過confusion matrix(混淆矩陣) 來判斷一下分類結果的好壞。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 計算混淆矩陣
lbl = list(kind_mapping.keys())
arr = confusion_matrix(df_test['boardName'], df_test['ML_Classify'], labels=lbl)

# 繪熱圖
df_cm = pd.DataFrame(arr, index = [i for i in lbl], columns = [i for i in lbl])
# plt.rcParams['font.sans-serif'] = ['Microsoft JhengHei'] # 顯示中文
plt.figure(figsize = (8,5))

sns.heatmap(df_cm, annot=True, cmap="YlGnBu");

plt.ylabel('y_true')
plt.xlabel('y_pred');
plt.tight_layout()

透過熱圖跟classification_report,可以看到Beauty(表特板)的f1-score是最高,因為他的排他性強烈,比較不會有分錯的問題。

此外,Stock(股票板)Tech_Job(科技板)彼此之間會互相誤判,可能原因是這兩種分類的特徵是互相耦合的。

再者,因為Stock(股票板)的資料量較多,模型會學到特別多關於Stock(股票板)的資訊,導致容易把其他類別誤判成是Stock(股票板)

最後的最後,就是回頭看一下資料裏頭到底是那些文章被分錯

1
df_test[df_test['boardName']!=df_test['ML_Classify']]

其中有一筆資料長這樣:

Title boardName ML_Classify ML_Classify_prob
[新聞] 生涯總薪資達4.35億美元 詹皇躍居史上第一 NBA Stock [[ 0.74948156 83.570015 0.8006586 0.31720236 14.562643 ]]

可能是因為談到薪資和美元的關係才把NBA誤分成Stock。仔細看一下分類後的各類別機率,雖然第一名分錯但NBA是排名在分類的第二名,所以整體來說分類表現還算差強人意。

後記

  1. 資料前處理的時候,有試過把[新聞][請益]…等等字樣透過正則表達式去剔除,想說這樣應該比較不會讓模型學壞,結果…

    1
    2
    regex = r'[[\w]*]'
    df['Title'].replace(to_replace=regex, value='', regex=True, inplace=True)

    事實證明,準確率掉到79.5%…

    可見這些資料對於模型訓練其實是有幫助的。

  2. XGboost有結合cross-validation的套件可以使用,理論上表現會更好,但因為這裡資料量太少,所以做cross-validation的效益看不出來,對於準確率來說無法有效提升。

    XGboost + CV(Cross-Validation)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    params = {}
    params['objective'] = 'multi:softprob' # 多分類
    params['num_class'] = len(df_test_origin['boardName'].unique())

    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, 100, watchlist, early_stopping_rounds=100, verbose_eval=10)
    y_pred = bst.predict(xgboost.DMatrix(x_valid))

    # 手動計算準確率
    cnt = 0
    for i in range(len(y_valid)):
    if y_valid.iloc[i] == kind_mapping[find_maxProb(y_pred[i])]:
    cnt+=1

    acc = cnt/len(y_valid)
    print('Accuracy: ',acc)

    [0] train-merror:0.26316 valid-merror:0.29825
    Multiple eval metrics have been passed: ‘valid-merror’ will be used for early stopping.

    Will train until valid-merror hasn’t improved in 100 rounds.
    [10] train-merror:0.11842 valid-merror:0.24561
    [20] train-merror:0.05482 valid-merror:0.21930
    [30] train-merror:0.03070 valid-merror:0.21930
    [40] train-merror:0.01974 valid-merror:0.21053
    [50] train-merror:0.01097 valid-merror:0.19298
    [60] train-merror:0.01097 valid-merror:0.20175
    [70] train-merror:0.00877 valid-merror:0.20175
    [80] train-merror:0.00877 valid-merror:0.20175
    [90] train-merror:0.00877 valid-merror:0.21053
    [99] train-merror:0.00658 valid-merror:0.21053

    Accuracy: 0.7894736842105263

總結

這篇文章主要透過爬蟲去爬取PTT板上的資訊,最後存入sqlite資料庫,再從sqlite資料庫裏頭撈取資料來做機器學習的分類,並比較常見幾種演算法如:KNN、RandomForest、XGboost、SVM的結果,最終整體分類結果尚可接受。

當然除了機器學習以外,也可以用深度學習的模型來做分類,ex: LSTM、BERT…等等模型,但就需要調參,而且要自己設計Network Structure,相對麻煩一點,但能夠客製化分類的模型,也算是一種可以嘗試的方向。

TODO:
後續會再寫個小網站把sqlite資料庫爬取下來的文章資訊透過網頁的方式去查詢,並嘗試做成文字雲或是將分類的模型上傳到網站以便隨時透過UI去測試分類,並寫成部落格文章以供未來的自己查閱。

TO BE CONTINUE…