藏字閣

閱讀時間約 12 分鐘

5677 字

今天想要知道發一篇笑話可以在 PTT Joke 版有多少機率鄉民們會覺得好笑,要怎麼做一個合理的模型?做出這個模型有什麼好處呢?可以寫出 PTT 優文賺 P 幣當名人自我感覺良好、讓鄉民的智慧幫你改作文練文筆 (或者可能是網軍的 KPI)。這篇文章會有點長,會從觀察資料到訓練出一個可以使用的模型,不包含 data pipeline 的建立 (例如定期更新資料、重新訓練模型),不會講模型的原理。本文所有程式碼都會放在 Github

定義指標

做所有事情第一件事就是要確定目標,才不會不小心走偏必須砍掉重練以致浪費一堆時間。這個問題的目標是什麼呢?建立一個模型輸入一篇文章並且預測鄉民認為它好不好笑。好不好笑對於模型來說太抽象了,它聽不懂,所以我們必須給他一個數學定義,因為 Joke 版人數不多,推文數有點少,所以我們定義好笑為推噓相減大於等於 30。

很顯然這是一個深度學習的分類問題,好笑 (推噓相減大於等於 30) 的文章遠少於不好笑的文章,這個情況我們稱之為 imbalance data 不平衡的資料,一個常用的指標是 AUC。簡單來說,AUC 在隨機分類的時候會是 0.5,全部答對的時候會是 1,是一個分數越高越好的指標。

選定單一指標

離題講一下 F1 score。F1 score 是視 precision 跟 recall 一樣重要的調和平均。Precision 的意義是,我們的模型覺得好笑的文章有多少比例是真的好笑的?Recall 的意義是我們的模型覺得好笑的文章佔所有好笑的文章多少比例?為什麼不只看 precision 或只看 recall,硬要用一個奇怪的公式把它們平均起來?

方法precisionrecall
A95%80%
B90%90%

這時候該說 A 好還是 B 好?最好的方式,是將所有在意的指標揉成一個數字,比如說這邊使用 F1 score 便是一種方式。如果你比較在乎 precision,可以調大 F score 中 precision 的權重,或是任何符合需求的指標。但是最好只看一個數字,不然兩個數字互相衝突的時候或不知道該相信哪一個。尤其當你同時在乎 precision 跟 recall 的時候,這兩個指標通常會是彼此衝突的,最好是合成 F1 score 來處理。

回到好笑預測,使用推噓相減會隨著時間有所增減,所以我們使用發文七天後累積的推文數當作我們的計算的基準。這樣做的意義是假設我們文章發出去七天內都會被看到,如果七天內連 30 推都沒有就真的很難笑。如果是不同的情況,比如說 PTT 八卦版文章很多,多數使用者不會掃過七天內的所有文章,可能只會往前掃 100 篇文章,那這個標準就可以改成發文後出現一百篇後續文章的時間內多少推當作我們的目標。

指標必須要隨著應用的不同做調整,要跟總目標 (發大財) 一致,在這裡就是一篇文章是可以在 PTT Joke 版七天內大於等於 30 推 (推噓相減) 的 AUC。先完全釐清總目標,再來根據這個總目標決定一個具體的模型衡量的指標。

觀察資料

取得資料的方式隨著問題不同,PTT Joke 版可以直接到 PTT 爬取文章。爬下來以後,簡單看一下一些統計值作為之後設計模型的參考。扣掉一些可能因為文章編輯過造成爬取失敗的文章,PTT Joke 版在 2019 一月到 2020 六月總共有 19644 篇文章,其中發文七天內推噓相減大於等於 30 的文章有 2442 篇,只佔了 12.43%。這是一個不太平衡的資料。

推噓相減的推文數分佈如下:

累積百分比0%10%20%30%40%50%60%70%80%90%100%
推文數-500-4-2-1-10138441370

其中平均推文數是 13.88,標準差是 53.53。可以看到大部分的文章都在十推內,極端值非常極端。

這邊是每個分類的文章數跟平均推文數:

分類猜謎XD耍冷笑話趣事趣圖影音地獄豪洨
文章數6055388434181873110595259145199
比例30.8%19.8%17.4%9.5%5.6%4.8%3.0%2.2%0.5%
平均推文2.6824.389.688.6212.2125.5213.776.0819.40

