Task01 筆記

zakarRoman發表於2024-07-14

Task01筆記

由比賽任務可知我們的任務是一個機器翻譯任務,它是在術語字典的干預下進行的,透過術語詞典的干預可以獲得更好的結果。

很感謝datawhale提供的baseline,在這篇筆記中我首先會對baseline進行分析,分享我的理解,並在這個過程中穿插一些我掌握的nlp相關知識

1. 資料集類的定義

繼承了torch中的dataset類,定義了TranslationDataset類來處理英中翻譯的資料集,主要包括以下功能:

  • 讀取並解析檔案中的英中句子對。
  • 建立詞彙表,即token(包含術語詞典中的詞)。
  • 透過token將中英文句子對轉換為張量以便於輸入模型進行訓練。
class TranslationDataset(Dataset):
    def __init__(self, filename, terminology):
        # ...
        self.data = []
        with open(filename, 'r', encoding='utf-8') as f:
            for line in f:
                en, zh = line.strip().split('\t')
                self.data.append((en, zh))
        
        self.terminology = terminology
        
        # 建立詞彙表,確保術語詞典中的詞也被包含在詞彙表中
        self.en_tokenizer = get_tokenizer('basic_english')
        self.zh_tokenizer = list  # 使用字元級分詞
        
        en_vocab = Counter(self.terminology.keys())
        zh_vocab = Counter()
        
        for en, zh in self.data:
            en_vocab.update(self.en_tokenizer(en))
            zh_vocab.update(self.zh_tokenizer(zh))
        
        self.en_vocab = ['<pad>', '<sos>', '<eos>'] + list(self.terminology.keys()) + [word for word, _ in en_vocab.most_common(10000)]
        self.zh_vocab = ['<pad>', '<sos>', '<eos>'] + [word for word, _ in zh_vocab.most_common(10000)]
        
        self.en_word2idx = {word: idx for idx, word in enumerate(self.en_vocab)}
        self.zh_word2idx = {word: idx for idx, word in enumerate(self.zh_vocab)}

    def __len__(self):
        return len(self.data)

    def __getitem__(self, idx):  # 獲取中英文句子對以及索引
        en, zh = self.data[idx]
        en_tensor = torch.tensor([self.en_word2idx.get(word, self.en_word2idx['<sos>']) for word in self.en_tokenizer(en)] + [self.en_word2idx['<eos>']])
        zh_tensor = torch.tensor([self.zh_word2idx.get(word, self.zh_word2idx['<sos>']) for word in self.zh_tokenizer(zh)] + [self.zh_word2idx['<eos>']])
        return en_tensor, zh_tensor

def collate_fn(batch):
    en_batch, zh_batch = [], []
    for en_item, zh_item in batch:
        en_batch.append(en_item)
        zh_batch.append(zh_item)
    
    en_batch = nn.utils.rnn.pad_sequence(en_batch, padding_value=0, batch_first=True)
    zh_batch = nn.utils.rnn.pad_sequence(zh_batch, padding_value=0, batch_first=True)
    
    return en_batch, zh_batch

2. 模型定義

定義了Encoder、Decoder和Seq2Seq模型。

解碼器和編碼器是解決序列問題的經典模型,Seq2Seq(Seq to Seq)模型是解碼器-編碼器結構的進一步改進。

RNN(Recurrent Neural Network),即迴圈神經網路,是一種適用於序列資料的神經網路,透過迴圈連線處理輸入序列。編碼器-解碼器結構用於序列到序列任務中,編碼器將輸入序列編碼為固定長度的上下文向量,解碼器根據該向量生成輸出序列。

GRU(Gated Recurrent Unit),即門控單元,是RNN的一種變體,透過更新門和重置門控制資訊流動,解決了傳統RNN中的梯度消失問題。LSTM(Long Short-Term Memory)是另一種RNN變體,包含輸入門、遺忘門和輸出門,透過這些門來儲存和更新長期資訊,更好地捕捉長距離依賴。

RNN適用於序列處理,但在長序列中存在梯度消失問題;GRU和LSTM透過門機制緩解該問題,廣泛用於NLP等領域。

class Encoder(nn.Module):
    def __init__(self, input_dim, emb_dim, hid_dim, n_layers, dropout):
        super().__init__()
        self.embedding = nn.Embedding(input_dim, emb_dim)
        self.rnn = nn.GRU(emb_dim, hid_dim, n_layers, dropout=dropout, batch_first=True)
        self.dropout = nn.Dropout(dropout)

    def forward(self, src):
        embedded = self.dropout(self.embedding(src))
        outputs, hidden = self.rnn(embedded)
        return outputs, hidden

