iT邦幫忙

2024 iThome 鐵人賽

DAY 5
1
AI/ ML & Data

一個Kaggle金牌解法是如何誕生的?跟隨Kaggle NLP競賽高手的討論,探索解題脈絡系列 第 5

[Day 5] Kaggle 自動作文評分競賽(四)- 前四名優勝作法解析:彼此制衡的兩階段式微調、發揮奇效的 pseudo-labeling、集成多種 BERT Pooling 與最優化策略

  • 分享至 

  • xImage
  •  

前情提要

昨天我們從主題建模、prompt name 與 score 的分佈等,更全面地探勘本次賽題的訓練資料集;也透過設計一些專用於這種 code competition 、看不到 hidden test set 的比賽所適用的一些小實驗,探索 testset 和 trainset 存在的潛在差異。

今天會介紹的前幾名優勝解法,都有用到這幾天觀察到的一些現象,那就讓我們開始吧!

🎯 優勝解法解析

在研究本題的優勝解法以及討論區時,我發現大部分的做法都圍繞在如何利用兩個不同來源的、具有不同評分標準的 dataset(non-persuade, persuade) 來訓練模型,使得模型可以從資料量較多的 persuade dataset 中學到大量 general 的知識,但又不會 overfit 在它的分數分佈上;學完後,再更好地遷移到關鍵的 non-persuade dataset 上。

為什麼說他是關鍵的 dataset 呢? 因為從前幾天對 dataset 的分析,我們可以下一個小結:
Hidden test set 更有可能和 non-persuade 這個 dataset 有同樣的來源。

4th Solution

第四名1的作者在他的 prior-study 做了一個有趣的小嘗試:

把 Non-Persuade 和 persuade 的 train data 混在一起並分出 train/validation set 之後,訓練一個 deberta 觀察其整體的 QWK 分數,以及在 non-persuade 和 persuade data 上各自的測試分數:

https://ithelp.ithome.com.tw/upload/images/20240919/20152668H6uQzTUiuW.png

可以發現整體 validation set 的 QWK 雖有到 0.82 多,但其實 (non-persuade, persuade) 的評估分數是有差異的:
non-persuade 的表現會比較差。

因此作者就想,既然這兩者可能有不同的分數分佈,那我就明確地區分開來,讓模型為這兩個 dataset 各別學習呢?

於是他在 non-persuade data 的 essay 內容前面加上 [A] 這個識別符號;在 persuade data 的 essay 前面加上 [B] 的識別符號,結果如下:

https://ithelp.ithome.com.tw/upload/images/20240919/20152668Q9otYFc8eM.png

想法很簡單,沒想到效果竟然也不錯!

兩者的 QWK 都提升了。因此可得到一個結論:

顯式地告訴模型這兩個 data 的來源並加以區分,有助於提升模型的準確度。

為了深化這個想法,作者在訓練時,不只會在 essay 前面標示出不同的識別符號,還會讓不同資料來源的 data 使用模型不同的分類頭,作法如下:

model = DebertaModel(config)
    model = ScoreRegressionV1(
        backbone=HuggingFaceModel(model),
        neck=MeanPooling(),
        head=torch.nn.Linear(model.config.hidden_size, 6),
    )
    
class ScoreRegressionV1(nn.Module):
    def __init__(self, backbone, neck, head):
        super().__init__()
        self.backbone = backbone
        self.neck = neck
        self.head = head

    def forward(self, inputs):
        input_ids = inputs["input_ids"]
        attn_mask = inputs["attn_mask"]
        features = self.backbone(input_ids, attn_mask)
        features = self.neck(features, attn_mask)
        logits = self.head(features)
        logits = logits.float()

        data_source = inputs["data_source"]
        logits_score = logits[:, :3]
        // 這邊根據不同的 data_source(0, 1 or 2)選擇 logits 的不同 column 的數值。也就是說每個 data_source 在最後一個分類層上,都有各自對應的 neuron 來負責做預測。(只是這邊只有三個來源,我不知他為何要開 6 個 neuron 但在這邊也只會用到前 3 個而已。)
        logits_score = logits[
            torch.arange(logits_score.size(0), device=logits_score.device),
            data_source,
        ]
        return logits_score

那在 ensemble 模型的時候,他設計了下列 45 種變體,每一次的預測都會參考這 45 個模型預測出的結果,最後取平均後依照下面的 threshold 決定打分要是 1, 2, 3, 4, 5 或是 6。

