轉戰pytorch——實現自己的任務(4)

前言

在前面的三章中,我們分別介紹了pytorch的組件,詳細解讀了官方的代碼示例,瞭解python的運行過程,並利用最新的自然語言處理模型Albert實現了一次文本分類。但是,如果我們需要構建屬於自己的數據處理、模型以及評估該怎麼辦呢?本文將會在接下來介紹如何從頭開始實現一個自己的任務。所有的代碼我都會整理到github上。

1. 模型的構建

pytorch的模型構建比較簡單,之前也已經介紹過,只需要在初始化層中定義屬於自己的模型,但是,pytorch官方提供的模塊是較爲基礎的。我們需要在文件夾裏添加一個custom_model文件用於存放我們的模型,這時候,我們就可以發揮我們自己的想象力,搭建屬於我們自己的模型了。

一般的模塊,例如在第一章中介紹的那些,我們都可以在pytorch自帶的模塊中獲得。但是有很多時候,我們需要更爲高級的模塊時,pytorch那些模塊就顯得不夠用了。例如,最簡單的attention模型,當然,現在都是transformer了,但是並不是所有的任務transformer都勝任,例如在文本分類模型裏,lstm也顯示出一定的競爭力。

實際上,在構建模型方面,有兩個比較好的庫,一個是集成化高的fast.ai,在我看來,它比Keras集成度更加高級,因爲它將整個學習過程集成在了learner裏,learner不僅僅可以進行數據的處理、模型的訓練,還可以對模型訓練過程中各種情況進行一個打印分析,如果是一個初學者,使用這個是非常容易上手的。

另一個是隨着elmo火起來的Allennlp。它雖然沒有fast.ai封裝的那麼高級,但是它除了提供很多常見的模型外,還提供了很多的模塊,用以構建我們的模型,比如一個在原裝的pytorch裏不存在,但是在Kreas裏有的,且非常實用的Timedistribute模塊,在Allennlp裏就有,否則,只能自己手寫一個了。

1.1 例子attention的實現

這裏我們以一個attention的實現作爲例子,看一下pytorch自己實現的層長什麼樣子,由於已經有一些基礎了,我們就不再贅述attention的具體實現,詳情可以參見《attention機制》、《attention 可視化》、《bilstm+attention》、《Self-attention》。

2. 修改processor和InputExample

processor的作用就是從文本里讀取樣本到內存裏。它的具體細節如下,可以看到其核心在於_create_examples,這塊是我們需要自己寫的,我們將一行的文本變成我們的格式化輸入樣例InputExample,另一個就是使用自帶的_read_tsv或者_read_csv文件讀取我們想要的文本,這個已經自帶了,因此也不用擔心。第三個要注意的是InputExample的定義,這裏是一個文本對,你也可以做成文本序列或者其他你需要的形式。

class WnliProcessor(DataProcessor):
    """Processor for the WNLI data set (GLUE version)."""

    def get_train_examples(self, data_dir):
        """See base class."""
        return self._create_examples(
            self._read_tsv(os.path.join(data_dir, "train.tsv")), "train")

    def get_dev_examples(self, data_dir):
        """See base class."""
        return self._create_examples(
            self._read_tsv(os.path.join(data_dir, "dev.tsv")), "dev")

    def get_labels(self):
        """See base class."""
        return ["0", "1"]

    def _create_examples(self, lines, set_type):
        """Creates examples for the training and dev sets."""
        examples = []
        for (i, line) in enumerate(lines):
            if i == 0:
                continue
            guid = "%s-%s" % (set_type, line[0])
            text_a = line[1]
            text_b = line[2]
            label = line[-1]
            examples.append(
                InputExample(guid=guid, text_a=text_a, text_b=text_b, label=label))
        return examples

3. 增加任務索引

增加任務索引則能更好的使用整個框架,只需要把自己的任務定義一個名稱,然後規定該任務的任務類型,以及類別標籤數目,就可以在後面不用再管你這個任務的特殊性了。這裏的任務名稱目前僅僅是區分讀入數據時處理不同,你也可以應用到其他領域。

4.將樣例轉換爲特徵

剛剛已經將文本讀取爲一個個結構化的樣例存儲在內存中,接下來就是在這個文本中抽取特徵。一些常規操作比如分詞等,都可以在此處進行。而現在流行的BERT模型,則是進行Token操作。另一個需要注意的,就是和上面一樣,我們還是需要定義一個輸入特徵類,其實它沒有什麼其他的用處,只是爲了更好的存儲輸入的特徵。