class Decoder(nn.Module):
    def __init__(self, output_dim, emb_dim, hid_dim, n_layers, dropout):
        super().__init__()
        self.output_dim = output_dim
        self.embedding = nn.Embedding(output_dim, emb_dim)
        self.rnn = nn.GRU(emb_dim, hid_dim, n_layers, dropout=dropout, batch_first=True)
        self.fc_out = nn.Linear(hid_dim, output_dim)
        self.dropout = nn.Dropout(dropout)

    def forward(self, input, hidden):
        embedded = self.dropout(self.embedding(input))
        output, hidden = self.rnn(embedded, hidden)
        prediction = self.fc_out(output.squeeze(1))
        return prediction, hidden

class Seq2Seq(nn.Module):
    def __init__(self, encoder, decoder, device):
        super().__init__()
        self.encoder = encoder
        self.decoder = decoder
        self.device = device

    def forward(self, src, trg, teacher_forcing_ratio=0.5):
        batch_size = src.shape[0]
        trg_len = trg.shape[1]
        trg_vocab_size = self.decoder.output_dim

        outputs = torch.zeros(batch_size, trg_len, trg_vocab_size).to(self.device)
        
        _, hidden = self.encoder(src)
        
        input = trg[:, 0].unsqueeze(1)  # Start token
        
        for t in range(1, trg_len):
            output, hidden = self.decoder(input, hidden)
            outputs[:, t, :] = output
            teacher_force = random.random() < teacher_forcing_ratio
            top1 = output.argmax(1)
            input = trg[:, t].unsqueeze(1) if teacher_force else top1.unsqueeze(1)

        return outputs

Seq2Seq類中運用到了Teacher Forcing技術,它是一種在訓練序列到序列(seq2seq)模型時使用的技術。它透過將目標序列的真實值作為解碼器的下一步輸入,而不是使用模型的預測值來加速訓練過程。

在沒有Teacher Forcing的情況下,模型生成每個時間步的輸出,並將其用作下一個時間步的輸入。這種方法可能導致誤差累積,因為每個時間步的預測誤差會影響後續時間步的預測。

Teacher Forcing透過使用真實目標值作為下一步的輸入,確保解碼器在每個時間步都接收到正確的上下文資訊。這可以顯著加快訓練速度和穩定性。然而,這也會使模型在推理時面臨更大的挑戰,因為它必須在沒有真實目標值作為輸入的情況下生成整個序列。因此,在實際應用中,通常會在訓練過程中逐漸減少Teacher Forcing的比例,以幫助模型適應不依賴真實值輸入的生成過程。

3. 術語詞典載入

定義了一個函式load_terminology_dictionary來載入術語詞典。

def load_terminology_dictionary(dict_file):
    terminology = {}
    with open(dict_file, 'r', encoding='utf-8') as f:  # 開啟術語詞典進行讀寫
        for line in f:
            en_term, ch_term = line.strip().split('\t')
            terminology[en_term] = ch_term
    return terminology

4. 訓練函式

定義了一個train函式,用於訓練模型。

def train(model, iterator, optimizer, criterion, clip):  # 函式中傳入了一個生成器
    model.train()
    epoch_loss = 0
    for i, (src, trg) in enumerate(iterator):
        src, trg = src.to(device), trg.to(device)
        optimizer.zero_grad()  # 清空梯度
        output = model(src, trg)
        output_dim = output.shape[-1]
        output = output[:, 1:].contiguous().view(-1, output_dim)
        trg = trg[:, 1:].contiguous().view(-1)
        loss = criterion(output, trg)
        loss.backward()
        torch.nn.utils.clip_grad_norm_(model.parameters(), clip)
        optimizer.step()
        epoch_loss += loss.item()
    return epoch_loss / len(iterator)

5. 主程式

初始化資料集、模型、最佳化器和損失函式,進行模型訓練並儲存模型引數。