thresholds = [1.692, 2.528, 3.432, 4.295, 5.082]
pred = pd.cut(
    pred,
    [-np.inf] + thresholds + [np.inf],
    labels=[1, 2, 3, 4, 5, 6],
).astype("int32")

以下是第四名作者1集成的 45 個模型的簡單介紹:

  • microsoft/deberta-large:

    • 在內文前面加上來源的標示符號
    • 不同來源使用不同的 classification head
    • 用 5 folds cross-validation
    • 每個 fold 都用 3 個不同 random seeds 訓練 3 個模型
  • microsoft/deberta-v3-large

    • 在內文前面加上來源的標示符號
    • 不同來源共用一個 classification head
    • 用 5 folds cross-validation
    • 每個 fold 都用 3 個不同 random seeds 訓練 3 個模型
  • Qwen/Qwen2-1.5B-Instruct

    • 在內文前面加上來源的標示符號
    • 加上 instruction
    • 不同來源使用不同的 classification head
    • 用 5 folds cross-validation
    • 每個 fold 都用 3 個不同 random seeds 訓練 3 個模型

除了上述提到的主要解法,他還分享為了加速 train/inference 的速度,並提升訓練的穩定度,作者 1 還使用了一些小技巧:

  • Dynamic Micro Batch Collation: 由於每一篇 essay 的字數差異都比較大,如果剛好一個 batch 裡面只有少數筆essay的長度很長,其他長度都對比較短,就會浪費很多 padding 的空間。因此這邊作者在 每個 batch 又切分出 micro batch,把字數相近的資料放在一起,並設定每一個 micro batch 的最大字數限制。透過 Micro Batch 的設計讓訓練速度更快, Memory Usage 也更穩定。

具體怎麼實現,直接上代碼:
DataLoader 的寫法:

dataloader = torch.utils.data.DataLoader(
        dataset,
        batch_size=16,
        num_workers=2,
        collate_fn=data_utils.MaxTokensMicrobatchCollate(
            max_tokens=4096,
            collate_fn=data_utils.DynamicPadCollate(),
        ),
    )

class MaxTokensMicrobatchCollate:
    def __init__(self, max_tokens, collate_fn, sort_samples=False):
        self.max_tokens = max_tokens
        self.collate_fn = collate_fn
        self.sort_samples = sort_samples

    def __call__(self, samples):
        if self.sort_samples:
            samples = sorted(samples, key=lambda x: x["input_ids"].size(0))
        micro_batches = []
        curr_samples = []
        curr_seq_length = 0
        for next_sample in samples:
            next_seq_length = next_sample["input_ids"].size(0)
            // 因為最後在 padding 文字時,是用 batch 裡面字數最多的 sample 的長度來當作 Padding 的目標,所以這邊 overflow 就是在估算最後 padding 完該個 micro batch 的長度
            overflow = (
                max(next_seq_length, curr_seq_length) * (len(curr_samples) + 1)
                > self.max_tokens
            )
            // 如果已經 overflow 了,而且 curr_samples 是有東西的,就直接加入到 micro _batches 裡面,這輪就收集完成;如果沒有的話,就把該個 sample 加入到 curr_samples 然後繼續下輪收集。
            if overflow and curr_samples:
                micro_batches.extend(self.collate_fn(curr_samples))
                curr_samples = [next_sample]
                curr_seq_length = next_seq_length
            else:
                curr_samples.append(next_sample)
                curr_seq_length = max(curr_seq_length, next_seq_length)
        if curr_samples:
            micro_batches.extend(self.collate_fn(curr_samples))
        return micro_batches

Training Loop:

for batch in dataloader:
    optimizer.zero_grad()
    total_batch_size = sum(micro_batch["batch_size"] for micro_batch in batch)
    for micro_batch in batch:
        micro_batch = to_gpu(micro_batch)
        loss = model(micro_batch)
        // 計算 Micro Batch 的權重比例。
        scale = micro_batch["batch_size"] / total_batch_size
        // 根據比例計算梯度。
        (loss * scale).backward()
    optimizer.step()

Inference Loop:

