文章目錄
前言
在前面的三章中,我們分別介紹了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. 小結
我們這裏簡單的總結一下,如果我們需要做一個模型,需要修改的部分有哪些。
- 搭建自己的模型(custom_model)
- 搭建自己的processor(glue)和 InputExample (util)
- 增加任務索引(glue)
- 搭建自己的convert_examples_to_features(gule) 和InputFeatures(gule)
- 構建加載過程(load_and_cache_examples)
- 構建自己的data_loader回調函數(glue)
- 調整模型的輸入(train/evaluate/test)
- 創建訓練文件
只需要按照我們說的這10個步驟,我們就能夠搭建出屬於自己的程序,而且,儘管這8個過程相較於一個toy來說,是非常複雜的,但是每完成一步都是紮紮實實的一步,從而有條不紊的構建一個系統級的系統。