日韩黑丝制服一区视频播放|日韩欧美人妻丝袜视频在线观看|九九影院一级蜜桃|亚洲中文在线导航|青草草视频在线观看|婷婷五月色伊人网站|日本一区二区在线|国产AV一二三四区毛片|正在播放久草视频|亚洲色图精品一区

分享

超詳細中文注釋的GPT2新聞標題生成項目

 520jefferson 2021-01-14

筆者開源了一個帶有超詳細中文注釋的GPT2新聞標題生成項目。

該項目參考了GPT2-Chinese、GPT2-chitchat、CDial-GPT、GPT2等多個GPT2開源項目(感謝大佬們的開源),并根據自己的理解,將代碼進行重構,添加詳細注釋,希望可以幫助到有需要的同學。

項目是基于HuggingFace的transformers實現GPT2模型代碼進行修改、訓練及測試。并且通過Flask框架搭建了一個Web服務,將新聞標題生成模型進行工程化,可以通過頁面,可視化地體驗新聞標題生成效果。

該項目的目的是帶領大家走一遍GPT2生成模型的訓練、測試及部署全部流程。

項目地址:https://github.com/liucongg/GPT2-NewsTitle

本文主要是對項目中的代碼進行講解,主要從數據預處理、數據類實現、模型代碼實現、模型訓練、模型測試和模型上線,六個部分進行介紹,如下。

數據預處理

數據來源于新浪微博,由He Zhengfang大佬整理,詳細鏈接如下:https://www.jianshu.com/p/8f52352f0748?tdsourcetag=s_pcqq_aiomsg。

由于數據來自微博,在標題中常常帶有“話題”、“表情”標記,在正文中常常帶有“HTML”標記,如下:

Title:
2014#福布斯中國名人榜#:她再奪冠[威武]
Content:
為什么我們要工作?聽演講者Barry Schwartz告訴你工作的另一個重要意義。非常有深度的一個演講,值得一看!http:///RqzKvtn 轉發(fā)學習,給自己的工作加油打氣吧![good]

因此需要對數據進行清洗,具體如下:

(1)對標題清洗時,會去除“##”符號(一般為微博數據的話題標記)、去除“[]”中間的文字(一般為微博數據中的表情)、合并標題中過多的空格

def clean_weibo_title(title: str):
'''
對微博數據中的標題內容(待生成)進行清洗
Args:
title: 標題
Returns:
'''
# 去除##符號(一般為微博數據的話題標記)
title = re.sub(r'#', '', title)
# 去除[]中間的文字(一般為微博數據中的表情)
title = re.sub(r'(\[{1,2})(.*?)(\]{1,2})', '', title)
# 合并標題中過多的空格
title = re.sub(r'\s+', ' ', title)
return title

(2)對正文清洗時,會去除網址、合并正文中過多的空格、去除“\u200b”字符

def clean_weibo_content(content: str):
'''
對微博數據中的文本內容進行清洗
Args:
content: 文本
Returns:
'''
# 去除網址
content = re.sub(r'(https|http)?:\/\/(\w|\.|\/|\?|\=|\&|\%)*\b', '', content)
# 合并正文中過多的空格
content = re.sub(r'\s+', ' ', content)
# 去除\u200b字符
content = content.replace('\u200b', '')
return content

(3)對清洗后的數據進行整合,去除重復數據、正文內容字數小于100的數據和標題內容字數小于2的數據;并且拆分訓練集和測試集。

def build_news_data(content_path, title_path, train_save_path, test_save_path):
'''
對微博數據進行清洗,構建訓練集和測試集
Args:
content_path: 正文內容文件路徑
title_path: 標題內容文件路徑
train_save_path: 訓練集文件路徑
test_save_path: 測試集文件路徑
Returns:
'''
# 打開文件,并將其zip成一個文件
content_data = open(content_path, 'r', encoding='utf-8')
title_data = open(title_path, 'r', encoding='utf-8')
data = zip(content_data.readlines(), title_data.readlines())
# 使用多進程處理數據
threads = min(8, cpu_count())
with Pool(threads) as p:
annoate_ = partial(clean_data)
data = list(tqdm(p.imap(annoate_, data, chunksize=8),
desc='build data'
)
)
# 對數據進行過濾,去除重復數據、正文內容字長小于100的數據和標題內容字長小于100的數據
data_set = set()
data_new = []
for d in data:
if d['content'] in data_set or len(d['content']) < 100 or len(d['title']) < 2:
continue
else:
data_set.add(d['content'])
data_new.append(d)
# 拆分數據,構建訓練集和測試集
random.shuffle(data_new)
train_data = data_new[:-3000]
test_data = data_new[-3000:]
fin = open(train_save_path, 'w', encoding='utf-8')
fin.write(json.dumps(train_data, indent=4, ensure_ascii=False))
fin.close()
fin = open(test_save_path, 'w', encoding='utf-8')
fin.write(json.dumps(test_data, indent=4, ensure_ascii=False))
fin.close()