def predict(model, dataloader):
    results = []
    for batch in tqdm(dataloader):
        for micro_batch in batch:
            micro_batch = data_utils.to_gpu(micro_batch)
            with torch.cuda.amp.autocast(dtype=torch.float16):
                logits = model(micro_batch)
                results.append(logits.cpu().numpy())
    results = np.concatenate(results, axis=0)
  • Faster DeBERTa Implementation: 因為這篇花太多時間寫了,這部分就沒有去特別研究作者的實現方式了。

🥉 3rd Solution

第三名(2)和第四名(1) 一樣,重點放在:
如何在 persuade data 上訓練,然後更好地 transfer 到 non-persuade data 上。
他的想法是分成兩個階段:

  • 階段一:在 non-persuade 和 persuade data 上訓練,但是分出一部分 non-persuade data 出來當作 validation set。根據 model 在 non-perusade data 上評估的分數來選擇要用哪一個 epoch 的模型。這邊作者提到他把原始[1, 2, 3, 4, 5, 6]6個級距的label,使用 sigmoid function 將之轉成介在 0~1 之間的 soft-label,並用 regression MSE 的範式進行訓練。
  • 階段二:Load 階段一訓練好的 model,並用這個 model 在 non-persuade data 上面做預測,再把預測出來的分數以 8:2 的比例,和原始的 ground truth label 用 weighted average 的方式組成新的 pseudo-label。接下來他在 non-persuade data 的 soft label 上繼續訓練階段一的模型,不過現在改成在 persuade data 上監測 validation loss,並根據這個結果執行 early stop 。這一步是要避免模型在第二階段過度 fit 在 non-persuade data 上,所以如果觀測到 persuade data 的 loss 隨著第二階段的訓練越來越大,那可能就是開始 overfitting 需要停下來了。

這兩階段都各自在 persuade 或 non-persuade dataset 上訓練,但都會把另外一個 dataset 當作評估的對象,就好像兩個 dataset 在互相制衡彼此,避免模型陷入 overfitting 的陷阱,過度擬合到任何一個 dataset 的分佈。

第三名的作者還有用到其他一些實作細節,包含如下:

  1. MLM Pre-Training
    為了穩定訓練,作者先在 deberta-v3 上用 Masked Language Modeling 的方式進行 pretrain,訓練完會凍結前九層(for deberta-v3-base/xsmall)或是前六層(for deberta-v3-base/xsmall)再拿去之後的兩階段訓練。

但這邊我不太確定作者是用什麼資料來 pretrained 的,應該也是和 essay 相關的 data,我也不太確定多做這步驟對這個題目帶來多少增益。

下面是作者進行 MLM 訓練的 code,完整代碼請見 3。我覺得很簡潔,現在訓練 MLM 變得很方便了,之後有需要的話可以直接參考下面我加上註釋的整理:

  • Normalize Text Encoding
from text_unidecode import unidecode
from typing import Dict, List, Tuple
import codecs

def replace_encoding_with_utf8(error: UnicodeError) -> Tuple[bytes, int]:
    return error.object[error.start : error.end].encode("utf-8"), error.end


def replace_decoding_with_cp1252(error: UnicodeError) -> Tuple[str, int]:
    return error.object[error.start : error.end].decode("cp1252"), error.end

# Register the encoding and decoding error handlers for `utf-8` and `cp1252`.
codecs.register_error("replace_encoding_with_utf8", replace_encoding_with_utf8)
codecs.register_error("replace_decoding_with_cp1252", replace_decoding_with_cp1252)

def resolve_encodings_and_normalize(text: str) -> str:
    """Resolve the encoding problems and normalize the abnormal characters."""
    text = (
        text.encode("raw_unicode_escape")
        .decode("utf-8", errors="replace_decoding_with_cp1252")
        .encode("cp1252", errors="replace_encoding_with_utf8")
        .decode("utf-8", errors="replace_decoding_with_cp1252")
    )
    text = unidecode(text)
    return text

train['text'] = train['text'].apply(lambda x : resolve_encodings_and_normalize(x))
  • 建構 MLM Dataset
from transformers import AutoTokenizer, AutoModel, AutoConfig, LineByLineTextDataset, DataCollatorForLanguageModeling,Trainer, TrainingArguments

train_dataset = LineByLineTextDataset(
    tokenizer=tokenizer,
    file_path="train_text.txt", #mention train text file here
    block_size=512)

valid_dataset = LineByLineTextDataset(
    tokenizer=tokenizer,
    file_path="val_text.txt", #mention valid text file here
    block_size=512)