有些文章沒有分類,剩下的是公告跟篇數小於 20 篇的份累,就不列上來了。可以看到類別還是有差的,猜謎通常都不好笑。其實「趣圖」跟「影音」都是轉貼連結到 PTT,好不好笑跟內文本身沒有太大的關係,跟連結連到的內容比較有關。這邊當文字訓練的話,學到的是這個連結的內容好不好笑,不是 PTT 的內文好不好笑。不過這邊我就偷懶一下,直接把網址當內文訓練,因為網址用的單字也有意義,會學到哪個網站的內容通常會比較好笑。

其實還有很多可以觀察的資料,比如說,是否有哪個作者推文數特別多,平均推文數有沒有隨著時間改變等等,這邊我再偷懶一次,看到這邊就好。觀察資料主要是為了之後在設計模型或是調整模型的時候可以比較有靈感,大概知道往哪個方向調整會讓模型變好。

線下模型驗證

這邊使用 2019 年一月到 2020 年六月的資料來預測七月的資料。這樣做是因為模擬真實世界的情況,假設今天是六月三十日,可以用來訓練的資料就是六月三十日以前的所有資料,模擬訓練好的模型七月才開始使用的情形。

在真實世界的情況當中,我們一筆資料只有一次的預測機會,錯了就是錯了。今天寫了一篇文章模型覺得會被推爆,但是發出去一但被噓爆就救不回來了,就算刪掉重發還是有第三方的網站備份,一定會被鄉民發現。所以我們做線下模型驗證的目的就是我們希望有一個方式,能夠在模型上線之前先猜一下上線之後的成效。能夠一開始就上一個好的模型,不用一邊看爛掉的成效一邊調整。

具體來說要怎麼做呢?我們要把可以得到的資料 (2019 一月到 2020 年六月) 分成三份,這邊姑且稱作訓練集 (training set)、驗證集 (validation set)、測試集 (test set)。其中訓練集用來訓練模型,用驗證集的成效來調整模型的參數,最後用測試集的成效來衡量現在這個模型這組參數好不好。切分資料的時候要記得一件事情:我們目標是希望模型上線前就可以先猜到上線後的成效,所以測試集的資料分佈要跟線上的情形一樣。而且訓練模型的時候不可以用任何方式偷看到測試集的資料。在我們這個例子,是用 2019 年一月到 2020 年六月的資料來預測七月的資料,所以測試集對驗證集跟訓練集來說就要是未來的資料,因為七月對 2019 年一月到 2020 年六月是未來的時間。一個可行的資料切分方式就是用 2019 年一月到 2020 年二月的資料做訓練集、2020 年三、四月的資料做驗證集、五月跟六月的資料做測試集。要注意驗證集跟測試集時間上也要分開,不能五月六月的資料隨機一半做驗證集一半做測試集。因為影響推文數的文章主題可能會隨著時間不同,拿跟測試集同時間的文章做驗證,某種程度上來說就偷看了五月六月的熱門主題,提高了 overfitting 的風險。

但是模型訓練完以後,拿二月以前的資料訓練的模型去預測七月的文章,可能也隔太久了會影響成效。這邊一個可能的作法是,確定參數以後,用那個參數在六月十五日以前的資料上面做訓練,訓練完以後在六月十六日到六月三十日的資料做測試,如果成效沒有差太多,那就用這個模型上線,這樣的話就只相隔兩週。當然兩週的資料做出來的驗證 variance 就會比用兩個月的資料做出來的大,不過用兩個月的資料做驗證,模型看過得資料就離線上的資料更遠,這之間就是一個權衡,很難說怎麼做會比較好。

選擇模型

現在自然語言處理最強的模型一定是用深度學習,有預訓練模型的話一定要用預訓練模型。不過自然語言處理也有很多種架構,應該要選哪一個呢?一個是用 Google 找,另一個方式可以到 Papers with Code,他蒐集了各種不同資料上面,每篇論文的表現,如果有程式碼的話也會附上。我們要預測推文數,最接近的就是 text classification,只要把 classification 輸出改成二維實數分別代表好笑跟不好笑,然後用 Cross Entropy 當 loss function 來訓練就可以了。這邊選擇 Cross Entropy 最重要的原因就是,它跟我們定義的指標是一致的,當 Cross Entropy 做得越好的時候,AUC 通常也會越好。所以模型在最佳化 Cross Entropy loss 的時候,同時也是在最佳化我們觀察的指標。因為我們是根據推文數問題的最終目的來選擇指標,所以模型在最佳化 loss 的時候,同時也是在最佳化我們的最終目的。 模型的話,因為在 Papers with Code 網站上看到多組文字分類的資料上 BERT 都表現不錯,而且他有多語言 (包含繁體中文) 的預訓練模型,所以我們就使用 BERT。