def glue_convert_examples_to_features(examples, tokenizer,
                                      max_seq_length=512,
                                      task=None,
                                      label_list=None,
                                      output_mode=None):
    if task is not None:
        processor = glue_processors[task]()
        if label_list is None:
            label_list = processor.get_labels()
            logger.info("Using label list %s for task %s" % (label_list, task))
        if output_mode is None:
            output_mode = glue_output_modes[task]
            logger.info("Using output mode %s for task %s" % (output_mode, task))

    label_map = {label: i for i, label in enumerate(label_list)}

    features = []
    for (ex_index, example) in enumerate(examples):
        if ex_index % 10000 == 0:
            logger.info("Writing example %d" % (ex_index))

        """
        在這裏編寫邏輯代碼,將文本轉換爲我們需要的輸入特徵。這裏具體的可以參見原文,由於代碼過長,因此我們不進一步的進行展示。
        """

        features.append(
            InputFeatures(input_ids=input_ids,
                          attention_mask=attention_mask,
                          token_type_ids=token_type_ids,
                          label=label_id,
                          input_len=input_len))
    return features

5. 將數據統一存放於Dataset中

做到上述過程,就已經把一個個樣例以結構化的形式存儲到內存之中了,那麼只需要在我們需要的時候,一個個提取出來就行了,而這裏爲了方便對接Dataset和Data_loader兩個類,我們還需要在主函數中的load_and_cache_examples(args, task, tokenizer, data_type='train')方法裏進行一定的修改,將我們每個樣例的不同的部分先合併爲一個統一的整體。打個比方,原來我們對於車輛都是以組成一輛車的材料在一起的方式存儲的,但是現在爲了流水線操作,我們必須將輪胎先都放在一起,車架都放在一起,等等,然後在組裝時,我們才能夠更快的組裝,這裏的load_and_cache_examples(args, task, tokenizer, data_type='train')就起到了分類歸總各個配件的操作。

def load_and_cache_examples(args, task, tokenizer, data_type='train'):
    if args.local_rank not in [-1, 0] and not evaluate:
        torch.distributed.barrier()  # Make sure only the first process in distributed training process the dataset, and the others will use the cache

    """
    此部分代碼省略,主要功能就是讀取數據並進行緩存,最後得出各個特徵爲下一步提供處理的材料。
    """
    if args.local_rank == 0 and not evaluate:
        torch.distributed.barrier()  # Make sure only the first process in distributed training process the dataset, and the others will use the cache
    # Convert to Tensors and build dataset
    all_input_ids = torch.tensor([f.input_ids for f in features], dtype=torch.long)
    all_attention_mask = torch.tensor([f.attention_mask for f in features], dtype=torch.long)
    all_token_type_ids = torch.tensor([f.token_type_ids for f in features], dtype=torch.long)
    all_lens = torch.tensor([f.input_len for f in features], dtype=torch.long)
    if output_mode == "classification":
        all_labels = torch.tensor([f.label for f in features], dtype=torch.long)
    elif output_mode == "regression":
        all_labels = torch.tensor([f.label for f in features], dtype=torch.float)
    dataset = TensorDataset(all_input_ids, all_attention_mask, all_token_type_ids, all_lens, all_labels)
    return dataset

核心就是上述的代碼,因爲我們之前的樣本都還沒有進行張量化,在這裏將他們一同進行張量化後,才能讓Data_loader進行採樣抽取出樣本。

6. 調整data_loader

在訓練過程的一開始,就向我們介紹了採樣的過程,首先進行一個隨機採樣器RandomSampler,此處的步驟就是打亂數據集。然後使用DataLoader進行數據加載,這裏需要注意的就是collate_fn,這個函數需要你自己編寫,以適應我們模型對於輸入的需求。

def train(args, train_dataset, model, tokenizer):
    """ Train the model """
    args.train_batch_size = args.per_gpu_train_batch_size * max(1, args.n_gpu)
    train_sampler = RandomSampler(train_dataset) if args.local_rank == -1 else DistributedSampler(train_dataset)
    train_dataloader = DataLoader(train_dataset, sampler=train_sampler, batch_size=args.train_batch_size,
                                  collate_fn=collate_fn)
"""
後面的代碼先不看
"""

從下面的代碼中可以看到,這裏就是對於每個batach進行一個統一的格式化,保證每個批次的長度一致,並且返回值其實就是我們的模型想要的樣子。