// mlm_probability 的值(在此例中为 0.15)表示在输入序列中,隨機選取 15% 的 token masked 掉
data_collator = DataCollatorForLanguageModeling(
    tokenizer=tokenizer, mlm=True, mlm_probability=0.15)

  • 設定訓練參數
training_args = TrainingArguments(
    output_dir="./deberta_v3_base_chk", #select model path for checkpoint
    overwrite_output_dir=True,
    num_train_epochs=8,
    per_device_train_batch_size=4,
    evaluation_strategy= 'steps',
    save_total_limit=0,
    save_strategy='steps',
    save_steps=14456,
    eval_steps=7228,
    metric_for_best_model = 'eval_loss',
    greater_is_better=False,
    load_best_model_at_end =True,
    prediction_loss_only=True,
    report_to = "none")

trainer = Trainer(
    model=model,
    args=training_args,
    data_collator=data_collator,
    train_dataset=train_dataset,
    eval_dataset=valid_dataset)
  • 開始訓練並儲存模型
trainer.train()
trainer.save_model(f'./deberta_v3_base')
  1. CV 策略

如前面關於 prompt name 的討論,根據 prompt name 做 CV,可以觀察到不同 prompt 的 validation fold 測試結果差異很大。所以進入兩階段訓練後,作者採用 MultiLabelStratiFiedkFold 的方法,根據 prompt name 和 score 來切 6-folds。

  1. Ensemble 不同的 pooling 方法

這邊作者集成多個模型的方法是使用 weighted sum 的方法。也就是針對不同模型預測出來的結果,乘上不同的權重,最後再加總起來當成最終答案。

但是要怎麼找到每個模型最適合的權重呢?這邊作者使用 Nelder Mead 這個優化算法。

步驟如下:

  1. 將 dataset 切成 n-folds
  2. 使用 m 個要 ensemble 的模型在 out-of-fold data 做預測。
  3. 定義一個要優化的 loss function
    • 這邊作者的寫法是將該個模型的 weight 乘上其原始預測出來的 predict 結果,得到新的預測數值
    • 設定要切出1,2,3,4,5,6等分數級距的 threshold
    • 將預測數值轉成分數級距後,和ground truth 計算 cohen kappa
    • cohen kappa 應該越大越好,但因為整體 loss function 的目標是要最小化輸出的數值,所以乘上一個負號
  • 把每個 out-of-fold 的 m 個模型預測結果交給 Nelder Mead Mathod 優化找到每個模型最佳的 weight 後,再把 folds 之間同個模型找到的 weight 取平均當成最終結果。

聽起來有點複雜,結合 code 服用效果更佳:

  • 準備 dataset 和要 ensemble 的 model
# 假设你有一个包含 id 和 text 的 DataFrame
# df = pd.DataFrame({'id': ..., 'text': ...})

# 假设你有一个模型列表 models,其中包含10个模型
# models = [model1, model2, ..., model10]
  • Main Code: 幫每個 oof 的不同模型找到他們的最佳權重並儲存在 fold_weights
oof_predictions, final_weights = kfold_predict_and_optimize(df, models, n_splits=5)

def kfold_predict_and_optimize(df, models, n_splits=5):
    kf = KFold(n_splits=n_splits, shuffle=True, random_state=42)
    oof_predictions = np.zeros((len(df), len(models)))
    fold_weights = np.zeros((n_splits, len(models)))  # 用于保存每个fold的最佳权重

    for fold, (train_idx, val_idx) in enumerate(kf.split(df)):
        X_train, X_val = df.iloc[train_idx]['text'], df.iloc[val_idx]['text']
        y_train, y_val = df.iloc[train_idx]['id'], df.iloc[val_idx]['id']

        predictions = []
        for model in models:
            model.fit(X_train, y_train)
            preds = model.predict(X_val)
            predictions.append(preds)

        predictions = np.array(predictions).T
        optimized_weights = optimize_weights(predictions, y_val)
        fold_weights[fold] = optimized_weights  # 记录当前fold的最佳权重
        oof_predictions[val_idx] = np.dot(predictions, optimized_weights)

    # 计算每个模型在不同fold上的权重平均值
    final_weights = np.mean(fold_weights, axis=0)

    return oof_predictions, final_weights
  • 定義優化算法需要的 loss function