詳細代碼見Github項目的data_helper.py文件。

數據類實現

數據類的作用是將文本數據轉換成模型可以使用的索引數據,并預先存儲下來。避免模型每訓練一步,都進行無效的數據轉換操作。

(1)判斷是否存在緩存文件,如果存在,則直接加載;否則重新將文本數據轉換為索引數據,并存為緩存。

if os.path.exists(cached_feature_file) and not is_overwrite:
logger.info('已經存在緩存文件{},直接加載'.format(cached_feature_file))
self.data_set = torch.load(cached_feature_file)['data_set']
# 如果緩存數據不存在,則對原始數據進行數據處理操作,并將處理后的數據存成緩存文件
else:
logger.info('不存在緩存文件{},進行數據預處理操作'.format(cached_feature_file))
self.data_set = self.load_data(path_file)
logger.info('數據預處理操作完成,將處理后的數據存到{}中,作為緩存文件'.format(cached_feature_file))
torch.save({'data_set': self.data_set}, cached_feature_file)

(2)將文本數據轉換為索引數據的函數

def convert_feature(self, sample):
'''
數據處理函數
Args:
sample: 一個字典,包含新聞的正文和新聞的標題,格式為{'content': content, 'title': title}
Returns:
'''
input_ids = []
token_type_ids = []
# 對新聞正文進行tokenizer.tokenize分詞
content_tokens = self.tokenizer.tokenize(sample['content'])
# 對新聞標題進行tokenizer.tokenize分詞,注意tokenizer中已經將[Space]作為一個分隔符,不會切割成多個字符
title_tokens = self.tokenizer.tokenize(sample['title'].replace(' ', '[Space]'))
# 判斷如果正文過長,進行截斷
if len(content_tokens) > self.max_len - len(title_tokens) - 3:
content_tokens = content_tokens[:self.max_len - len(title_tokens) - 3]
# 生成模型所需的input_ids和token_type_ids
input_ids.append(self.tokenizer.cls_token_id)
token_type_ids.append(self.content_id)
input_ids.extend(self.tokenizer.convert_tokens_to_ids(content_tokens))
token_type_ids.extend([self.content_id] * len(content_tokens))
input_ids.append(self.tokenizer.sep_token_id)
token_type_ids.append(self.content_id)
input_ids.extend(self.tokenizer.convert_tokens_to_ids(title_tokens))
token_type_ids.extend([self.title_id] * len(title_tokens))
input_ids.append(self.tokenizer.sep_token_id)
token_type_ids.append(self.title_id)
# 判斷input_ids與token_type_ids長度是否一致
assert len(input_ids) == len(token_type_ids)
# 判斷input_ids長度是否小于等于最大長度
assert len(input_ids) <= self.max_len
return input_ids, token_type_ids

詳細代碼見Github項目的data_set.py文件。

模型代碼實現

模型部分,主要對transformers包中GPT2LMHeadModel類進行重寫,修改計算loss部分,只計算預測title部分的loss。

模型的輸入由word embedding、segment embedding和position embedding三部分組成,具體如下圖所示:

為什么需要加segment embedding?
為了更好地區(qū)分Content和Title,并且根據token type id可以僅計算title部分的損失值。