PyTorch 實作推文預測模型

選定模型跟指標以後,就可以來實作我們的模型。除了 Google 有釋出 TensorFlow 1.x 版的程式碼跟 pretrained weight,不過我覺得 HuggingFace 的 PyTorch 版本在實作上比 TensorFlow 更友善,所以我們這邊就用 PyTorch。

實作一個 PyTorch 訓練模型的程式碼需要實作以下幾個部份:

  1. 處理資料的 Dataset class。
  2. 模型架構的 class。
  3. 訓練的程式碼 。

處理資料的 Dataset class

import arrow
import json
import numpy as np

from torch.utils.data import Dataset

class TextDataset(Dataset):
    def __init__(self, start_date, end_date, tokenizer, board='joke', max_len=256):
        self.feature = []
        self.label = []
        path = 'data/%s/line.json' % board
        # 讀取資料,每篇文章是一行 JSON
        for line in open(path):
            d = json.loads(line)
            atime = arrow.get(d['time']).shift(hours=8)
            if start_date <= atime < end_date:
                # 計算七天內推噓相減
                push = 0
                for r in d['Responses']:
                    if r['ResponseTime'] - d['time'] > 86400 * 7:
                        continue
                    if r['Vote'] == '推':
                        push += 1
                    elif r['Vote'] == '噓':
                        push -= 1
                
                # 使用標題 + 內文當作 feature
                # 將標題與內文切成 token list
                tokens = ['CLS'] + tokenizer.tokenize(d['Title']) + ['SEP'] + tokenizer.tokenize(d['Content']) + ['SEP']
                # 將文字轉成 id
                token_ids = tokenizer.convert_tokens_to_ids(tokens)
                
                # 將 feature 固定在長度為 max_len,太長切掉,太短補 0
                if len(token_ids) > max_len:
                    token_ids = token_ids[:max_len]
                else:
                    token_ids += [0] * (max_len - len(token_ids))
                self.feature.append(token_ids)
                self.label.append(int((push >= 30)))
        self.feature = np.array(self.feature)
        self.label = np.array(self.label)
        logging.info('#pos %d #neg %d', np.sum(self.label), len(self.label) - np.sum(self.label))

    # 取得第 idx 筆資料
    def __getitem__(self, idx):
        return self.feature[idx], self.label[idx]

    # dataset 的長度
    def __len__(self):
        return len(self.label)

資料處理需要繼承 Dataset class,必須實作 constructor__getitem____len__ 三個 function。這個 class 會一次讀取一筆資料,外面會包一個 DataLoader 的 class,利用 Dataset class,一次讀取一個 batch 的資料。

模型架構的 class

HuggingFace 做得很好用,三行程式就可以完成模型。

# pretrained = bert-base-multilingual-cased
tokenizer = BertTokenizerFast.from_pretrained(pretrained)
config = BertConfig.from_pretrained(pretrained, num_labels=2)
model = BertForSequenceClassification.from_pretrained(pretrained, config=config)

我們知道 deep learning 數學模型需要向量作為輸入才有辦法訓練,那文字要怎麼轉成向量呢?這時候就需要 tokenizer 幫我們把句子切成 token list 餵給模型。比如說「如何訓練 deep learning model」這句話要怎麼轉成向量?tokenizer 會先把他斷開:

toekn_list = tokenizer.tokenize('如何訓練 deep learning model')
print(toekn_list)
# ['如', '何', '訓', '練', 'deep', 'learning', 'model']

然後再轉成 embedding index,每個 index 會對應到一個向量,而這個向量的數值是學出來的。

print(tokenizer.convert_tokens_to_ids(toekn_list))
# [3241, 2253, 7174, 6261, 26591, 26901, 13192]

通常,我們會在開頭加上 CLS token,分隔跟結尾使用 SEP token,如同我們在 TextDataset實作的方式。

config 用來設定模型的架構,num_labels=2 表示我們要預測是、否推文大於等於 30 共兩個 class。model 使用 bert-base-multilingual-cased 作為多語言的 pretrained weight,根據 Google 的說法,他用了 wikipedia 最大的 100 種語言做預訓練,其中當然包含了繁體中文。

訓練的程式碼