def optimize_weights(predictions, y_true):
    def loss_func(weights):
        final_prediction = np.sum(weights * predictions, axis=1)
        final_prediction = pd.cut(final_prediction * 6, bins=[-np.inf, 0.83333333, 1.66666667, 2.5, 3.33333333, 4.16666667, np.inf], labels=[0, 1, 2, 3, 4, 5])
        score = cohen_kappa_score(y_true, final_prediction, weights='quadratic')
        return -score
    
    initial_weights = np.ones(len(predictions[0])) / len(predictions[0])
    res = minimize(loss_func, initial_weights, method='Nelder-Mead')
    return res.x

下表是2集成的所有模型的規格和 CV Score:
https://ithelp.ithome.com.tw/upload/images/20240919/201526685Ul43jaSX4.png

  • 主要模型:
class CustomModel(nn.Module):
    def __init__(self, cfg, pooling_method="MeanPooling", do_layer_normalize=False):
        super().__init__()
        self.cfg = cfg
        self.do_layer_normalize = do_layer_normalize
        self.model = AutoModel.from_pretrained(cfg.model, config=self.config)
        if pooling_method == "MeanPooling":
            self.pool = MeanPooling()
        elif pooling_method == "AttentionPooling":
            self.pool = AttentionPooling()
        elif pooling_method == "LSTMPooling":
            self.pool = LSTMPooling()
        else:
            raise Exception('Pooling Method Wrong!')
        self.layer_norm1 = nn.LayerNorm(self.config.hidden_size)
        self.fc = nn.Linear(self.config.hidden_size, self.cfg.target_size)
        
        
    def feature(self, inputs):
        outputs = self.model(**inputs)
        last_hidden_states = outputs[0]
        feature = self.pool(last_hidden_states, inputs['attention_mask'])
        return feature

    def forward(self, inputs):
        feature = self.feature(inputs)
        if self.do_layer_normalize:
            feature = self.layer_norm1(feature)
        output = self.fc(feature)
        return output

作者一共實現了三種不同的 pooling 方式(實現細節用註解寫在代碼中):

  • MeanPooling
class MeanPooling(nn.Module):
    def __init__(self, cfg):
        super(MeanPooling, self).__init__()
        self.cfg = cfg
    def forward(self, last_hidden_state, attention_mask):
        # Expand the attention mask to match the last_hidden_state dimensions: [batch_size, sequence_length, hidden_size]
        expanded_mask = attention_mask.unsqueeze(-1).expand_as(last_hidden_state).float()

        # Apply the attention mask to the hidden states
        masked_hidden_state = last_hidden_state * expanded_mask

        # Sum the masked hidden states along the sequence length dimension
        sum_embeddings = masked_hidden_state.sum(dim=1)

        # Sum the attention mask along the sequence length dimension
        sum_mask = expanded_mask.sum(dim=1)

        # Avoid division by zero by clamping the sum_mask to a minimum value
        sum_mask = torch.clamp(sum_mask, min=1e-9)

        # Compute the mean embeddings by dividing the summed embeddings by the summed mask
        mean_embeddings = sum_embeddings / sum_mask

        return mean_embeddings
  • Attention Pooling
class AttentionPooling(nn.Module):
    def __init__(self, cfg):
        super(AttentionPooling, self).__init__()
        self.cfg = cfg
        self.attention = nn.Sequential(
            nn.Linear(self.config.hidden_size, 512),
            nn.Tanh(),
            # 將 512 維的輸出縮減到 1 維,這一步的結果可以看作是每個時間步(序列中的每個元素)的注意力分數(attention score)
            nn.Linear(512, 1),
            # 將注意力分數轉換為概率分佈,這樣輸出就變成了在序列上對應每個元素的權重。dim=1 表示這個操作是在序列的維度上進行的,即針對每個序列元素計算其相對於其他元素的重要性。
            nn.Softmax(dim=1)
        )
    def forward(self, last_hidden_state, attention_mask=None):
        weight = self.attention(last_hidden_state)
        # 在序列維度上對所有元素進行加權求和,得到一個單一的特徵表示 feature
        pool_embeddings = torch.sum(weights * last_hidden_states, dim=1)
        return pool_embeddings
  • LSTM Pooling