def forward(self, input_ids=None, past=None, token_type_ids=None, labels=None, title_id=None):
'''
前向函數,計算GPT2預測結果值
Args:
input_ids: 輸入序列在詞表中的索引序列,size:[batch_size, sequence_length]
past: 包含由模型預先計算好的隱藏狀態(tài),一般使用在預測階段,用于加速順序解碼,防止重復計算前面計算過的token
token_type_ids: 用于區(qū)分輸入序列中content和title的分隔符序列,size:[batch_size, sequence_length]
labels: 標簽序列,size:[batch_size, sequence_length],一般情況下,與input_ids相同
title_id: title部分分隔符的id
Returns:
'''
# 獲取GPT2模型的輸出結果
transformer_outputs = self.transformer(input_ids, past=past, token_type_ids=token_type_ids)
# 獲取GPT2模型的最后一層的隱層節(jié)點狀態(tài),size:[batch_size, sequence_length, config.n_embd]
hidden_states = transformer_outputs[0]
# 預測隱層節(jié)點狀態(tài)中的每一個token的下一個token,size:[batch_size, sequence_length, config.vocab_size]
lm_logits = self.lm_head(hidden_states)
# 拼接輸出結果
outputs = (lm_logits,) + transformer_outputs[1:]
# 如果labels不為None時,計算損失值loss,并拼接到輸出結果中
if labels is not None:
# 計算loss時,title_id不可以為None,因為需要title_id找到title的部分
if title_id is None or token_type_ids is None:
raise Exception('當labels不為None時, title_id和token_type_ids均不可以為None。')
# 獲取mask值,如果token_type_ids中等于title_id的部分需要計算loss,標記為1;否則為0。
# size:[batch_size, sequence_length]
mask = (token_type_ids == title_id).long()
# 獲取新的標簽,size:[batch_size, sequence_length]
labels = labels * mask
# 對預測結果和標簽進行偏移操作
# GPT2的生成機制為通過前面的token,預測下一個token;并且labels與input_ids相同,
# 因此input_ids中的第一個token的預測結果,實際上是標簽中的第二個token,以此類推,最終僅計算sequence_length-1個token的loss
shift_logits = lm_logits[..., :-1, :].contiguous()
shift_labels = labels[..., 1:].contiguous()

# 定義損失函數CrossEntropyLoss,并且設置忽略計算loss的索引,以及返回loss的形式
# 忽略shift_labels中為0的loss,也就是僅計算title部分的損失值
# 對loss的計算方式設為sum,由于我們僅計算了itle部分的損失值,如果使用mean,會使loss變小(實際除的是sequence_length-1,不是title部分的真實長度)
loss_fct = CrossEntropyLoss(ignore_index=0, reduction='sum')
loss = loss_fct(shift_logits.view(-1, shift_logits.size(-1)), shift_labels.view(-1))
# 獲取title部分的真實長度,并計算真實loss
num = shift_labels.ne(0).long().sum().item()
loss = loss / num
outputs = (loss,) + outputs
return outputs # (loss), lm_logits, presents, (all hidden_states), (attentions)

詳細代碼見Github項目的model.py文件。

模型訓練

模型訓練參數如下圖所示:

模型訓練執(zhí)行代碼如下:

python3 train.py

python3 train.py --output_dir output_dir/(自定義保存模型路徑)

模型訓練文件主要由以下幾個函數組成:(1)設置訓練模型所需參數函數set_args;(2)訓練模型函數train;(3)對測試數據集進行模型測試evaluate;(4)主函數main。

詳細代碼見Github項目的train.py文件。

值得注意的是,在實例化tokenizer時,一定要使用tokenizer.add_tokens('[Space]', special_tokens=True),目的是為了將[Space]作為一個切分整體,例如:'我愛[Space]北京天安門。',使用原始tokenizer分詞結果為'['我', '愛', '[', 'Space', ']', '北', '京', '天', '安','門','。']';增加切分符號后的結果為'['我', '愛', '[Space]', '北', '京', '天', '安','門','。']'。

模型測試

模型測試部分,主要是通過不同的解碼策略,對已經訓練好的模型進行單個樣本的預測。

(1)top_k或top_p解碼策略,僅保留top_k個或累積概率到達top_p的標記,其他標記設為filter_value,后續(xù)在選取標記的過程中會取不到值設為無窮小。