def train_epoch(train_loader, model, optimizer, criterion):
    running_loss = 0.0
    y_pred = []
    y_true = []
    # 訓練模式
    model.train()
    # 逐 batch 讀出 feature 及 label
    for feature, label in tqdm(train_loader):
        # batch 中最長的長度
        length = feature.numpy().max(axis=0).nonzero()[0][-1] + 1
        feature = feature[:, :length]
        # mask 指出有 token 的部份,feature 補 0 的部份在 mask 也為 0
        mask = torch.tensor(feature.numpy() > 0, dtype=torch.int64).to(device)
        # 將資料搬到 GPU
        feature = feature.to(device)
        label = label.to(device)

        # optimizer 的 gradient 歸零
        optimizer.zero_grad()
        output = model(input_ids=feature, attention_mask=mask)[0]
        # 計算 loss
        loss = criterion(output, label)
        # 計算 gradient
        loss.backward()
        # 更新 model weight
        optimizer.step()

        running_loss += loss.item()
        y_pred.append(torch.argmax(output, axis=1).cpu().numpy())
        y_true.append(label.cpu().numpy())
    y_pred = np.concatenate(y_pred)
    y_true = np.concatenate(y_true)
    train_loss = running_loss / len(train_loader.dataset)
    return train_loss, accuracy_score(y_true, y_pred), roc_auc_score(y_true, y_pred)


def eval_epoch(valid_loader, model, criterion):
    running_loss = 0.0
    y_pred = []
    y_true = []
    # 預測模式
    model.eval()
    # 逐 batch 讀出 feature 及 label
    for feature, label in tqdm(valid_loader):
        # batch 中最長的長度
        length = feature.numpy().max(axis=0).nonzero()[0][-1] + 1
        feature = feature[:, :length]
        # mask 指出有 token 的部份,feature 補 0 的部份在 mask 也為 0
        mask = torch.tensor(feature.numpy() > 0, dtype=torch.int64).to(device)
        # 將資料搬到 GPU
        feature = feature.to(device)
        label = label.to(device)

        # 告訴 PyTorch 我們不計算 gradient,可以節省 GPU 記憶體
        with torch.no_grad():
            output = model(input_ids=feature, attention_mask=mask)[0]
            # 計算 loss
            loss = criterion(output, label)

        running_loss += loss.item()
        y_pred.append(torch.argmax(output, axis=1).cpu().numpy())
        y_true.append(label.cpu().numpy())
    y_pred = np.concatenate(y_pred)
    y_true = np.concatenate(y_true)
    valid_loss = running_loss / len(valid_loader.dataset)
    return valid_loss, accuracy_score(y_true, y_pred), roc_auc_score(y_true, y_pred)


def load_checkpoint(name, model, optimizer):
    best_loss = np.inf
    best_epoch = -1
    start_epoch = 0
    scores = {
        'train_loss': [],
        'train_acc': [],
        'train_auc': [],
        'valid_loss': [],
        'valid_acc': [],
        'valid_auc': [],
    }
    # 如果有之前訓練到一半的,從最好的那個接續訓練
    path = os.path.join('checkpoints', name, 'best.bin')
    if os.path.exists(path):
        checkpoint = torch.load(path)
        model.load_state_dict(checkpoint['model_state'])
        optimizer.load_state_dict(checkpoint['optimizer_state'])
        scores = checkpoint['scores']
        best_epoch = len(scores['valid_loss']) - 1
        best_loss = scores['valid_loss'][-1]
        start_epoch = best_epoch + 1
    return model, optimizer, start_epoch, best_epoch, best_loss, scores

optimizer = AdamW(model.parameters(), lr=lr)
model, optimizer, start_epoch, best_epoch, best_loss, scores = load_checkpoint(conf['name'], model, optimizer)

criterion = CrossEntropyLoss()
train_loader = DataLoader(
    TextDataset(arrow.get('20190101', 'YYYYMMDD'), arrow.get('20200301', 'YYYYMMDD'), tokenizer),
    batch_size=train_batch_size, shuffle=True)
valid_loader = DataLoader(
    TextDataset(arrow.get('20200301', 'YYYYMMDD'), arrow.get('20200501', 'YYYYMMDD'), tokenizer),
    batch_size=eval_batch_size, shuffle=False)