class LSTMPooling(nn.Module):
    def __init__(self, cfg):
        super(LSTMPooling, self).__init__()
        self.cfg = cfg
        """
        self.config.hidden_size:這是 LSTM 的輸入維度,即每個時間步輸入到 LSTM 的特徵數量。這裡,它等於 Transformer 模型的隱藏狀態的維度。

        (self.config.hidden_size) // 2:這是 LSTM 的輸出維度(隱藏層狀態的維度),因為 LSTM 是雙向的(bidirectional=True),實際上每個時間步的最終輸出將是兩倍的這個大小(來自正向和反向的隱藏層狀態拼接在一起)。

        num_layers=2:這表示 LSTM 的層數。這裡使用了兩層 LSTM,使得模型能夠學習更複雜的序列依賴。

        dropout=self.config.hidden_dropout_prob:這是在 LSTM 層之間應用的 dropout 率,用於防止過擬合。

        batch_first=True:這個參數表示輸入和輸出的數據形狀是 (batch_size, sequence_length, hidden_size),這通常與 Transformer 模型的輸出形狀保持一致。

        bidirectional=True:這表示 LSTM 是雙向的,這樣可以捕捉到序列中的前向和後向信息,進一步增強模型的特徵提取能力。
        """
        self.lstm = nn.LSTM(
            self.config.hidden_size,
            (self.config.hidden_size) // 2,
            num_layers=2,
            dropout=self.config.hidden_dropout_prob,
            batch_first=True,
            bidirectional=True)
        self.pool = MeanPooling()
    def forward(self, last_hidden_state, attention_mask):
        feature = self.lstm(last_hidden_state)
        pool_embeddings = self.pool(feature, attention_mask)
        return pool_embeddings

🥇 1st Solution

第一名4的做法跟前兩位一樣,重點也是在如何弭平 non-persuade 和 persuade data 的差距。

他使用 pre-train(on persuade data) -> fine-tune(on non-persuade data)的兩階段訓練方法,其作法如下:

  1. 先在 persuade data 上 pretrain
  2. 再用 non-persuade data fine-tuned
  3. 用 fine-tuned 好的模型重新在 persuade data 上做預測,並將預測結果和原始 persuade data 的 label 取平均,創造新的 pseudo-label。
  4. 重新執行 1, 2 的步驟,以得到最終的模型。

我覺得第一名的作法和第三名其實有共同之處,都是用 relabel data 重新創造 pseudo-label 的方法來弭平這兩個 dataset 之間的差距,避免模型過度 overfit 在任何一個特定的 dataset 上。

第一名還有提到一些實作細節如下:

  • CV 策略
    他使用 stratifiedKFold 的方式來切分 data。不過他也有注意到 prompt name 之間分數的差異,所以他用 prompt_id+score 來當作切分 folds 依據的 label。

也就是說,他在切分folds時,總共要考慮 7(prompt name 數量)*6(6個分數級距) = 42 個 labels,根據這些 labels 的分佈切成 5-folds。