def collate_fn(batch):
    """
    batch should be a list of (sequence, target, length) tuples...
    Returns a padded tensor of sequences sorted from longest to shortest,
    """
    all_input_ids, all_attention_mask, all_token_type_ids, all_lens, all_labels = map(torch.stack, zip(*batch))
    max_len = max(all_lens).item()
    all_input_ids = all_input_ids[:, :max_len]
    all_attention_mask = all_attention_mask[:, :max_len]
    all_token_type_ids = all_token_type_ids[:, :max_len]
    return all_input_ids, all_attention_mask, all_token_type_ids, all_labels

7. 調整模型輸入

下面是訓練過程中最重要的代碼,用以將數據作爲輸入給我們的模型,可以看到其實就是以字典的形式是最合適的,因爲這樣有我們需要的特徵放進去也行,我們不需要的特徵也可以放進去,並不影響最後的結果。這裏將labels也放進去,爲了進行損失的計算。

        for step, batch in enumerate(train_dataloader):
            model.train()
            batch = tuple(t.to(args.device) for t in batch)
            inputs = {'input_ids': batch[0], 'attention_mask': batch[1], 'labels': batch[3], 'token_type_ids': batch[2]}
            outputs = model(**inputs)

8. 訓練過程中的loss的各種情況與解析

當我們將以上所有步驟都完成之後,只需要將我們的數據轉換爲可以讀取的文件即可,可以說把中間很多拿不準的過程都交給整個框架了,而框架的可靠性相對比較好,其擴展性也比較強,當你進行一個改動時,你可以只改動其中的某一部分,而不是全體,這樣就可以記住很多東西,也減輕了我們對於全局的把握壓力。但是,對於一個神經網絡訓練的過程中,loss會出現的種種情況。針對各種現象,正好看到了一篇非常好的文章《loss不下降原因解析》,詳細介紹了各個情況,這裏簡單列舉一下:

  • train loss 不斷下降,test loss不斷下降,說明網絡仍在學習
  • train loss 不斷下降,test loss趨於不變,說明網絡過擬合
  • train loss 趨於不變,test loss不斷下降,說明數據集100%有問題
  • train loss 趨於不變,test loss趨於不變,說明學習遇到瓶頸,需要減小學習率或批量數目
  • train loss 不斷上升,test loss不斷上升,說明網絡結構設計不當,訓練超參數設置不當,數據集經過清洗等問題

當然,也存在一種情況,你給了輸入的數據,給了輸出的標籤,但是模型怎麼都學不到東西。這時候,應該考慮數據和標籤之間是否有關係?例如,一個文章的類型是隨便標的2類,電腦基本上是識別不出來了,因此就挑選類別大類進行識別,而且無法改進。

如果有同學知道,loss都在不斷的下降,但是預測結果一直不變是什麼情況,也可以留言或者私信告訴我。

9.其他的一些pytorch小技巧

9.1 查看張量情況

pytorch也有很多有利的調試的庫幫助我們更好的進行編程,一個有用的庫就是TorchSnooper,它可以跟蹤一個函數內的所有張量的形狀並自動打印出來,詳情可以參見《pytorch調試利器》,但是需要補充的是,它只能追蹤所有的數據變量的信息,對於模型處於哪個設備,情況如何並不清楚,因此,在出現問題時,還需要額外檢測一下模型是否也在預定情況下運行。

9.2 查看非張量的list形狀

需要查看list的形狀時,我們可以使用(np.array(list)).shape將列表轉換爲np的形式查看其情況,並且使用exit()可以隨時退出程序運行。不過在此仍然進行單元測試,而並不是整體測試,因爲後者的測試成本過高。(比如,你是在數據加載中出現問題,那這時候,直接運行服務器的話佔用成本太大。)

10. 小結

我們這裏簡單的總結一下,如果我們需要做一個模型,需要修改的部分有哪些。

  1. 搭建自己的模型(custom_model)
  2. 搭建自己的processor(glue)和 InputExample (util)
  3. 增加任務索引(glue)
  4. 搭建自己的convert_examples_to_features(gule) 和InputFeatures(gule)
  5. 構建加載過程(load_and_cache_examples)
  6. 構建自己的data_loader回調函數(glue)
  7. 調整模型的輸入(train/evaluate/test)
  8. 創建訓練文件

只需要按照我們說的這10個步驟,我們就能夠搭建出屬於自己的程序,而且,儘管這8個過程相較於一個toy來說,是非常複雜的,但是每完成一步都是紮紮實實的一步,從而有條不紊的構建一個系統級的系統。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章