for epoch in range(start_epoch, epochs):
    logging.info('training epoch %d', epoch)
    # 訓練模型以及在 validation set 上計算分數
    train_loss, train_acc, train_auc = train_epoch(train_loader, model, optimizer, criterion)
    valid_loss, valid_acc, valid_auc = eval_epoch(valid_loader, model, criterion)
    logging.info('Train loss %f Train acc %f Train auc %f', train_loss, train_acc, train_auc)
    logging.info('Validation loss %f Validation acc %f Validation auc %f', valid_loss, valid_acc, valid_auc)
    scores['train_loss'].append(train_loss)
    scores['train_acc'].append(train_acc)
    scores['train_auc'].append(train_auc)
    scores['valid_loss'].append(valid_loss)
    scores['valid_acc'].append(valid_acc)
    scores['valid_auc'].append(valid_auc)
    # 紀錄這個 epoch 的分數及模型
    torch.save({
        'epoch': epoch,
        'best_loss': best_loss,
        'scores': scores,
        'model_state': model.state_dict(),
        'optimizer_state': optimizer.state_dict(),
    }, os.path.join(checkpoint_dir, '%d.bin' % epoch))
    if valid_loss < best_loss:
        # validation loss 最好的 epoch 就是我們要使用的最好的模型
        logging.info('save model with best loss at epoch %d', epoch)
        best_loss = valid_loss
        best_epoch = epoch
        shutil.copy2(checkpoint_dir + '%d.bin' % epoch, checkpoint_dir + 'best.bin')
    if epoch - best_epoch >= patient:
        logging.info('Epoch %d not improving from best_epoch %d beark!', epoch, best_epoch)
        break

希望註解足夠清楚讓大家了解程式碼的內容。

實驗結果

DatasetLoss準確度AUC
train0.02390.8%0.671
validation0.01191.3%0.704
test0.00993.5%0.770

經驗來說,AUC 做到 0.7 算是一個堪用的結果,沒有到 Papers with code 上面看到的 95%、96% 準確率也很合理,因為笑話是出乎意料的邏輯轉折才會讓人覺得好笑,這個不是用維基百科做預訓練可以學到的東西,對於不同母語的人來說都很困難了 (有時候台灣人也不太懂美國人的笑點,反之亦然),更何況是要機器來做這件事情。不過具體來說 AUC 多少的模型可以用,還是要根據你的應用來決定。如果應用是人臉辨識解鎖門禁,那 precision 就應該要做到近乎 1,可能要做到 0.99999,不然放錯人進家門就糟了。如果應用是網頁廣告點擊的預測,那通常推出來的廣告都不太會被點,每次廣告的成本也都很便宜,推幾千次被點一次可能就會賺錢了,那麼點擊率只有 1% 也沒關係。

模型調整

因為我們需要預訓練的權重成效才會好,所以模型架構只能拿現成的,沒有什麼可以調整的空間。

參數的話,通常 deep learning 會需要調 learning rate (包含 weitgh decay),learning rate 太大會訓練不出東西,太小會跑很久。我這邊使用 1e-6 也是調出來的結果。一個簡單的方式就是先一次調十倍,找到最好的參數以後再細調。像是先調 1, 0.1, 0.01, 0.001……假設找出來最好的是 0.001 跟 0.0001,那再來調它們之間的數值,這樣調會比較有效率。那這邊「好」的意思,除了 training loss 要越來越低以外 (通常可以作到),更重要的是指 validation loss 的好,用 validation loss 來比較哪個參數好。

結論

除了參數以外,要讓模型表現更好,最好的方式還是從資料下手。最簡單的就是爬更多文章得到更多資料,或是做 data augmentation,用現有文章做出更多筆不太一樣但是依然可以訓練的資料。或是加入其他 feature,比如說作者、發文時間等等,可以跟最後一層 embedding 做 concatenate,後面再接 fully conected layer。而且通常會把分錯的資料拿出來看,看看是什麼原因分錯,有沒有什麼調整模型的靈感 (雖然大部分的時候都沒有)。不過這些再講下去就太多太複雜了,照著這個流程就可以訓練出一個合理的模型了,所以今天講到這邊就好。

除了文字分類,HuggingFace 也提供很多種模型輸出的格式,可以用在各式各樣的應用上。比如說 BERT 他還有 BertForMultipleChoiceBertForTokenClassificationBertForQuestionAnswering 可以使用。Pretrained model 的出現真的是大大降低了訓練模型的困難度,大部份的人都可以輕易地訓練出一個成效不錯的自然語言處理的模型,這個在 deep learning 出現之前是很難作到的。除了大公司,一般人也幾乎不可能從 0 開始訓練出預訓練模型,也就沒有辦法調整架構,所以成效的關鍵就在對機器學習的觀念掌握了。

參考資料

HuggingFace Transformers

comments powered by Disqus

最新文章

分類

標籤