def top_k_top_p_filtering(logits, top_k, top_p, filter_value=-float('Inf')):
'''
top_k或top_p解碼策略,僅保留top_k個或累積概率到達top_p的標記,其他標記設為filter_value,后續(xù)在選取標記的過程中會取不到值設為無窮小。
Args:
logits: 預測結果,即預測成為詞典中每個詞的分數
top_k: 只保留概率最高的top_k個標記
top_p: 只保留概率累積達到top_p的標記
filter_value: 過濾標記值
Returns:
'''
# logits的維度必須為2,即size:[batch_size, vocab_size]
assert logits.dim() == 2
# 獲取top_k和字典大小中較小的一個,也就是說,如果top_k大于字典大小,則取字典大小個標記
top_k = min(top_k, logits[0].size(-1))
# 如果top_k不為0,則將在logits中保留top_k個標記
if top_k > 0:
# 由于有batch_size個預測結果,因此對其遍歷,選取每個預測結果的top_k標記
for logit in logits:
indices_to_remove = logit < torch.topk(logit, top_k)[0][..., -1, None]
logit[indices_to_remove] = filter_value
# 如果top_p不為0,則將在logits中保留概率值累積達到top_p的標記
if top_p > 0.0:
# 對logits進行遞減排序
sorted_logits, sorted_indices = torch.sort(logits, descending=True, dim=-1)
# 對排序后的結果使用softmax歸一化,再獲取累積概率序列
# 例如:原始序列[0.1, 0.2, 0.3, 0.4],則變?yōu)椋篬0.1, 0.3, 0.6, 1.0]
cumulative_probs = torch.cumsum(F.softmax(sorted_logits, dim=-1), dim=-1)
# 刪除累積概率高于top_p的標記
sorted_indices_to_remove = cumulative_probs > top_p
# 將索引向右移動,使第一個標記也保持在top_p之上
sorted_indices_to_remove[..., 1:] = sorted_indices_to_remove[..., :-1].clone()
sorted_indices_to_remove[..., 0] = 0
for index, logit in enumerate(logits):
# 由于有batch_size個預測結果,因此對其遍歷,選取每個預測結果的累積概率達到top_p的標記
indices_to_remove = sorted_indices[index][sorted_indices_to_remove[index]]
logit[indices_to_remove] = filter_value
return logits

(2)對單個樣本進行預測

def predict_one_sample(model, tokenizer, device, args, content):
'''
對單個樣本進行預測
Args:
model: 模型
tokenizer: 分詞器
device: 設備信息
args: 配置項信息
content: 新聞正文
Returns:
'''
# 對新聞正文進行預處理,并判斷如果超長則進行截斷
content_tokens = tokenizer.tokenize(content)
if len(content_tokens) > args.max_len - 3 - args.generate_max_len:
content_tokens = content_tokens[:args.max_len - 3 - args.generate_max_len]
# 獲取content_id、title_id、unk_id、sep_id值
content_id = tokenizer.convert_tokens_to_ids('[Content]')
title_id = tokenizer.convert_tokens_to_ids('[Title]')
unk_id = tokenizer.convert_tokens_to_ids('[UNK]')
sep_id = tokenizer.convert_tokens_to_ids('[SEP]')
# 將tokens索引化,變成模型所需格式
content_tokens = ['[CLS]'] + content_tokens + ['[SEP]']
input_ids = tokenizer.convert_tokens_to_ids(content_tokens)
# 將input_ids和token_type_ids進行擴充,擴充到需要預測標題的個數,即batch_size
input_ids = [copy.deepcopy(input_ids) for _ in range(args.batch_size)]
token_type_ids = [[content_id] * len(content_tokens) for _ in range(args.batch_size)]
# 將input_ids和token_type_ids變成tensor
input_tensors = torch.tensor(input_ids).long().to(device)
token_type_tensors = torch.tensor(token_type_ids).long().to(device)
next_token_type = torch.tensor([[title_id] for _ in range(args.batch_size)]).long().to(device)
# 用于存放每一步解碼的結果
generated = []
# 用于存放,完成解碼序列的序號
finish_set = set()
with torch.no_grad():
# 遍歷生成標題最大長度
for _ in range(args.generate_max_len):
outputs = model(input_ids=input_tensors, token_type_ids=token_type_tensors)
# 獲取預測結果序列的最后一個標記,next_token_logits size:[batch_size, vocab_size]
next_token_logits = outputs[0][:, -1, :]
# 對batch_size進行遍歷,將詞表中出現在序列中的詞的概率進行懲罰
for index in range(args.batch_size):
for token_id in set([token_ids[index] for token_ids in generated]):
next_token_logits[index][token_id] /= args.repetition_penalty
# 對batch_size進行遍歷,將詞表中的UNK的值設為無窮小
for next_token_logit in next_token_logits:
next_token_logit[unk_id] = -float('Inf')
# 使用top_k_top_p_filtering函數,按照top_k和top_p的值,對預測結果進行篩選
filter_logits = top_k_top_p_filtering(next_token_logits, top_k=args.top_k, top_p=args.top_p)
# 對filter_logits的每一行做一次取值,輸出結果是每一次取值時filter_logits對應行的下標,即詞表位置(詞的id)
# filter_logits中的越大的值,越容易被選中
next_tokens = torch.multinomial(F.softmax(filter_logits, dim=-1), num_samples=1)
# 判斷如果哪個序列的預測標記為sep_id時,則加入到finish_set
for index, token_id in enumerate(next_tokens[:, 0]):
if token_id == sep_id:
finish_set.add(index)
# 判斷,如果finish_set包含全部的序列序號,則停止預測;否則繼續(xù)預測
finish_flag = True
for index in range(args.batch_size):
if index not in finish_set:
finish_flag = False
break
if finish_flag:
break
# 將預測標記添加到generated中
generated.append([token.item() for token in next_tokens[:, 0]])
# 將預測結果拼接到input_tensors和token_type_tensors上,繼續(xù)下一次預測
input_tensors = torch.cat((input_tensors, next_tokens), dim=-1)
token_type_tensors = torch.cat((token_type_tensors, next_token_type), dim=-1)
# 用于存儲預測結果
candidate_responses = []
# 對batch_size進行遍歷,并將token_id變成對應漢字
for index in range(args.batch_size):
responses = []
for token_index in range(len(generated)):
# 判斷,當出現sep_id時,停止在該序列中添加token
if generated[token_index][index] != sep_id:
responses.append(generated[token_index][index])
else:
break
# 將token_id序列變成漢字序列,去除'##',并將[Space]替換成空格
candidate_responses.append(
''.join(tokenizer.convert_ids_to_tokens(responses)).replace('##', '').replace('[Space]', ' '))
return candidate_responses