不過有趣的是,他似乎只有在第一階段 pre-train 做 CV,或是在標 pseudo-label 前使用這個 CV 策略。因為他最後提交的模型,似乎都是使用全部訓練資料下去訓練,只是調整不同模型的參數以及 random seed 而已。

  • 創造多個可集成(ensemble)的模型變體

    • 改變 loss function
      作者使用的 backbone model 主要都是 deberta-v3-base 或是 deberta-v3-large。但是有些使用 regression 的範式訓練,以就是使用 MSE 當作 loss;有些則將 score 用 sigmoid function 轉成介在 0~1 之間的數值,然後使用 binary cross entropy 來優化

    • 改變 pooling 方法
      作者使用 GEM Pooling 或是直接使用 [CLS] token 的 embedding

    • 使用不同 random seed 訓練多個模型
      因為第二階段只會在 4.5k 的 non-persuade data 上 fine-tuned,data 數量比較少,作者發現不同 random seed 會得到差異較大的結果。所以最後他每個模型、每種訓練方式都會用 3 種不同的 random seed 訓練,最終平均這 3 個的結果當成該個訓練方式的最終輸出。

    • 找到區分6個分數級距的最佳 threshold
      由於分數一共有 6 個級距(1, 2, 3, 4, 5, 6),所以在切分 threshold 時,很自然地就會想設定 [1.5, 2.5, 3.5, 3.5, 5.5] 當成 threshold。也就是模型預測分數小於 1.5 的,就 label 成分數 1;模型預測結果介在 1.5~2.5 的,就 label 成分數 2,以此類推,如果預測結果大於 5.5 就 label 成分數 6
      但由於訓練資料中每個 score(label) 的資料量不同,例如 score 3 的 data 是最多的,因此 label 為 2 的可能更常被預測成較大的數值,如果不調整 threshold,可能就會容易被捨入為 score 3。
      那要如何自動找到最適合的 threshold 呢?

    和前面第三名找集成模型的最佳權重一樣,現在作者也是使用一個優化算法,並定義一個 loss function,來找到這六個分數級距的最佳分割 threshold。這邊作者使用 Powell 的優化算法來優化 QWK 分數,具體程式碼如下:

    predictions = [...]
    true_labels = [...]
    
    optimal_thresholds = find_optimal_thresholds(predictions, true_labels)
    
    def find_optimal_thresholds(predictions, true_labels):
    # 计算QWK分数
    def qwk_score(thresholds):
        # 根据阈值将回归预测结果转换为分类
        thresholds = np.sort(thresholds)
        bins = [-np.inf] + list(thresholds) + [np.inf]
        pred_labels = pd.cut(predictions, bins=bins, labels=[1, 2, 3, 4, 5, 6])
        return -cohen_kappa_score(true_labels, pred_labels, weights='quadratic')
    
    # 初始 threshold
    initial_thresholds = [1.5, 2.5, 3.5, 4.5, 5.5]
    
    # 使用Powell方法最小化-QWK
    
    result = minimize(qwk_score, initial_thresholds, method='Powell')
    
    # 返回最佳阈值
    optimal_thresholds = result.x
    optimal_thresholds.sort()
    return optimal_thresholds
    

    根據當下的 threshold 分割出 score 後,和 ground truth 計算 QWK(Quadratic Weighted Kappa)。由於目標是最小化 loss function 的數值,然而 QWK 應該越大越好,故乘上負號。

    • 選擇集成(ensemble)模型的方法
      作者試過 Nelder-Mead 以及 Hill-Climbing,但最後還是選擇用 average 所以模型的預測結果。他認為與其花時間嘗試優化每個模型預測結果的權重,直接做平均的泛化能力是最好的。

小結

關於 Learning Agency Lab - Automated Essay Scoring 2.0,這個 kaggle 競賽的系列分享就到此為止啦!

今天分享前四名(第二名的 write-up 的 write-up 寫的比較讓我困惑,所以這邊只分享1, 3, 4名的做法)各自在這個賽題上試出的最優解法。

我個人覺得今天分享的三個得獎組別的做法,都有讓人覺得趣味滿滿的巧妙想法!

像是第四名透過簡單的實驗觀察到,兩種 data 應該是有不同的分佈,所以僅僅是在兩種不同來源的 data 前加上 [A], [B] 兩個識別符,就可以明顯提升 performance,於是繼續延伸出兩種 data 要用不同的 classification head 等策略。方法簡單又有效!

第一名和第三名的做法有點像,都是透過兩階段或多階段的微調,在過程中用上一個階段的模型產生 pseudo-label,讓下階段的模型在這個 pseudo-label 上繼續訓練。

剩下還有一些實用的小技巧,包含 Micro batch 在data字數差異較大時可以穩定訓練、集成模型時如何用最優化算法找到適合每個模型自己的權重、如何設計不同的 pooling 方法等等,從他們的分享中,真的可以挖掘出很多乾貨!

由於今天的分享大多是我從他們得獎後發布的
write-up 以及我 trace 他們提交的 inference code 推敲出來的,如果有發現哪裡講錯的、需要補充的地方,歡迎在討論區跟我說。🙇🏻‍♀️🙇🏻‍♀️

明天我們會開啟新的一章,進入新的賽題囉!

明天見~


謝謝讀到最後的你,希望你會覺得有趣!
如果喜歡這系列,別忘了按下訂閱,才不會錯過最新更新,也可以按讚給我鼓勵呦!
如果有任何回饋和建議,歡迎在留言區和我說✨✨


(Kaggle - Learning Agency Lab - Automated Essay Scoring 2.0 解法分享系列)


上一篇
[Day 4] 探究 Train vs. Test Set 的真實差距:善用 Topic Modeling, Cross / Adversarial Validation等實用技巧!
下一篇
[Day 6] 別著急訓練模型,挖掘好用的 feature 是成功的一半: EDA 實戰演練(上)
系列文
一個Kaggle金牌解法是如何誕生的?跟隨Kaggle NLP競賽高手的討論,探索解題脈絡12
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言