if __name__ == '__main__':
    start_time = time.time()  # 開始計時

    # 如果是有GPU的環境,就可以使用cuda進行訓練加速
    device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')

    terminology = load_terminology_dictionary('../dataset/en-zh.dic')

    # 載入資料
    dataset = TranslationDataset('../dataset/train.txt', terminology=terminology)
    N = 1000  # 選擇資料集的前N個樣本進行訓練,也可以修改為len(dataset)
    subset_indices = list(range(N))
    subset_dataset = Subset(dataset, subset_indices)
    train_loader = DataLoader(subset_dataset, batch_size=32, shuffle=True, collate_fn=collate_fn)

    # 定義模型超引數
    INPUT_DIM = len(dataset.en_vocab)
    OUTPUT_DIM = len(dataset.zh_vocab)
    ENC_EMB_DIM = 256
    DEC_EMB_DIM = 256
    HID_DIM = 512
    N_LAYERS = 2
    ENC_DROPOUT = 0.5
    DEC_DROPOUT = 0.5

    # 初始化模型
    enc = Encoder(INPUT_DIM, ENC_EMB_DIM, HID_DIM, N_LAYERS, ENC_DROPOUT)
    dec = Decoder(OUTPUT_DIM, DEC_EMB_DIM, HID_DIM, N_LAYERS, DEC_DROPOUT)
    model = Seq2Seq(enc, dec, device).to(device)

    # 定義最佳化器和損失函式
    optimizer = optim.Adam(model.parameters())
    criterion = nn.CrossEntropyLoss(ignore_index=dataset.zh_word2idx['<pad>'])

    # 訓練模型
    N_EPOCHS = 10
    CLIP = 1

    for epoch in range(N_EPOCHS):
        train_loss = train(model, train_loader, optimizer, criterion, CLIP)
        print(f'Epoch: {epoch+1:02} | Train Loss: {train_loss:.3f}')
        
    # 儲存模型
    torch.save(model.state_dict(), './translation_model_GRU.pth')
    
    end_time = time.time()  # 結束計時

    # 計算並列印執行時間
    elapsed_time_minute = (end_time - start_time)/60
    print(f"Total running time: {elapsed_time_minute:.2f} minutes")

6. BLEU

BLEU (Bilingual Evaluation Understudy) 是一種用於評估機器翻譯質量的指標,透過計算候選譯文與參考譯文之間的 n-gram 重合度來衡量翻譯的準確性。BLEU 分數介於 0 到 1 之間,通常表示為百分比形式,分數越高表示翻譯質量越好。

BLEU 評分的計算過程
  1. n-gram 重合度
    BLEU 評分基於 n-gram 重合度,即候選譯文和參考譯文中 n-gram 的重疊程度。n-gram 是長度為 n 的詞序列,例如 "the cat" 是一個 2-gram,"the cat sat" 是一個 3-gram。

  2. 精確度 (Precision)
    計算候選譯文中每個 n-gram 與參考譯文中相應 n-gram 的匹配個數,然後除以候選譯文中的 n-gram 總數,得到每個 n-gram 的精確度。

    \( P_n = \frac{\sum_{ngram \in \text{候選譯文}} \min(\text{候選譯文中的 n-gram 計數}, \text{參考譯文中的 n-gram 計數})}{\sum_{ngram \in \text{候選譯文}} \text{候選譯文中的 n-gram 計數}} \)

  3. BP (Brevity Penalty)
    為了防止候選譯文過短,BLEU 評分引入了長度懲罰項。若候選譯文長度小於參考譯文,BP 會降低 BLEU 分數。

    [\(BP = \begin{cases} 1 & \text{如果候選譯文長度 > 參考譯文長度} \\ e^{1-\frac{\text{參考譯文長度}}{\text{候選譯文長度}}} & \text{如果候選譯文長度 <= 參考譯文長度} \end{cases}\)]

  4. BLEU 分數
    最終的 BLEU 分數透過將不同 n-gram 的精確度取幾何平均後,再乘以 BP 計算得出。

    \( \text{BLEU} = BP \cdot \exp \left( \sum_{n=1}^{N} w_n \log P_n \right) \)

    其中 (\(w_n\)) 是每個 n-gram 的權重,通常設定為均勻分佈,即 (\(w_n = \frac{1}{N}\))。

透過計算模型在驗證集上的 BLEU 評分,可以衡量模型的翻譯質量並指導模型的改進。定義了一個evaluate_bleu函式來計算模型在開發集上的BLEU評分。

import torch
from sacrebleu.metrics import BLEU
from typing import List

def load_sentences(file_path: str) -> List[str]:
    with open(file_path, 'r', encoding='utf-8') as f:


        return [line.strip() for line in f]