詳細代碼見Github項目的generate_title.py文件。

測試結果如下:

從測試集中抽一篇
content:
今日,中國三條重要高鐵干線——蘭新高鐵、貴廣鐵路和南廣鐵路將開通運營。其中蘭新高鐵是中國首條高原高鐵,全長1776公里,最高票價658元。貴廣鐵路最貴車票320元,南廣鐵路最貴車票206.5元,這兩條線路大大縮短西南與各地的時空距離。出行更方便了!中國“高鐵版圖”再擴容 三條重要高鐵今日開通
title:
生成的第1個標題為:中國“高鐵版圖”再擴容 三條重要高鐵今日開通
生成的第2個標題為:貴廣鐵路最高鐵版圖
生成的第3個標題為:出行更方便了!中國“高鐵版圖”再擴容三條重要高鐵今日開通

模型上線

通過Flask框架搭建了一個Web服務,將新聞摘要生成模型進行工程化,可以通過頁面可視化地體驗新聞摘要生成效果。

詳細代碼見Github項目的http_server.py文件。

并且在我之前文章中,詳細介紹過如何使用Flask框架搭建Web服務,見:https://zhuanlan.zhihu.com/p/143678340

https://zhuanlan.zhihu.com/p/148224626

啟動服務命令:
python3 http_server.py

python3 http_server.py --http_id '0.0.0.0' --port 5555
本地測試直接使用'127.0.0.1:5555/news-title-generate',如果給他人訪問,只需將'127.0.0.1'替換成的電腦的IP地址即可。
初始頁面如下圖所示:
輸入新聞正文后,點擊“一鍵生成”,可以獲取到生成的新聞標題,如下圖所示:

后期工作

可能會將清華新聞數據、搜狗新聞數據等新聞數據集進行整理清洗,構建一個較完善的新聞摘要數據集。

可能會使用新聞數據訓練一個小的GPT2預訓練模型。
可能會對已上傳的新聞標題生成模型進行更新,訓練一個效果較好的模型。

總結

GPT2模型已經非常成熟,也有很多很好的開源項目。筆者本著開源之心,將代碼進行整理,增加詳細注釋,希望可以幫助大家更好地理解代碼。也歡迎大家留言討論。
參考
GPT2-Chinese:https://github.com/Morizeyao/GPT2-Chinese
GPT2-chitchat:https://github.com/yangjianxin1/GPT2-chitchat
CDial-GPT:https://github.com/thu-coai/CDial-GPT
GPT2:https://github.com/ConnorJL/GPT2
transformers:https://github.com/huggingface/transformers

    本站是提供個人知識管理的網絡存儲空間,所有內容均由用戶發(fā)布,不代表本站觀點。請注意甄別內容中的聯(lián)系方式、誘導購買等信息,謹防詐騙。如發(fā)現有害或侵權內容,請點擊一鍵舉報。
    轉藏 分享 獻花(0

    0條評論

    發(fā)表

    請遵守用戶 評論公約

    類似文章 更多