def evaluate_bleu(model, dataset, dataloader):
    model.eval()
    references, candidates = [], []
    with torch.no_grad():
        for src, trg in dataloader:
            src, trg = src.to(model.device), trg.to(model.device)
            output = model(src, trg, teacher_forcing_ratio=0)
            output_dim = output.shape[-1]
            output = output.argmax(dim=-1)
            for i in range(len(trg)):
                references.append([dataset.zh_tokenizer(trg[i])])
                candidates.append([dataset.zh_tokenizer(output[i])])
    bleu = BLEU()
    return bleu.corpus_score(candidates, references)

if __name__ == '__main__':
    terminology = load_terminology_dictionary('../dataset/en-zh.dic')
    dataset = TranslationDataset('../dataset/valid.txt', terminology=terminology)
    valid_loader = DataLoader(dataset, batch_size=32, shuffle=False, collate_fn=collate_fn)

    INPUT_DIM = len(dataset.en_vocab)
    OUTPUT_DIM = len(dataset.zh_vocab)
    ENC_EMB_DIM = 256
    DEC_EMB_DIM = 256
    HID_DIM = 512
    N_LAYERS = 2
    ENC_DROPOUT = 0.5
    DEC_DROPOUT = 0.5

    enc = Encoder(INPUT_DIM, ENC_EMB_DIM, HID_DIM, N_LAYERS, ENC_DROPOUT)
    dec = Decoder(OUTPUT_DIM, DEC_EMB_DIM, HID_DIM, N_LAYERS, DEC_DROPOUT)
    model = Seq2Seq(enc, dec, device).to(device)
    model.load_state_dict(torch.load('./translation_model_GRU.pth'))

    bleu_score = evaluate_bleu(model, dataset, valid_loader)
    print(f'BLEU score: {bleu_score.score:.2f}')

7. 推理部分

定義了一個translate_sentence函式來翻譯單個句子,並優先使用術語詞典中的術語。

def translate_sentence(sentence, model, dataset, max_len=50):
    model.eval()
    tokens = dataset.en_tokenizer(sentence)
    tokens = [dataset.en_word2idx['<sos>']] + [dataset.en_word2idx.get(token, dataset.en_word2idx['<sos>']) for token in tokens] + [dataset.en_word2idx['<eos>']]
    src_tensor = torch.LongTensor(tokens).unsqueeze(0).to(model.device)
    
    with torch.no_grad():
        encoder_outputs, hidden = model.encoder(src_tensor)
    
    trg_indexes = [dataset.zh_word2idx['<sos>']]
    
    for _ in range(max_len):
        trg_tensor = torch.LongTensor([trg_indexes[-1]]).unsqueeze(0).to(model.device)
        with torch.no_grad():
            output, hidden = model.decoder(trg_tensor, hidden)
        pred_token = output.argmax(1).item()
        trg_indexes.append(pred_token)
        if pred_token == dataset.zh_word2idx['<eos>']:
            break
    
    trg_tokens = [dataset.zh_vocab[i] for i in trg_indexes]
    return trg_tokens[1:-1]  # 返回去除<eos>和<sos>的部分

if __name__ == '__main__':
    terminology = load_terminology_dictionary('../dataset/en-zh.dic')
    dataset = TranslationDataset('../dataset/test.txt', terminology=terminology)

    INPUT_DIM = len(dataset.en_vocab)
    OUTPUT_DIM = len(dataset.zh_vocab)
    ENC_EMB_DIM = 256
    DEC_EMB_DIM = 256
    HID_DIM = 512
    N_LAYERS = 2
    ENC_DROPOUT = 0.5
    DEC_DROPOUT = 0.5

    enc = Encoder(INPUT_DIM, ENC_EMB_DIM, HID_DIM, N_LAYERS, ENC_DROPOUT)
    dec = Decoder(OUTPUT_DIM, DEC_EMB_DIM, HID_DIM, N_LAYERS, DEC_DROPOUT)
    model = Seq2Seq(enc, dec, device).to(device)
    model.load_state_dict(torch.load('./translation_model_GRU.pth'))

    with open('../dataset/test.txt', 'r', encoding='utf-8') as f, open('./output.txt', 'w', encoding='utf-8') as out_f:
        for line in f:
            en_sentence = line.strip()
            zh_sentence = translate_sentence(en_sentence, model, dataset)
            out_f.write(' '.join(zh_sentence) + '\n')