当前位置: 首页 > news >正文

一期6.文本摘要(md版)

1.1 项目背景介绍
文本摘要介绍¶
学习目标¶
理解什么是文本摘要任务.

了解文本摘要的基本方法和思路.
avatar

理解什么是文本摘要任务¶
本质: 文本摘要任务就是利用模型自动完成关键信息的抽取, 文本核心语义的概括, 用一个简短的结果文本来表达和原文本同样的意思, 并传达等效的信息. - 中学语文课的中心思想概括.

新浪体育上的体育新闻短评.

今日头条上的每日重要新闻概览.

英语考试中的概括某段落信息的选择题.

了解文本摘要的基本方法和思路.¶
从NLP的角度看待文本摘要任务, 主流的涵盖两大方法: - 抽取式摘要: Extraction-based

生成式摘要: Abstraction-based

抽取式摘要(Extraction-based)¶
直接从原文中选择若干条重要的句子, 并对它们进行排序和重组, 以形成摘要的方法. - 无监督抽取.

有监督抽取.

无监督抽取: 不需要平行语料, 节省了人工标记的成本.

最著名的无监督抽取方法就是TextRank算法. 这些方法都是基于统计层面的, 即最大化摘要句子对原始文档的表征能力.

有监督抽取: 通过神经网络来学习句子及其标签之间的对应关系, 需要平行语料, 需要人工标记的成本.

生成式摘要(Abstraction-based)¶
采用基于神经网络模型的结构, 通过Encoder + Decoder的连接方式, 自由生成一段概括源文档信息的文本.

生成式摘要基于对篇章article的精确理解, 回顾之前我们对字向量, 词向量的理解. 再到语句, 段落的理解, 难度不断增加. 现在升级到了大段落, 甚至篇章的理解, 因此文本摘要是一个很有难度的任务.

1.2 项目中的数据集初探
文本摘要项目数据集¶
学习目标¶
清晰了解本项目的数据.

清晰了解项目中的数据待处理任务.

清晰了解本项目的数据.¶
本项目中用到的数据集来源于百度汽车大师APP对话记录, 分两个数据文件提供, 分别是train.csv和test.csv.

训练数据集展示如下, 文件中共有数据82943行.

QID,Brand,Model,Question,Dialogue,Report
Q1,奔驰,奔驰GL级,方向机重,助力泵,方向机都换了还是一样,技师说:[语音]|车主说:新的都换了|车主说:助力泵,方向
机|技师说:[语音]|车主说:换了方向机带的有|车主说:[图片]|技师说:[语音]|车主说:有助力就是重,这车要匹配吧|技
师说:不需要|技师说:你这是更换的部件有问题|车主说:跑快了还好点,就倒车重的很。|技师说:是非常重吗|车主说:是
的,累人|技师说:[语音]|车主说:我觉得也是,可是车主是以前没这么重,选吧助理泵换了不行,又把放向机换了,现在还
这样就不知道咋和车主解释。|技师说:[语音]|技师说:[语音],随时联系
Q2,奔驰,奔驰M级,奔驰ML500排气凸轮轴调节错误,技师说:你这个有没有电脑检测故障代码。|车主说:有|技师说:发一下|>车主说:发动机之前亮故障灯、显示是失火、有点缺缸、现在又没有故障、发动机多少有点抖动、检查先前的故障是报这个故
障|车主说:稍等|车主说:显示图片太大传不了|技师说:[语音]|车主说:这个对发动机的抖动、失火、缺缸有直接联系吗?
|技师说:[语音]|车主说:还有就是报(左右排气凸轮轴作动电磁铁)对正极短路、对地短路、对导线断路|技师说:[语音]|车主说:这几个电磁阀和问您的第一个故障有直接关系吧|技师说:[语音]|车主说:这个有办法检测它好坏吗?|技师说:[语
音]|车主说:谢谢|技师说:不客气,随时联系
数据文件路径: /home/ec2-user/text_summary/seq2seq/data/train.csv

测试数据集展示如下, 文件中共有数据20000行.

QID,Brand,Model,Question,Dialogue
Q1,大众(进口),高尔夫(进口),我的帕萨特烧机油怎么办怎么办?,技师说:你好,请问你的车跑了多少公里了,如果在保修期
内,可以到当地的4店里面进行检查维修。如果已经超出了保修期建议你到当地的大型维修店进行检查,烧机油一般是发动机>活塞环间隙过大和气门油封老化引起的。如果每7500公里烧一升机油的话,可以在后备箱备一些机油,以便机油报警时有机油
及时补充,如果超过两升或者两升以上,建议你进行发动机检查维修。|技师说:你好|车主说:嗯
Q2,一汽-大众奥迪,奥迪A6,修一下多少钱是换还是修,技师说:你好师傅!抛光处理一下就好了!50元左右就好了,希望能够>帮到你!祝你生活愉快!
Q3,上汽大众,帕萨特,帕萨特领域 喇叭坏了 店里说方向盘里线坏了 换一根两三百不等 感觉太贵 ,技师说:你好,气囊>油丝坏了吗,这个价格不贵。可以更换。
Q4,南京菲亚特,派力奥,发动机漏气会有什么征兆?,技师说:你好!一:发动机没力,并伴有“啪啪”的漏气声音。二:发动机
没力,并伴有排气管冒黑烟。三:水温高,水箱盖出冒气泡出来。
Q5,东风本田,思铂睿,请问 那天右后胎扎了订,补了胎后跑高速80多开始有点抖,110时速以上抖动明显,以为是未做动平衡>导致,做了一样抖,请问是不是前面两条胎的问题导致?,技师说:你好师傅!可能前轮平衡快脱落或者不平衡造成的!建议>前轮做一下动平衡就好了!希望能够帮到你!祝你用车愉快!|车主说:谢谢大师!|技师说:不客气!祝您用车愉快!
数据文件路径: /home/ec2-user/text_summary/seq2seq/data/test.csv

结论: 本项目中真正能用于监督学习的数据只有train.csv中的数据, 因为test.csv没有人工摘要的标签Report列. 所以后续的训练集, 测试集都要通过train.csv进行划分. 而这里面的test.csv的功能其实是利用训练好的模型进行预测, 然后将预测结果文件提交至竞赛平台, 由百度平台的后端程序自动进行效果评估. 也就是说test.csv中对应的真实标签并没有公开给广大用户, 属于"闭卷考试题".

清晰了解项目中的数据待处理任务¶
一般来说, 在任何项目中, 面对原始数据都要进行接下来的几点工作: - 删除空值.

删除"脏"数据.

删除特定字符的集合.

分词.

完成字符到id的映射.

完成padding, cutting的工作.

数据集中的空值¶
本项目中首先要评估的就是数据集中的空值情况.

import pandas as pd

train_path = 'train.csv'
test_path = 'test.csv'

df = pd.read_csv(train_path, encoding='utf-8')
df.info()

print('**********************')

df = pd.read_csv(test_path, encoding='utf-8')
df.info()
输出结果:

<class 'pandas.core.frame.DataFrame'>
RangeIndex: 82943 entries, 0 to 82942
Data columns (total 6 columns):

Column Non-Null Count Dtype


0 QID 82943 non-null object
1 Brand 81642 non-null object
2 Model 81642 non-null object
3 Question 82943 non-null object
4 Dialogue 82941 non-null object
5 Report 82873 non-null object
dtypes: object(6)


<class 'pandas.core.frame.DataFrame'>
RangeIndex: 20000 entries, 0 to 19999
Data columns (total 5 columns):

Column Non-Null Count Dtype


0 QID 20000 non-null object
1 Brand 19987 non-null object
2 Model 19987 non-null object
3 Question 20000 non-null object
4 Dialogue 20000 non-null object
dtypes: object(5)
结论: 经过初步统计, 可以很清晰的看到同一个数据文件中, 不同的数据列非空样本数量并不相同. 所以删除空值的是必须的环节, 否则后续处理文件会报错, 后续构建训练样本也会因为缺少特征而报错.

数据集中的脏数据¶
对于生产环境中的现实数据, 总会有各种各样的情况, 我们喜欢"干净"的数据, 不喜欢"脏"的数据. 一般来说, 关于脏数据没有严格的统一定义, 都是各个公司根据不同的业务线, 业务场景自行定义.

比如我们在测试集样本test.csv中截取两行样本, 展示如下:

Q34,一汽-大众奥迪,奥迪A4L,刚刚亮起,是什么故障灯?,技师说:这是发动机故障灯|技师说:该故障灯点亮,表示发动机系
统检测到故障,需要用解码器读取故障记录。根据故障提示排查问题。|车主说:[图片]|车主说:抬速不出常,平时正常是800转,今晚突然底转速,当推到p挡后转速升到千二三转,然后又降下来|车主说:这是什么问题
Q35,上汽大众,朗逸,朗逸17款新车。300公里。行驶中发现转数高,停车熄火。档位在D挡回不去。刹车踩不下去,打不燃火,>技师说:你好,发动机故障灯亮了没有,检查一下刹车真空助力|车主说:[图片]|车主说:请问怎么检查|车主说:可以电话>交流吗?|技师说:没办法,我们有规定不让留联系方式,车子启动,起动机工作不|技师说:我问你回答,尽最大努力帮你解
决问题!|车主说:点火完全没有反应。档位回不去。刹车踩不了。方向盘锁死|技师说:仪表盘上防盗灯有没有亮,|车主说>:我刚刚给你发了一张图片。亮的就是那些|技师说:检查一下发动机舱和驾驶员车门旁边仪表台测面有没有保险丝烧了|车主
说:没有看到。麻烦问一下。这种情况我的车还没有上户。应该怎么找4s店处理|技师说:新车,4s店要给质保的,要把问题>帮你彻底解决,并且告诉你造成这种故障的原因,|车主说:可以找他理赔吗?|车主说:自动挡能不能找车拉回去呢?|技师>说:自,在变速器杆旁边有个机械回位的插孔,可以把档位回到空挡上,用车拉,要不然自动挡不能直接拉|技师说:放心把>,他们肯定要帮你把问题解决的,如果解决不了,你可以申请理赔|车主说:机械回位在哪?|技师说:在换挡杆周边,塑料盖
子下面,|车主说:[图片]|车主说:哪里?|技师说:把皮套拿掉|车主说:[图片]|车主说:哪一个?|车主说:复位了,火打
燃了|技师说:那就好|技师说:可以去4s店找他们,|车主说:谢谢|技师说:不用谢
可以发现, 汽车大师中的对话内容由两方面要考虑: - 第一: 很多"技师说", "车主说", 类似这样字符串其实对于文本语义没有任何帮助, 属于"停用词"的类型.

第二: 文本中有"[图片]"字样, 这是在APP端可见的图片, 但是在本项目中不可见. 因此这样的字符子串也属于"脏数据".

结论: 经过上述分析, 可知在未来的数据预处理中包含"脏"数据的部分都应该删除掉.

数据集中的特殊字符¶
关于特殊字符一般来说, 除了常用的停用词表, 主要靠程序员的观察, 总结. 再次展示test.csv中的若干样本文本:

Q1,大众(进口),高尔夫(进口),我的帕萨特烧机油怎么办怎么办?,技师说:你好,请问你的车跑了多少公里了,如果在保修期
内,可以到当地的4店里面进行检查维修。如果已经超出了保修期建议你到当地的大型维修店进行检查,烧机油一般是发动机>活塞环间隙过大和气门油封老化引起的。如果每7500公里烧一升机油的话,可以在后备箱备一些机油,以便机油报警时有机油
及时补充,如果超过两升或者两升以上,建议你进行发动机检查维修。|技师说:你好|车主说:嗯
Q38,一汽-大众奥迪,奥迪A6,05年的圆屁股a6,1.8T自动挡,能卖多少钱,一手车,车况好【车型:奥迪A6】,技师说:你好,
你可以在当地二手车市场让他们估价,我想大概还能值30000-40000左右吧。谢谢
Q39,北京现代,瑞纳,新买的车,想贴膜,请问什么牌子的膜好用?在一般的店里贴膜大概多少钱?当然不要太贵的,就是经济
实惠的~,技师说:你好,最好是熟人修理厂,因为贴膜利润是比较大得,暴利产品,四门后档便宜,前挡风玻璃要贵一点得>。祝你们幸福的,|车主说:请问四门后挡贴个一般的大概多少钱|技师说:熟人得话四门加后档四五百,一个前挡也是四百块
钱左右。如果是比较熟或者亲戚更便宜,因为膜得利润是百分几百
结论: 有的字符是"(进口)", 这个字样对摘要意义不大(当然了, 你也可以认为意义很大!). 还有"【车型:奥迪A6】"这样的中文输入法下的中括号, 也属于特殊字符, 意义不大. 还有"就是经济
实惠的~,", 这种带波浪线的特殊字符, 都是未来数据预处理中需要删除的无效字符.

后续的关于分词, 完成word_to_id的映射, 以及完成padding, cutting的工作, 就属于更纯粹的数据处理领域了. 等到后面章节中具体用到的时候我们具体分析, 处理.

2.1 TextRank算法理论基础
TextRank算法介绍¶
学习目标¶
理解TextRank算法的来源.

掌握TextRank算法的概念.

掌握TextRank算法的代码实践.

TextRank算法的来源¶
在介绍TextRank算法之前, 我们先来简单回顾一下著名的PageRank算法.

PageRank算法: 通过计算网页链接的数量和质量来评估网页的重要性, 算法发明人即谷歌的两位联合创始人之一的拉里.佩奇(Larry Page). 最初被应用在搜索引擎优化操作中.

联想思维: PageRank算法其实是借鉴了学术界评价学术论文重要性的通用方法-"影响因子", 可以直观的理解为"该论文被引用的次数".

这样就可以很自然的得到PageRank的核心思想: - 如果一个网页被很多其他网页连接到的话, 说明这个网页比较重要, 也就是PageRank值会比较高.

如果一个PageRank值很高的网页链接到另一个网页, 那么被链接到的那个网页的PageRank值也会相应的被提高.

TextRank算法的概念¶
对比于衡量网页重要性的PageRank算法, TextRank算法用于衡量哪些单词是关键词, 类比之下的算法思想也就很好理解了: - 如果一个单词出现在很多单词的后面, 就是它和很多单词有关联, 那么说明这个单词比较重要.

如果一个TextRank值很高的单词后面跟着另一个单词, 那么后面这个单词的TextRank值也会相应的被提高.

如果对TextRank更深的理论感兴趣, 可以直接查询原始论文, 地址如下: - https://web.eecs.umich.edu/~mihalcea/papers/mihalcea.emnlp04.pdf

TextRank算法代码实践¶
在本小节中, 我们仅以示例代码跑通几段小程序, 让同学们掌握如何具体在代码层面用TextRank. - 关键词抽取(keyword extraction)

关键短语抽取(keyphrase extraction)

关键句抽取(sentence extraction)

关键词抽取(keyword extraction)¶
关键词抽取: 是指从文本中确定一些能够描述文档含义的关键术语的过程.

对关键词抽取而言, 用于构建顶点集的文本单元可以使句子中的一个或多个字. 根据这些字之间的关系构建边.

根据任务的需要, 可以使用语法过滤器(syntactic filters)对顶点集进行优化. 语法过滤器的主要作用是将某一类或者某几类词性的字过滤出来作为顶点集.

在真实的企业场景下, 应用TextRank一般都直接采用基于textrank4zh工具包来说辅助工程.

安装工具包: pip install textrank4zh

coding=utf-8

导入textrank4zh的相关工具包

from textrank4zh import TextRank4Keyword, TextRank4Sentence

导入常用工具包

import pandas as pd
import numpy as np

关键词抽取

def keywords_extraction(text):
# allow_speech_tags : 词性列表, 用于过滤某些词性的词
tr4w = TextRank4Keyword(allow_speech_tags=['n', 'nr', 'nrfg', 'ns', 'nt', 'nz'])

# text: 文本内容, 字符串
# window: 窗口大小, int, 用来构造单词之间的边, 默认值为2
# lower: 是否将英文文本转换为小写, 默认值为False
# vertex_source: 选择使用words_no_filter, words_no_stop_words, words_all_filters中的>哪一个来构造pagerank对应的图中的节点
#                默认值为'all_filters', 可选值为'no_filter', 'no_stop_words', 'all_filters'
# edge_source: 选择使用words_no_filter, words_no_stop_words, words_all_filters中的哪>一个来构造pagerank对应的图中的节点之间的边
#              默认值为'no_stop_words', 可选值为'no_filter', 'no_stop_words', 'all_filters', 边的构造要结合window参数
# pagerank_config: pagerank算法参数配置, 阻尼系数为0.85tr4w.analyze(text=text, window=2, lower=True, vertex_source='all_filters',edge_source='no_stop_words', pagerank_config={'alpha': 0.85, })# num: 返回关键词数量
# word_min_len: 词的最小长度, 默认值为1    
keywords = tr4w.get_keywords(num=6, word_min_len=2)# 返回关键词
return keywords

调用:

if name == "main":
text = "来源:中国科学报本报讯(记者肖洁)又有一位中国科学家喜获小行星命名殊荣!4月19日下午,中国科学院国家天文台在京举行“周又元星”颁授仪式,"
"我国天文学家、中国科学院院士周又元的弟子与后辈在欢声笑语中济济一堂。国家天文台党委书记、"
"副台长赵刚在致辞一开始更是送上白居易的诗句:“令公桃李满天下,何须堂前更种花。”"
"据介绍,这颗小行星由国家天文台施密特CCD小行星项目组于1997年9月26日发现于兴隆观测站,"
"获得国际永久编号第120730号。2018年9月25日,经国家天文台申报,"
"国际天文学联合会小天体联合会小天体命名委员会批准,国际天文学联合会《小行星通报》通知国际社会,"
"正式将该小行星命名为“周又元星”。"

#关键词抽取
keywords=keywords_extraction(text)
print(keywords)

输出结果:

[{'word': '小行星', 'weight': 0.05808441467341854},
{'word': '天文台', 'weight': 0.05721653775742513},
{'word': '命名', 'weight': 0.0485177005159723},
{'word': '中国', 'weight': 0.045716478124251815},
{'word': '中国科学院', 'weight': 0.037818937836996636},
{'word': '国家', 'weight': 0.03438059254484016}]
关键短语抽取(keyphrase extraction)¶
关键短语抽取: 关键词抽取结束后, 可以得到N个关键词, 在原始文本中相邻的关键词便构成了关键短语.

具体方法: 分析get_keyphrases()函数可知, 内部实现上先调用get_keywords()得到关键词, 然后分析关键词是否存在相邻的情况, 最后即可确定哪些是关键短语.

from textrank4zh import TextRank4Keyword, TextRank4Sentence

关键短语抽取

def keyphrases_extraction(text):
tr4w = TextRank4Keyword()

tr4w.analyze(text=text, window=2, lower=True, vertex_source='all_filters',edge_source='no_stop_words', pagerank_config={'alpha': 0.85, })# keywords_num: 抽取的关键词数量
# min_occur_num: 关键短语在文中的最少出现次数
keyphrases = tr4w.get_keyphrases(keywords_num=6, min_occur_num=1)# 返回关键短语
return keyphrases

调用:

if name == "main":
text = "来源:中国科学报本报讯(记者肖洁)又有一位中国科学家喜获小行星命名殊荣!4月19日下午,中国科学院国家天文台在京举行“周又元星”颁授仪式,"
"我国天文学家、中国科学院院士周又元的弟子与后辈在欢声笑语中济济一堂。国家天文台党委书记、"
"副台长赵刚在致辞一开始更是送上白居易的诗句:“令公桃李满天下,何须堂前更种花。”"
"据介绍,这颗小行星由国家天文台施密特CCD小行星项目组于1997年9月26日发现于兴隆观测站,"
"获得国际永久编号第120730号。2018年9月25日,经国家天文台申报,"
"国际天文学联合会小天体联合会小天体命名委员会批准,国际天文学联合会《小行星通报》通知国际社会,"
"正式将该小行星命名为“周又元星”。"

#关键短语抽取
keyphrases=keyphrases_extraction(text)
print(keyphrases)

输出结果:

['小行星命名']
关键句抽取(sentence extraction)¶
关键句抽取: 句子抽取任务主要就是为了解决自动文本摘要任务, 将每一个sentence作为一个顶点, 根据两个句子之间的内容重复程度来计算他们之间的相似度. 由于不同的句子对之间相似度大小不同, 因此最终构建的是以相似度大小作为edge权重的有权图.

代码实现中, 可以直接调用函数完成:

from textrank4zh import TextRank4Keyword, TextRank4Sentence

关键句抽取

def keysentences_extraction(text):
tr4s = TextRank4Sentence()

# text: 文本内容, 字符串
# lower: 是否将英文文本转换为小写, 默认值为False
# source: 选择使用words_no_filter, words_no_stop_words, words_all_filters中的哪一个来生成句子之间的相似度
#         默认值为'all_filters', 可选值为'no_filter', 'no_stop_words', 'all_filters'
tr4s.analyze(text, lower=True, source='all_filters')# 获取最重要的num个长度大于等于sentence_min_len的句子用来生成摘要
keysentences = tr4s.get_key_sentences(num=3, sentence_min_len=6)# 返回关键句子
return keysentences

调用:

if name == "main":
text = "来源:中国科学报本报讯(记者肖洁)又有一位中国科学家喜获小行星命名殊荣!4月19日下午,中国科学院国家天文台在京举行“周又元星”颁授仪式,"
"我国天文学家、中国科学院院士周又元的弟子与后辈在欢声笑语中济济一堂。国家天文台党委书记、"
"副台长赵刚在致辞一开始更是送上白居易的诗句:“令公桃李满天下,何须堂前更种花。”"
"据介绍,这颗小行星由国家天文台施密特CCD小行星项目组于1997年9月26日发现于兴隆观测站,"
"获得国际永久编号第120730号。2018年9月25日,经国家天文台申报,"
"国际天文学联合会小天体联合会小天体命名委员会批准,国际天文学联合会《小行星通报》通知国际社会,"
"正式将该小行星命名为“周又元星”。"

#关键句抽取
keysentences=keysentences_extraction(text)
print(keysentences)

输出结果:

[{'index': 4, 'sentence': '2018年9月25日,经国家天文台申报,国际天文学联合会小天体联合会小天体命名委员会批准,国际天文学联合会《小行星通报》通知国际社会,正式将该小行星命名为“周又元星”', 'weight': 0.2281040325096452},
{'index': 3, 'sentence': '”据介绍,这颗小行星由国家天文台施密特CCD小行星项目组于1997年9月26日发现于兴隆观测站,获得国际永久编号第120730号', 'weight': 0.2106246105971721},
{'index': 1, 'sentence': '4月19日下午,中国科学院国家天文台在京举行“周又元星”颁授仪式,我国天文学家、中国科学院院士周又元的弟子与后辈在欢声笑语中济济一堂', 'weight': 0.2020923401661083}]
基于jieba的TextRank算法¶
jieba工具不仅仅可以用来分词, 进行词性分析. 也可以用来完成TextRank.

import jieba.analyse

def jieba_keywords_textrank(text):
keywords = jieba.analyse.textrank(text, topK=6)
return keywords
调用:

if name == "main":
text = "来源:中国科学报本报讯(记者肖洁)又有一位中国科学家喜获小行星命名殊荣!4月19日下午,中国科学院国家天文台在京举行“周又元星”颁授仪式,"
"我国天文学家、中国科学院院士周又元的弟子与后辈在欢声笑语中济济一堂。国家天文台党委书记、"
"副台长赵刚在致辞一开始更是送上白居易的诗句:“令公桃李满天下,何须堂前更种花。”"
"据介绍,这颗小行星由国家天文台施密特CCD小行星项目组于1997年9月26日发现于兴隆观测站,"
"获得国际永久编号第120730号。2018年9月25日,经国家天文台申报,"
"国际天文学联合会小天体联合会小天体命名委员会批准,国际天文学联合会《小行星通报》通知国际社会,"
"正式将该小行星命名为“周又元星”。"

# 基于jieba的textrank算法实现
keywords = jieba_keywords_textrank(text)
print(keywords)

输出结果:

['小行星', '命名', '国际', '中国', '国家', '天文学家']
小节总结¶
小节总结: 2.1小节介绍了TextRank算法的来源和核心思想, 并通过代码实践, 依次提取关键词, 关键短语, 关键句. 利用textrank4zh工具和jieba工具来实现TextRank, 既方便又好用.

2.2 TextRank实现模型
基于TextRank实现模型¶
学习目标¶
选定中文文本摘要数据集.

掌握对于数据的预处理过程.

掌握TextRank模型的代码实现.

文本摘要数据集¶
此处选择验证集数据dev.csv, 作为TextRank算法构建模型的原始数据.

QID,Brand,Model,Question,Dialogue,Report
Q70001,别克,英朗,15款英朗因为油底壳撞烂,导致机油泄露曲轴抱死,要大修,但是没换曲轴只是加工校正了。现在跑高速>上100码发动机故障灯闪烁,降到100码一下过10秒左右故障灯灭,用电脑检测14缸缺火,换了火花塞和点火线圈还是一样。,>技师说:你好!以前也出现过该故障吗?|技师说:缸压多少有没有测量一下?|车主说:没有过|车主说:没测缸压|技师说:>测量一下缸压 看一四缸缸压是否偏低|车主说:用电脑测,只是14缸缺火|车主说:[语音]|车主说:[语音]|技师说:点火线>圈 火花塞 喷油嘴不用干活 直接和二三缸对倒一下 跑一段在测量一下故障码进行排除|车主说:[语音]|车主说:[语音]|车主说:[语音]|车主说:[语音]|车主说:师傅还在吗|技师说:调一下喷油嘴 测一下缸压 都正常则为发动机电脑板问题|车主说:[语音]|车主说:[语音]|车主说:[语音]|技师说:这个影响不大的|技师说:缸压八个以上正常|车主说:[语音]|技
师说:所以说让你测量缸压 只要缸压正常则没有问题|车主说:[语音]|车主说:[语音]|技师说:可以点击头像关注我 有>什么问题随时询问 一定真诚用心为你解决|车主说:师傅,谢谢了|技师说:不用客气,检修喷油嘴 缸压 及电脑板是否正常

Q70002,标致,标致3008,你好,今天清洗发动机后发现锁车后过一段时间喇叭会一直响,然后到晚上时候锁车,双闪灯不会亮>,过一会儿才亮,是什么情况?,技师说:你好,相关的线路进水导致的防盗系统线路故障,这种故障只能等相关的线路水分>晾干或者是去修理厂排查线路|车主说:会不会影响什么?|技师说:别的方面影响不大|车主说:等我明天早上再看看有没有问
题,明早上应该干了|车主说:是不是干了就不怕了?|车主说:不干继续开怕不怕?|技师说:干了就没有任何问题,清洗发动
机造成的相关线路进水,防盗系统连电造成的,这种情况只能等水彻底干了,以后才行。
Q70003,现代,北京现代ix35,请问多少钱,技师说:你好!可以说的具体点吗?|车主说:北京现代ix35,2018新款2.0L自动版>的多少钱|技师说:两驱还是四驱?|技师说:两驱12万左右 四驱14万左右|车主说:你认为那个好点|技师说:个人认为两驱>的动力就可以了 满足需要|技师说:要根据你自己的需求 我的建议仅供参考|车主说:好的谢谢|技师说:不用客气|技师说
:可以点击头像关注我 有什么不明白的随时询问 一定真诚用心为你解决,13万左右 不同地区价格有些差异 仅供参考
数据文件路径: /home/ec2-user/text_summary/textrank/dev.csv, 共有12943条样本数据.

数据预处理¶
我们在第一章的1.2小节曾经讨论过原始数据存在的各种问题, 这些问题都需要在数据预处理的这个环节一一解决. 接下来按照如下步骤进行处理: - 第一步: 提取特定的文本.

第二步: 删除"脏"数据.

第三步: 删除特定的字符集合.

第四步: 删除特殊位置的特定字符.

第一步: 提取特定的文本.¶
面对原始语料, 并不是说我们必须要全部纳入模型中, 可以根据业务需求, 或者程序员的项目经验, 或许出于尝试的态度, 只选取一部分出来作为我们后续模型的输入数据.

这里我们按照'|'对语句进行分隔, 只选取'技师'说的话作为后续应用TextRank算法的语料数据.

代码文件路径: /home/ec2-user/text_summary/textrank/model.py

def clean_sentence(sentence):
# 1. 将sentence按照'|'分句,并只提取技师的话
sub_jishi = []
# 按照'|'字符将车主和用户的对话分离
sub = sentence.split('|')

# 遍历每个子句
for i in range(len(sub)):
# 如果不是以句号结尾, 增加一个句号
if not sub[i].endswith('。'):
sub[i] += '。'
# 只使用技师说的句子
if sub[i].startswith('技师'):
sub_jishi.append(sub[i])

# 拼接成字符串并返回
sentence = ''.join(sub_jishi)

return sentence
调用:

if name == 'main':
# 读取数据, 并指定编码格式为'utf-8'
df = pd.read_csv('dev.csv', engine='python', encoding='utf-8')
texts = df['Dialogue'].tolist()
print('预处理前的第一条句子:', texts[0])
print('********************************')

# 数据预处理
res = clean_sentence(texts[0])
print('预处理后的第一条句子: ', res)
输出结果:

预处理前的第一条句子: 技师说:你好!以前也出现过该故障吗?|技师说:缸压多少有没有测量一下?|车主说:没有过|车主说:没测缸压|技师说:测量一下缸压 看一四缸缸压是否偏低|车主说:用电脑测,只是14缸缺火|车主说:[语音]|车主说:[语音]|技师说:点火线圈 火花塞 喷油嘴不用干活 直接和二三缸对倒一下 跑一段在测量一下故障码进行排除|车主说:[语音]|车主说:[语音]|车主说:[语音]|车主说:[语音]|车主说:师傅还在吗|技师说:调一下喷油嘴 测一下缸压 都正常则为发动机电脑板问题|车主说:[语音]|车主说:[语音]|车主说:[语音]|技师说:这个影响不大的|技师说:缸压八个以上正常|车主说:[语音]|技师说:所以说让你测量缸压 只要缸压正常则没有问题|车主说:[语音]|车主说:[语音]|技师说:可以点击头像关注我 有什么问题随时询问 一定真诚用心为你解决|车主说:师傅,谢谢了|技师说:不用客气


预处理后的第一条句子: 技师说:你好!以前也出现过该故障吗?。技师说:缸压多少有没有测量一下?。技师说:测量一下缸压 看一四缸缸压是否偏低。技师说:点火线圈 火花塞 喷油嘴不用干活 直接和二三缸对倒一下 跑一段在测量一下故障码进行排除。技师说:调一下喷油嘴 测一下缸压 都正常则为发动机电脑板问题。技师说:这个影响不大的。技师说:缸压八个以上正常。技师说:所以说让你测量缸压 只要缸压正常则没有问题。技师说:可以点击头像关注我 有什么问题随时询问 一定真诚用心为你解决。技师说:不用客气。
第二步: 删除"脏"数据.¶
关于什么是"脏"数据是个千人千面的问题, 我们在第一章中也讨论过. 这一步也仅仅处理一个baseline的级别.

代码文件路径: /home/ec2-user/text_summary/textrank/model.py

导入正则表达式工具包, 用来删除特定模式的数据

import re

def clean_sentence(sentence):
# 1. 将sentence按照'|'分句,并只提取技师的话
sub_jishi = []
# 按照'|'字符将车主和用户的对话分离
sub = sentence.split('|')

# 遍历每个子句
for i in range(len(sub)):
# 如果不是以句号结尾, 增加一个句号
if not sub[i].endswith('。'):
sub[i] += '。'
# 只使用技师说的句子
if sub[i].startswith('技师'):
sub_jishi.append(sub[i])

# 拼接成字符串并返回
sentence = ''.join(sub_jishi)

# 第二步中添加的两个处理, 利用正则表达式re工具
# 2. 删除1. 2. 3. 这些标题
r = re.compile("\D(\d.)\D")
sentence = r.sub("", sentence)

# 3. 删除一些无关紧要的词以及语气助词
r = re.compile(r"车主说|技师说|语音|图片|呢|吧|哈|啊|啦")
sentence = r.sub("", sentence)


return sentence
调用:

if name == 'main':
# 读取数据, 并指定编码格式为'utf-8'
df = pd.read_csv('dev.csv', engine='python', encoding='utf-8')
texts = df['Dialogue'].tolist()
print('预处理前的第一条句子:', texts[0])
print('********************************')

# 数据预处理
res = clean_sentence(texts[0])
print('预处理后的第一条句子: ', res)

输出结果:

预处理前的第一条句子: 技师说:你好!以前也出现过该故障吗?|技师说:缸压多少有没有测量一下?|车主说:没有过|车主说:没测缸压|技师说:测量一下缸压 看一四缸缸压是否偏低|车主说:用电脑测,只是14缸缺火|车主说:[语音]|车主说:[语音]|技师说:点火线圈 火花塞 喷油嘴不用干活 直接和二三缸对倒一下 跑一段在测量一下故障码进行排除|车主说:[语音]|车主说:[语音]|车主说:[语音]|车主说:[语音]|车主说:师傅还在吗|技师说:调一下喷油嘴 测一下缸压 都正常则为发动机电脑板问题|车主说:[语音]|车主说:[语音]|车主说:[语音]|技师说:这个影响不大的|技师说:缸压八个以上正常|车主说:[语音]|技师说:所以说让你测量缸压 只要缸压正常则没有问题|车主说:[语音]|车主说:[语音]|技师说:可以点击头像关注我 有什么问题随时询问 一定真诚用心为你解决|车主说:师傅,谢谢了|技师说:不用客气


预处理后的第一条句子: :你好!以前也出现过该故障吗?。:缸压多少有没有测量一下?。:测量一下缸压 看一四缸缸压是否偏低。:点火线圈 火花塞 喷油嘴不用干活 直接和二三缸对倒一下 跑一段在测量一下故障码进行排除。:调一下喷油嘴 测一下缸压 都正常则为发动机电脑板问题。:这个影响不大的。:缸压八个以上正常。:所以说让你测量缸压 只要缸压正常则没有问题。:可以点击头像关注我 有什么问题随时询问 一定真诚用心为你解决。:不用客气。
第三步: 删除特定的字符集合.¶
对于文本中哪些字符是需要删除的特定字符, 这又是一个见仁见智的问题. 取决于程序员的经验, 个人喜好, 还有业务部门的具体需求. - 1: 我们发现原始数据文件中有若干的"进口", "海外"字样, 可认为是需要删除的特定字符.

2: 为了后续处理文本容易, 除了汉字还有数字, 英文字母, 特定的几个标点符号, 其他都删除.

3: 将标点符号的半角格式, 转变成全角格式.

4: 将问号, 感叹号, 转变成句号.

代码文件路径: /home/ec2-user/text_summary/textrank/model.py

导入正则表达式工具包, 用来删除特定模式的数据

import re

def clean_sentence(sentence):
# 第一步要处理的代码
# 1. 将sentence按照'|'分句,并只提取技师的话
sub_jishi = []
# 按照'|'字符将车主和用户的对话分离
sub = sentence.split('|')

# 遍历每个子句
for i in range(len(sub)):# 如果不是以句号结尾, 增加一个句号if not sub[i].endswith('。'):sub[i] += '。'# 只使用技师说的句子if sub[i].startswith('技师'):sub_jishi.append(sub[i])# 拼接成字符串并返回
sentence = ''.join(sub_jishi)# 第二步中添加的两个处理, 利用正则表达式re工具
# 2. 删除1. 2. 3. 这些标题
r = re.compile("\D(\d\.)\D")
sentence = r.sub("", sentence)# 3. 删除一些无关紧要的词以及语气助词
r = re.compile(r"车主说|技师说|语音|图片|呢|吧|哈|啊|啦")
sentence = r.sub("", sentence)# 第三步中添加的4个处理
# 4. 删除带括号的 进口 海外
r = re.compile(r"[((]进口[))]|\(海外\)")
sentence = r.sub("", sentence)# 5. 删除除了汉字数字字母和,!?。.- 以外的字符
r = re.compile("[^,!?。\.\-\u4e00-\u9fa5_a-zA-Z0-9]")# 6. 半角变为全角
sentence = sentence.replace(",", ",")
sentence = sentence.replace("!", "!")
sentence = sentence.replace("?", "?")# 7. 问号叹号变为句号
sentence = sentence.replace("?", "。")
sentence = sentence.replace("!", "。")
sentence = r.sub("", sentence)return sentence

调用:

if name == 'main':
# 读取数据, 并指定编码格式为'utf-8'
df = pd.read_csv('dev.csv', engine='python', encoding='utf-8')
texts = df['Dialogue'].tolist()
print('预处理前的第一条句子:', texts[0])
print('********************************')

# 数据预处理
res = clean_sentence(texts[0])
print('预处理后的第一条句子: ', res)

输出结果:

预处理前的第一条句子: 技师说:你好!以前也出现过该故障吗?|技师说:缸压多少有没有测量一下?|车主说:没有过|车主说:没测缸压|技师说:测量一下缸压 看一四缸缸压是否偏低|车主说:用电脑测,只是14缸缺火|车主说:[语音]|车主说:[语音]|技师说:点火线圈 火花塞 喷油嘴不用干活 直接和二三缸对倒一下 跑一段在测量一下故障码进行排除|车主说:[语音]|车主说:[语音]|车主说:[语音]|车主说:[语音]|车主说:师傅还在吗|技师说:调一下喷油嘴 测一下缸压 都正常则为发动机电脑板问题|车主说:[语音]|车主说:[语音]|车主说:[语音]|技师说:这个影响不大的|技师说:缸压八个以上正常|车主说:[语音]|技师说:所以说让你测量缸压 只要缸压正常则没有问题|车主说:[语音]|车主说:[语音]|技师说:可以点击头像关注我 有什么问题随时询问 一定真诚用心为你解决|车主说:师傅,谢谢了|技师说:不用客气


预处理后的第一条句子: 你好。以前也出现过该故障吗。。缸压多少有没有测量一下。。测量一下缸压看一四缸缸压是否偏低。点火线圈火花塞喷油嘴不用干活直接和二三缸对倒一下跑一段在测量一下故障码进行排除。调一下喷油嘴测一下缸压都正常则为发动机电脑板问题。这个影响不大的。缸压八个以上正常。所以说让你测量缸压只要缸压正常则没有问题。可以点击头像关注我有什么问题随时询问一定真诚用心为你解决。不用客气。
第四步: 删除特殊位置的特定字符.¶
原始文本中有些特定位置会有特定的字符, 比如每行文本的起始处的行号, 比如结尾处的特殊结尾符, 或者其他位置的那些和文本语义无关的特定字符, 都需要删除掉.

这里面完全取决于程序员的细心, 耐心, 多看多分析你要处理的文本数据.

代码文件路径: /home/ec2-user/text_summary/textrank/model.py

导入正则表达式工具包, 用来删除特定模式的数据

import re

def clean_sentence(sentence):
# 第一步要处理的代码
# 1. 将sentence按照'|'分句,并只提取技师的话
sub_jishi = []
# 按照'|'字符将车主和用户的对话分离
sub = sentence.split('|')

# 遍历每个子句
for i in range(len(sub)):# 如果不是以句号结尾, 增加一个句号if not sub[i].endswith('。'):sub[i] += '。'# 只使用技师说的句子if sub[i].startswith('技师'):sub_jishi.append(sub[i])# 拼接成字符串并返回
sentence = ''.join(sub_jishi)# 第二步中添加的两个处理, 利用正则表达式re工具
# 2. 删除1. 2. 3. 这些标题
r = re.compile("\D(\d\.)\D")
sentence = r.sub("", sentence)# 3. 删除一些无关紧要的词以及语气助词
r = re.compile(r"车主说|技师说|语音|图片|呢|吧|哈|啊|啦")
sentence = r.sub("", sentence)# 第三步中添加的4个处理
# 4. 删除带括号的 进口 海外
r = re.compile(r"[((]进口[))]|\(海外\)")
sentence = r.sub("", sentence)# 5. 删除除了汉字数字字母和,!?。.- 以外的字符
r = re.compile("[^,!?。\.\-\u4e00-\u9fa5_a-zA-Z0-9]")# 6. 半角变为全角
sentence = sentence.replace(",", ",")
sentence = sentence.replace("!", "!")
sentence = sentence.replace("?", "?")# 7. 问号叹号变为句号
sentence = sentence.replace("?", "。")
sentence = sentence.replace("!", "。")
sentence = r.sub("", sentence)# 第四步添加的删除特定位置的特定字符
# 8. 删除句子开头的逗号
if sentence.startswith(','):sentence = sentence[1:]return sentence

调用:

if name == 'main':
# 读取数据, 并指定编码格式为'utf-8'
df = pd.read_csv('dev.csv', engine='python', encoding='utf-8')
texts = df['Dialogue'].tolist()
print('预处理前的第一条句子:', texts[0])
print('********************************')

# 数据预处理
res = clean_sentence(texts[0])
print('预处理后的第一条句子: ', res)

输出结果:

预处理前的第一条句子: 技师说:你好!以前也出现过该故障吗?|技师说:缸压多少有没有测量一下?|车主说:没有过|车主说:没测缸压|技师说:测量一下缸压 看一四缸缸压是否偏低|车主说:用电脑测,只是14缸缺火|车主说:[语音]|车主说:[语音]|技师说:点火线圈 火花塞 喷油嘴不用干活 直接和二三缸对倒一下 跑一段在测量一下故障码进行排除|车主说:[语音]|车主说:[语音]|车主说:[语音]|车主说:[语音]|车主说:师傅还在吗|技师说:调一下喷油嘴 测一下缸压 都正常则为发动机电脑板问题|车主说:[语音]|车主说:[语音]|车主说:[语音]|技师说:这个影响不大的|技师说:缸压八个以上正常|车主说:[语音]|技师说:所以说让你测量缸压 只要缸压正常则没有问题|车主说:[语音]|车主说:[语音]|技师说:可以点击头像关注我 有什么问题随时询问 一定真诚用心为你解决|车主说:师傅,谢谢了|技师说:不用客气


预处理后的第一条句子: 你好。以前也出现过该故障吗。。缸压多少有没有测量一下。。测量一下缸压看一四缸缸压是否偏低。点火线圈火花塞喷油嘴不用干活直接和二三缸对倒一下跑一段在测量一下故障码进行排除。调一下喷油嘴测一下缸压都正常则为发动机电脑板问题。这个影响不大的。缸压八个以上正常。所以说让你测量缸压只要缸压正常则没有问题。可以点击头像关注我有什么问题随时询问一定真诚用心为你解决。不用客气。
结论: 经过上述几轮的迭代处理, 我们发现处理后的文本已经"干净"了很多. 剩下的文本都是对我们后续模型统计有正向价值的文本.

TextRank模型代码实现¶
我们在第一章中已经为大家演示了如何调用textrank4zh来直接提取关键词, 关键短语, 关键语句. 这里我们约定通过提取关键语句, 作为文本摘要的语义替代品.

代码文件路径: /home/ec2-user/text_summary/textrank/model.py

初始化结果存放的列表

results = []

初始化textrank4zh类对象

tr4s = TextRank4Sentence()

循环遍历整个测试集, texts是经历前面数据预处理后的结果列表

for i in range(len(texts)):
text = texts[i]
# 直接调用分析函数
tr4s.analyze(text=text, lower=True, source='all_filters')
result = ''

# 直接调用函数获取关键语句
# num=3: 获取重要性最高的3个句子.
# sentence_min_len=2: 句子的长度最小等于2.
for item in tr4s.get_key_sentences(num=3, sentence_min_len=2):result += item.sentenceresult += '。'results.append(result)# 间隔100次打印结果
if (i + 1) % 100 == 0:print(i + 1, result)

print('result length: ', len(results))
调用:

上述代码在model.py文件中, 切换到对应目录下直接执行程序即可.

cd /home/ec2-user/text_summary/textrank/
python model.py
输出结果:

Building prefix dict from the default dictionary ...
Loading model from cache /tmp/jieba.cache
Loading model cost 0.737 seconds.
Prefix dict has been built successfully.
100 可以先开地毯看一下有没有生锈腐蚀的情况,没有的话就最好。你好,可以做一下打磨,喷漆处理,或者是,点漆处理。这种情况谁也不知道,会不会生锈,只有打开,才可以看到。
200 你好,影响肯定是有的,但不会有太大影响,P档是利用一个钩型的结构勾住齿轮的,这种情况肯定会对该机构造成损伤,至于损伤程度有多大,只能是拆解检查。不是更换油液。属于机械结构,对变速器危害不大。
300 需要更换机油,机滤,空滤,空调滤芯,汽油滤芯,火花塞,防冻液,变速箱油还需要清洗节气门。没有老化裂痕,可以不用。喷油嘴,三元催化,等项目。
400 这个情况的一般原因是左前门喇叭线路断路或者喇叭损坏造成的。修复相关线路,更换损坏的喇叭。需要拆开车门内衬板来检查。
500 这个不能,我也是偷着从4S店拿出来,时间长了会出事儿。定州市,不用正时容易对不准,这不麻烦。实在不行只能去4S店借了,我这边能借到,不过也得拖朋友。
600 如果室外温度低,发动机温度也低,冷车提速会慢一些,同时建议你清洗节气门,喷油嘴。渗漏的废气。清洗干净即可。
700 喷漆的话,市场价一个面是300到400左右。在外面修理厂找个专业做钣金喷漆的,让她给你修复一下,完全没问题的。只是保险杠修复和喷漆时这么多,你后备箱怎么了也需要做喷漆吗。
800 车子公里多少,有缺机油,或是漏机油,以及出现过烧机油问题没有。
900 你好很高兴为您回答,这两款车肯定选择高尔夫,因为大众高尔夫整车性能都杠杠的。

......
......
......

12400 你好,这个不影响驾驶的,经常检查一下就可以了,渗漏严重的话更换密封垫就可以了。祝您用车愉快。
12500 如果,都没事,去修理厂排查一下。一个一个拔保险,看电流表数值变小时,说明就是这个保险后面的线路跑电。几天不开就没电了。
12600 你好,正常的,冷车启动,机油流动性不好,在油底壳里面。机油没有润滑,气门的声音,机油润滑了就消失了,是正常的,很多车。冷车都会有这个声音。
12700 这是变速箱的问题,一般在4S店维修三次还不好就可以申请更换变速箱总成部件。第三次维修不好才能申请更换总成。可以的。
12800 低转速共振大,检查发动机机脚胶垫。看看节气门的信号,和正常车辆的信号做对比。检查发动机引擎脚。
12900 还是配件问题。你好,这个是后尾盖弹簧质量的问题。这个不好说,反正后来的配件弄不好都不怎么合适。
result length: 12943
将结果保存到文件中:

代码文件路径: /home/ec2-user/text_summary/textrank/model.py

保存结果

df['Prediction'] = results

提取ID, Report, 和预测结果这3列

df = df[['QID', 'Report', 'Prediction']]

保存结果,这里自动生成一个结果名

df.to_csv('textrank_result_.csv', index=None, sep=',')

将空行置换为随时联系, 文件保存格式指定为utf-8

df = pd.read_csv('textrank_result_.csv', engine='python', encoding='utf-8')
df = df.fillna('随时联系。')

将处理后的文件保存起来

df.to_csv('textrank_result_final_.csv', index=None, sep=',')
调用:

上述代码在model.py文件中, 切换到对应目录下直接执行程序即可.

cd /home/ec2-user/text_summary/textrank/
python model.py
输出结果, 直接查看一下textrank_result_final_.csv文件即可:

QID,Report,Prediction
Q70001,检修喷油嘴 缸压 及电脑板是否正常 ,点火线圈火花塞喷油嘴不用干活直接和二三缸对倒一下跑一段在测量一下故障
码进行排除。所以说让你测量缸压只要缸压正常则没有问题。缸压多少有没有测量一下。
Q70002,清洗发动机造成的相关线路进水,防盗系统连电造成的,这种情况只能等水彻底干了,以后才行。,你好,相关的线路
进水导致的防盗系统线路故障,这种故障只能等相关的线路水分晾干或者是去修理厂排查线路。别的方面影响不大。干了就没
有任何问题。
Q70003,13万左右 不同地区价格有些差异 仅供参考,两驱还是四驱。两驱12万左右四驱14万左右。个人认为两驱的动力就可
以了满足需要。
Q70004,你好,图中显示的位置两个车门,如果是在四s店钣金喷漆的话,价格在1500左右,建议你走保险。,你好,图中显示>两车门,底边都需要钣金喷漆修复,在在4s维修,车门加底边钣金喷漆处理,价格1500左右,外面修理厂800左右就差不多。>走保险的话可以去4s维修。建议走保险。
Q70005,建议在倒车灯上面找线 比较好找,在倒车灯上面找。我是真心的希望帮助到你的师傅希望你。可以从档位开关上面找
挂倒挡用表或试灯测一下。
Q70006,你好,倒车灯更换,需要把后车门的装饰板拆卸,最好去修理厂找专业的维修人员帮你更换。,你好,宝骏730的倒车>灯更换,需要把后备箱的,后门装饰板拆下来才能更换。最好是用专用工具,建议你还是去修理厂更换。教程我没有详细,你
可以去百度查一下。
Q70007,您好,这种情况应该是发电机发电量不稳定或者是电瓶老化亏电造成的,具体还需要去修理厂检查一下才能确定,电瓶
是否老化亏电,确定都没问题,需要检查仪表盘搭铁线路是否虚接。如果电瓶亏电的话,可以用充电机充电试试,不行的话只
能更换。你好,这种情况首先检查一下发电机发电量是否正常。
Q70008,6——8万公里需要大保养 更换变速箱油 刹车油 转向助力油 汽油滤芯 火花塞 ,清洗节气门 进气道 喷油嘴 三元
催化 燃烧室积碳,68万公里需要检修大保养更换变速箱油汽油滤芯火花塞防冻液,清洗节气门进气道三元催化喷油嘴燃烧室积
碳。不建议你自己购买若购买的话则工时费会很高不合算。不用客气。
小节总结:¶
数据集的解析: - 明确采用的数据集是哪个.

明确数据集的大小, 特点.

数据预处理: - 提取特定文本: 我们在此只采用了技师说的话, 也可以尝试其他文本话语.

删除'脏'数据: 什么是'脏'数据见仁见智, 普遍来讲无效的数字标题, 一些语气助词都是这一类.

删除特定的字符集合: 这也是一个见仁见智的问题, 除了删除一些'主观'特定字符, 还做了全角转半角, 标点符号全部换成句号.

删除特定位置的特定字符: 这个也是程序员处理的自由, 凭经验, 业务要求等即可.

模型实现: - 在工程实践中, 普遍直接采用textrank4zh工具包来辅助实现.

将处理后的结果保存, 以便未来使用.

3.1 Seq2Seq模型实现
seq2seq架构实现文本摘要¶
学习目标¶
掌握seq2seq实现文本摘要的架构.

掌握seq2seq的代码实现文本摘要baseline-1模型.

seq2seq实现文本摘要的架构¶
首选回顾一下在英译法任务中的经典seq2seq架构图:
avatar

编码器端负责将输入数据进行编码, 得到中间语义张量.

解码器端负责一次次的循环解析中间语义张量, 得到最终的结果语句.

一般来说, 我们将注意力机制添加在解码器端.

对比于英译法任务, 我们再来看文本摘要任务下的seq2seq架构图:
avatar

编码器端负责进行原始文本的编码.

注意力层结合编码张量和解码器端的当前输入, 得到总体上的内容张量.

最后在注意力机制的指导下, 解码器端得到完整的单词分布, 解码出当前时间步的单词.

seq2seq实现baseline-1模型¶
为了完整的搭建baselins-1模型, 接下来要完成三大块的工作: - 若干工具函数的实现.

模型类的实现.

训练和测试函数的实现.

若干工具函数的实现¶
在这一部分中我们要实现如下几个工具函数: - 第一步: 实现配置函数config.py

第二步: 实现多核并行处理的函数multi_proc_utils.py

第三步: 实现参数配置函数params_utils.py

第四步: 实现保存字典的函数word2vec_utils.py

第五步: 实现数据加载的函数data_loader.py

第一步: 实现配置函数config.py - 代码文件路径: /home/ec2-user/text_summary/seq2seq/utils/config.py

导入os工具包

import os

设置项目代码库的root路径, 为后续所有的包导入提供便利

root_path = os.path.dirname(os.path.dirname(os.path.abspath(file)))
print(root_path)

设置原始数据文件的路径, 通过以项目root路径为基础, 逐级添加到文件路径

train_raw_data_path = os.path.join(root_path, 'data', 'train.csv')
test_raw_data_path = os.path.join(root_path, 'data', 'test.csv')

停用词路径和jieba分词用户自定义字典路径

stop_words_path = os.path.join(root_path, 'data', 'stopwords.txt')
user_dict_path = os.path.join(root_path, 'data', 'user_dict.txt')

预处理+切分后的训练测试数据路径

train_seg_path = os.path.join(root_path, 'data', 'train_seg_data.csv')
test_seg_path = os.path.join(root_path, 'data', 'test_seg_data.csv')

将训练集和测试机数据混合后的文件路径

merged_seg_path = os.path.join(root_path, 'data', 'merged_seg_data.csv')

样本与标签分离,并经过pad处理后的数据路径

train_x_pad_path = os.path.join(root_path, 'data', 'train_X_pad_data.csv')
train_y_pad_path = os.path.join(root_path, 'data', 'train_Y_pad_data.csv')
test_x_pad_path = os.path.join(root_path, 'data', 'test_X_pad_data.csv')

numpy转换为数字后最终使用的的数据路径

train_x_path = os.path.join(root_path, 'data', 'train_X.npy')
train_y_path = os.path.join(root_path, 'data', 'train_Y.npy')
test_x_path = os.path.join(root_path, 'data', 'test_X.npy')

正向词典和反向词典路径

vocab_path = os.path.join(root_path, 'data', 'wv', 'vocab.txt')
reverse_vocab_path = os.path.join(root_path, 'data', 'wv', 'reverse_vocab.txt')

测试集结果保存路径

result_save_path = os.path.join(root_path, 'data', 'result')
调用:

切换到当前文件目录下

cd /home/ec2-user/text_summary/seq2seq/utils

执行程序

python config.py
输出结果:

/home/ec2-user/text_summary/seq2seq
第二步: 实现多核并行处理的函数multi_proc_utils.py - 代码文件路径: /home/ec2-user/text_summary/seq2seq/utils/multi_proc_utils.py

import pandas as pd
import numpy as np
from multiprocessing import cpu_count, Pool

计算当前服务器CPU的数量

cores = cpu_count()

将分块个数设置为CPU的数量

partitions = cores
print(cores)

def parallelize(df, func):
# 数据切分
data_split = np.array_split(df, partitions)
# 初始化线程池
pool = Pool(cores)
# 数据分发, 处理, 再合并
data = pd.concat(pool.map(func, data_split))
# 关闭线程池
pool.close()
# 执行完close后不会有新的进程加入到pool, join函数等待所有子进程结束
pool.join()
# 返回处理后的数据
return data
调用:

切换到当前文件目录下

cd /home/ec2-user/text_summary/seq2seq/utils

执行程序

python multi_proc_utils.py
输出结果:

当前服务器是一个8核CPU, 32GB内存的机器

8
第三步: 实现参数配置函数params_utils.py - 代码文件路径: /home/ec2-user/text_summary/seq2seq/utils/params_utils.py

import argparse

def get_params():
parser = argparse.ArgumentParser()
# 编码器和解码器的最大序列长度
parser.add_argument("--max_enc_len", default=300, help="Encoder input max sequence length", type=int)
parser.add_argument("--max_dec_len", default=50, help="Decoder input max sequence length", type=int)
# 一个训练批次的大小
parser.add_argument("--batch_size", default=64, help="Batch size", type=int)
# seq2seq训练轮数
parser.add_argument("--seq2seq_train_epochs", default=20, help="Seq2seq model training epochs", type=int)
# 词嵌入大小
parser.add_argument("--embed_size", default=500, help="Words embeddings dimension", type=int)
# 编码器、解码器以及attention的隐含层单元数
parser.add_argument("--enc_units", default=512, help="Encoder GRU cell units number", type=int)
parser.add_argument("--dec_units", default=512, help="Decoder GRU cell units number", type=int)
parser.add_argument("--attn_units", default=20, help="Used to compute the attention weights", type=int)
# 学习率
parser.add_argument("--learning_rate", default=0.001, help="Learning rate", type=float)
args = parser.parse_args()

# param是一个字典类型的变量,键为参数名,值为参数值
params = vars(args)
return params

调用:

if name == 'main':
res = get_params()
print(res)
输出结果:

{'max_enc_len': 300, 'max_dec_len': 50, 'batch_size': 64, 'seq2seq_train_epochs': 20, 'embed_size': 500, 'enc_units': 512, 'dec_units': 512, 'attn_units': 20, 'learning_rate': 0.001}
第四步: 实现保存字典的函数word2vec_utils.py - 代码文件路径: /home/ec2-user/text_summary/seq2seq/utils/word2vec_utils.py

from gensim.models.word2vec import Word2Vec

def load_embedding_matrix_from_model(wv_model_path):
# 从word2vec模型中获取词向量矩阵
# wv_model_path: word2vec模型的路径
wv_model = Word2Vec.load(wv_model_path)
# wv_model.wv.vectors包含词向量矩阵
embedding_matrix = wv_model.wv.vectors
return embedding_matrix

def get_vocab_from_model(vocab_path, reverse_vocab_path):
# 提取映射字典
# vocab_path: word_to_id的文件存储路径
# reverse_vocab_path: id_to_word的文件存储路径
word_to_id, id_to_word = {}, {}
with open(vocab_path, 'r', encoding='utf-8') as f1:
for line in f1.readlines():
w, v = line.strip('\n').split('\t')
word_to_id[w] = int(v)

with open(reverse_vocab_path, 'r', encoding='utf-8') as f2:for line in f2.readlines():v, w = line.strip('\n').split('\t')id_to_word[int(v)] = wreturn word_to_id, id_to_word

def save_vocab_as_txt(filename, word_to_id):
# 保存字典
# filename: 目标txt文件路径
# word_to_id: 要保存的字典
with open(filename, 'w', encoding='utf-8') as f:
for k, v in word_to_id.items():
f.write("{}\t{}\n".format(k, v))
第五步: 实现数据加载的函数data_loader.py

代码文件路径: /home/ec2-user/text_summary/seq2seq/utils/data_loader.py - 1: 获取最大长度的函数.

2: 完成文本语句单词到id的数字映射函数.

3: 填充特殊标识符的函数.

4: 加载停用词表的函数.

5: 清洗文本的函数.

6: 过滤停用词的函数.

7: 语句处理的函数.

8: 加载构建好的训练集和测试集的函数.

9: 完成本步骤总体逻辑的函数build_dataset()函数.

1: 获取最大长度的函数

import numpy as np
import os
import sys

root_path = os.path.dirname(os.path.dirname(os.path.abspath(file)))
sys.path.append(root_path)

def get_max_len(data):
# 获得合适的最大长度值(被build_dataset调用)
# data: 待统计的数据train_df['Question']
# 句子最大长度为空格数+1
max_lens = data.apply(lambda x: x.count(' ') + 1)
# 平均值+2倍方差的方式
return int(np.mean(max_lens) + 2 * np.std(max_lens))
输入数据data的样式:

0 方向机 重 , 助力 泵 , 方向机 都 换 新 都 换 助力 泵 , 方向机 换 方向机 ...
1 奔驰 ML500 排气 凸轮轴 调节 错误 有没有 电脑 检测 故障 代码 。 有发 一下 ...
2 2010 款 宝马X1 , 2011 年 出厂 , 2.0 排量 , 通用 6L45 变速箱...
3 3.0 V6 发动机 号 位置 , 照片 最好 ! 右侧 排气管 上方 , 缸体 上 靠近 ...
4 2012 款 奔驰 c180 , 维修保养 , 动力 , 值得 拥有 家庭 用车 , 入手 ...
2: 完成文本语句单词到id的数字映射函数

def transform_data(sentence, word_to_id):
# 句子转换为index序列(被build_dataset调用)
# sentence: 'word1 word2 word3 ...' -> [index1, index2, index3 ...]
# word_to_id: 映射字典

# 字符串切分成词
words = sentence.split(' ')# 按照word_to_id的id进行转换, 到未知词就填充unk的索引
ids = [word_to_id[w] if w in word_to_id else word_to_id['<UNK>'] for w in words]# 返回映射后的文本id值列表
return ids

3: 填充特殊标识符的函数

def pad_proc(sentence, max_len, word_to_id):
# 根据max_len和vocab填充

# 0. 按空格统计切分出词
words = sentence.strip().split(' ')# 1. 截取规定长度的词数
words = words[:max_len]# 2. 填充<UNK>
sentence = [w if w in word_to_id else '<UNK>' for w in words]# 3. 填充<START> <END>
sentence = ['<START>'] + sentence + ['<STOP>']# 4. 判断长度,填充<PAD>
sentence = sentence + ['<PAD>'] * (max_len - len(words))# 以空格连接列表, 返回结果字符串
return ' '.join(sentence)

4: 加载停用词表的函数

def load_stop_words(stop_word_path):
# 加载停用词(程序调用)
# stop_word_path: 停用词路径

# 打开停用词文件
f = open(stop_word_path, 'r', encoding='utf-8')
# 读取所有行
stop_words = f.readlines()# 去除每一个停用词前后 空格 换行符
stop_words = [stop_word.strip() for stop_word in stop_words]
return stop_words

调用:

import os
import sys

root_path = os.path.dirname(os.path.dirname(os.path.abspath(file)))
sys.path.append(root_path)

配置模块

from utils.config import *

加载停用词, 这里面的stop_words_path是早已在config.py文件中配置好的

stop_words = load_stop_words(stop_words_path)
print('stop_words: ', stop_words[:20])
输出结果:

stop_words: [':', ':', '———', '》),', ')÷(1-', '”,', ')、', '=(', '→', '℃', '&', '*', '一一', '~~~~', '『', '.一', './', '--', '』', '=″']
5: 清洗文本的函数

def clean_sentence(sentence):
# 特殊符号去除(被sentence_proc调用)
# sentence: 待处理的字符串
if isinstance(sentence, str):
# 删除1. 2. 3. 这些标题
r = re.compile("\D(\d.)\D")
sentence = r.sub("", sentence)

    # 删除带括号的 进口 海外r = re.compile(r"[((]进口[))]|\(海外\)")sentence = r.sub("", sentence)# 删除除了汉字数字字母和,!?。.- 以外的字符r = re.compile("[^,!?。\.\-\u4e00-\u9fa5_a-zA-Z0-9]")# 用中文输入法下的,!?来替换英文输入法下的,!?sentence = sentence.replace(",", ",")sentence = sentence.replace("!", "!")sentence = sentence.replace("?", "?")sentence = r.sub("", sentence)# 删除 车主说 技师说 语音 图片 你好 您好r = re.compile(r"车主说|技师说|语音|图片|你好|您好")sentence = r.sub("", sentence)return sentence
else:return ''

调用:

sentence = '技师说:你好!以前也出现过该故障吗?|技师说:缸压多少有没有测量一下?|车主说:没有过|车主说:没测缸
压|技师说:测量一下缸压 看一四缸缸压是否偏低|车主说:用电脑测,只是14缸缺火|车主说:[语音]|车主说:[语音]|技师
说:点火线圈 火花塞 喷油嘴不用干活 直接和二三缸对倒一下 跑一段在测量一下故障码进行排除|车主说:[语音]|车主>说:[语音]|车主说:[语音]|车主说:[语音]|车主说:师傅还在吗|技师说:调一下喷油嘴 测一下缸压 都正常则为发动机
电脑板问题|车主说:[语音]|车主说:[语音]|车主说:[语音]|技师说:这个影响不大的|技师说:缸压八个以上正常|车主说
:[语音]|技师说:所以说让你测量缸压 只要缸压正常则没有问题|车主说:[语音]|车主说:[语音]|技师说:可以点击头像
关注我 有什么问题随时询问 一定真诚用心为你解决|车主说:师傅,谢谢了|技师说:不用客气'

res = clean_sentence(sentence)
print('res=', res)
输出结果:

res= !以前也出现过该故障吗?缸压多少有没有测量一下?没有过没测缸压测量一下缸压看一四缸缸压是否偏低用电脑测,只是14缸缺火点火线圈火花塞喷油嘴不用干活直接和二三缸对倒一下跑一段在测量一下故障码进行排除师傅还在吗调一下喷油嘴测一下缸压都正常则为发动机电脑板问题这个影响不大的缸压八个以上正常所以说让你测量缸压只要缸压正常则没有问题可以点击头像关注我有什么问题随时询问一定真诚用心为你解决师傅,谢谢了不用客气
6: 过滤停用词的函数

def filter_stopwords(seg_list):
# 过滤一句切好词的话中的停用词(被sentence_proc调用)
# seg_list: 切好词的列表 [word1 ,word2 .......]
# 首先去掉多余空字符
words = [word for word in seg_list if word]
# 去掉停用词
return [word for word in words if word not in stop_words]
调用:

sentence = '技师说:你好!以前也出现过该故障吗?|技师说:缸压多少有没有测量一下?|车主说:没有过|车主说:没测缸
压|技师说:测量一下缸压 看一四缸缸压是否偏低|车主说:用电脑测,只是14缸缺火|车主说:[语音]|车主说:[语音]|技师
说:点火线圈 火花塞 喷油嘴不用干活 直接和二三缸对倒一下 跑一段在测量一下故障码进行排除|车主说:[语音]|车主>说:[语音]|车主说:[语音]|车主说:[语音]|车主说:师傅还在吗|技师说:调一下喷油嘴 测一下缸压 都正常则为发动机
电脑板问题|车主说:[语音]|车主说:[语音]|车主说:[语音]|技师说:这个影响不大的|技师说:缸压八个以上正常|车主说
:[语音]|技师说:所以说让你测量缸压 只要缸压正常则没有问题|车主说:[语音]|车主说:[语音]|技师说:可以点击头像
关注我 有什么问题随时询问 一定真诚用心为你解决|车主说:师傅,谢谢了|技师说:不用客气'

第一步: 先将原始文本执行清洗操作

res = clean_sentence(sentence)

第二步: 对清洗结果进行分词, 默认是精确模式, 当设置cut_all=True时, 采用全模式

words = jieba.cut(res)

第三步: 将分词的结果传入过滤停用词函数中, 并打印结果

result = filter_stopwords(words)
print(result)
输出结果:

['!', '以前', '出现', '过该', '故障', '?', '缸', '压', '有没有', '测量', '一下', '?', '没有', '没测', '缸', '压', '测量', '一下', '缸', '压', '看', '一四缸', '缸', '压', '是否', '偏低', '电脑', '测', ',', '14', '缸', '缺火', '点火', '线圈', '火花塞', '喷油嘴', '不用', '干活', '直接', '二三', '缸', '倒', '一下', '跑', '一段', '测量', '一下', '故障', '码', '进行', '排除', '师傅', '还', '调', '一下', '喷油嘴', '测', '一下', '缸', '压', '都', '正常', '发动机', '电脑板', '问题', '影响', '不大', '缸', '压', '八个', '以上', '正常', '说', '测量', '缸', '压', '缸', '压', '正常', '没有', '问题', '点击', '头像', '关注', '问题', '随时', '询问', '一定', '真诚', '用心', '解决', '师傅', ',', '谢谢', '不用', '客气']
7: 语句处理的函数(1)

def sentence_proc(sentence):
# 预处理模块(处理一条句子, 被sentences_proc调用)
# sentence: 待处理字符串

# 第一步: 执行清洗原始文本的操作
sentence = clean_sentence(sentence)# 第二步: 执行分词操作, 默认精确模式, 全模式cut参数cut_all=True
words = jieba.cut(sentence)# 第三步: 将分词结果输入过滤停用词函数中
words = filter_stopwords(words)# 返回字符串结果, 按空格分隔, 将过滤停用词后的列表拼接
return ' '.join(words)

调用:

sentence = '技师说:你好!以前也出现过该故障吗?|技师说:缸压多少有没有测量一下?|车主说:没有过|车主说:没测缸
压|技师说:测量一下缸压 看一四缸缸压是否偏低|车主说:用电脑测,只是14缸缺火|车主说:[语音]|车主说:[语音]|技师
说:点火线圈 火花塞 喷油嘴不用干活 直接和二三缸对倒一下 跑一段在测量一下故障码进行排除|车主说:[语音]|车主>说:[语音]|车主说:[语音]|车主说:[语音]|车主说:师傅还在吗|技师说:调一下喷油嘴 测一下缸压 都正常则为发动机
电脑板问题|车主说:[语音]|车主说:[语音]|车主说:[语音]|技师说:这个影响不大的|技师说:缸压八个以上正常|车主说
:[语音]|技师说:所以说让你测量缸压 只要缸压正常则没有问题|车主说:[语音]|车主说:[语音]|技师说:可以点击头像
关注我 有什么问题随时询问 一定真诚用心为你解决|车主说:师傅,谢谢了|技师说:不用客气'

res = sentence_proc(sentence)
print('res=', res)
输出结果:

res= ! 以前 出现 过该 故障 ? 缸 压 有没有 测量 一下 ? 没有 没测 缸 压 测量 一下 缸 压 看 一四缸 缸 压 是否 偏低 电脑 测 , 14 缸 缺火 点火 线圈 火花塞 喷油嘴 不用 干活 直接 二三 缸 倒 一下 跑 一段 测量 一下 故障 码 进行 排除 师傅 还 调 一下 喷油嘴 测 一下 缸 压 都 正常 发动机 电脑板 问题 影响 不大 缸 压 八个 以上 正常 说 测量 缸 压 缸 压 正常 没有 问题 点击 头像 关注 问题 随时 询问 一定 真诚 用心 解决 师傅 , 谢谢 不用 客气
7: 语句处理的函数(2)

def sentences_proc(df):
# 预处理模块(处理一个句子列表, 对每个句子调用sentence_proc操作)
# df: 数据集

# 批量预处理训练集和测试集
for col_name in ['Brand', 'Model', 'Question', 'Dialogue']:df[col_name] = df[col_name].apply(sentence_proc)# 训练集Report预处理
if 'Report' in df.columns:df['Report'] = df['Report'].apply(sentence_proc)# 以Pandas的DataFrame格式返回
return df

8: 加载构建好的训练集和测试集的函数

import numpy as np

加载处理好的训练样本和训练标签.npy文件(执行完build_dataset后才能使用)

def load_train_dataset(max_enc_len=300, max_dec_len=50):
# max_enc_len: 最长样本长度, 后面的截断
# max_dec_len: 最长标签长度, 后面的截断
train_X = np.load(train_x_path)
train_Y = np.load(train_y_path)

train_X = train_X[:, :max_enc_len]
train_Y = train_Y[:, :max_dec_len]return train_X, train_Y

加载处理好的测试样本.npy文件(执行完build_dataset后才能使用)

def load_test_dataset(max_enc_len=300):
# max_enc_len: 最长样本长度, 后面的截断
test_X = np.load(test_x_path)
test_X = test_X[:, :max_enc_len]
return test_X
9: 完成本步骤总体逻辑的函数build_dataset()函数

数据预处理总函数, 用于数据加载 + 预处理 (注意: 只需执行一次)

def build_dataset(train_raw_data_path, test_raw_data_path):
# 1. 加载原始数据
print('1. 加载原始数据')
print(train_raw_data_path)
# 必须设定数据格式为utf-8
train_df = pd.read_csv(train_raw_data_path, engine='python', encoding='utf-8')
test_df = pd.read_csv(test_raw_data_path, engine='python', encoding='utf-8')

# 82943, 20000
print('原始训练集行数 {}, 测试集行数 {}'.format(len(train_df), len(test_df)))
print('\n')# 2. 空值去除(对于一行数据, 任意列只要有空值就去掉该行)
print('2. 空值去除(对于一行数据,任意列只要有空值就去掉该行)')
train_df.dropna(subset=['Question', 'Dialogue', 'Report'], how='any', inplace=True)
test_df.dropna(subset=['Question', 'Dialogue'], how='any', inplace=True)
print('空值去除后训练集行数 {}, 测试集行数 {}'.format(len(train_df), len(test_df)))
print('\n')# 3. 多线程, 批量数据预处理(对每个句子执行sentence_proc, 清除无用词, 分词, 过滤停用词, 再用空格拼接为一个字符串)
print('3. 多线程, 批量数据预处理(对每个句子执行sentence_proc, 清除无用词, 分词, 过滤停用词, 再用空格拼接为一个字符串)')
train_df = parallelize(train_df, sentences_proc)
test_df = parallelize(test_df, sentences_proc)
print('\n')
print('sentences_proc has done!')# 4. 合并训练测试集, 用于构造映射字典word_to_id
print('4. 合并训练测试集, 用于构造映射字典word_to_id')
# 新建一列, 按行堆积
train_df['merged'] = train_df[['Question', 'Dialogue', 'Report']].apply(lambda x: ' '.join(x), axis=1)
# 新建一列, 按行堆积
test_df['merged'] = test_df[['Question', 'Dialogue']].apply(lambda x: ' '.join(x), axis=1)
# merged列是训练集三列和测试集两列按行连接在一起再按列堆积, 用于构造映射字典
# 按列堆积, 用于构造映射字典
merged_df = pd.concat([train_df[['merged']], test_df[['merged']]], axis=0)
print('训练集行数{}, 测试集行数{}, 合并数据集行数{}'.format(len(train_df), len(test_df), len(merged_df)))
print('\n')# 5. 保存分割处理好的train_seg_data.csv, test_set_data.csv
print('5. 保存分割处理好的train_seg_data.csv, test_set_data.csv')
# 把建立的列merged去掉, 该列对于神经网络无用
train_df = train_df.drop(['merged'], axis=1)
test_df = test_df.drop(['merged'], axis=1)
# 将处理后的数据存入持久化文件
train_df.to_csv(train_seg_path, index=None, header=True)
test_df.to_csv(test_seg_path, index=None, header=True)
print('The csv_file has saved!')
print('\n')# 6. 保存合并数据merged_seg_data.csv, 用于构造映射字典word_to_id
print('6. 保存合并数据merged_seg_data.csv, 用于构造映射字典word_to_id')
merged_df.to_csv(merged_seg_path, index=None, header=False)
print('The word_to_vector file has saved!')
print('\n')# 7. 构建word_to_id字典和id_to_word字典, 根据第6步存储的合并文件数据来完成.
word_to_id = {}
count = 0# 对训练集数据X进行处理
with open(merged_seg_path, 'r', encoding='utf-8') as f1:for line in f1.readlines():line = line.strip().split(' ')for w in line:if w not in word_to_id:word_to_id[w] = 1count += 1else:word_to_id[w] += 1print('总体单词总数count=', count)
print('\n')res_dict = {}
number = 0
for w, i in word_to_id.items():if i >= 5:res_dict[w] = inumber += 1print('进入到字典中的单词总数number=', number)
print('合并数据集的字典构造完毕, word_to_id容量: ', len(res_dict))
print('\n')word_to_id = {}
count = 0
for w, i in res_dict.items():if w not in word_to_id:word_to_id[w] = countcount += 1print('最终构造完毕字典, word_to_id容量=', len(word_to_id))
print('count=', count)# 8. 将Question和Dialogue用空格连接作为模型输入形成train_df['X']
print("8. 将Question和Dialogue用空格连接作为模型输入形成train_df['X']")
train_df['X'] = train_df[['Question', 'Dialogue']].apply(lambda x: ' '.join(x), axis=1)
test_df['X'] = test_df[['Question', 'Dialogue']].apply(lambda x: ' '.join(x), axis=1)
print('\n')# 9. 填充<START>, <STOP>, <UNK>和<PAD>, 使数据变为等长
print('9. 填充<START>, <STOP>, <UNK> 和 <PAD>, 使数据变为等长')# 获取适当的最大长度
train_x_max_len = get_max_len(train_df['X'])
test_x_max_len = get_max_len(test_df['X'])
train_y_max_len = get_max_len(train_df['Report'])print('填充前训练集样本的最大长度为: ', train_x_max_len)
print('填充前测试集样本的最大长度为: ', test_x_max_len)
print('填充前训练集标签的最大长度为: ', train_y_max_len)# 选训练集和测试集中较大的值
x_max_len = max(train_x_max_len, test_x_max_len)# 训练集X填充处理
# train_df['X'] = train_df['X'].apply(lambda x: pad_proc(x, x_max_len, vocab))
print('训练集X填充PAD, START, STOP, UNK处理中...')
train_df['X'] = train_df['X'].apply(lambda x: pad_proc(x, x_max_len, word_to_id))
# 测试集X填充处理
print('测试集X填充PAD, START, STOP, UNK处理中...')
test_df['X'] = test_df['X'].apply(lambda x: pad_proc(x, x_max_len, word_to_id))
# 训练集Y填充处理
print('训练集Y填充PAD, START, STOP, UNK处理中...')
train_df['Y'] = train_df['Report'].apply(lambda x: pad_proc(x, train_y_max_len, word_to_id))
print('\n')# 10. 保存填充<START>, <STOP>, <UNK>和<PAD>后的X和Y
print('10. 保存填充<START>, <STOP>, <UNK> 和 <PAD>后的X和Y')
train_df['X'].to_csv(train_x_pad_path, index=None, header=False)
train_df['Y'].to_csv(train_y_pad_path, index=None, header=False)
test_df['X'].to_csv(test_x_pad_path, index=None, header=False)
print('填充后的三个文件保存完毕!')
print('\n')# 11. 重新构建word_to_id字典和id_to_word字典, 根据第10步存储的3个文件数据来完成.
word_to_id = {}
count = 0# 对训练集数据X进行处理
with open(train_x_pad_path, 'r', encoding='utf-8') as f1:for line in f1.readlines():line = line.strip().split(' ')for w in line:if w not in word_to_id:word_to_id[w] = countcount += 1print('训练集X字典构造完毕, word_to_id容量: ', len(word_to_id))# 对训练集数据Y进行处理
with open(train_y_pad_path, 'r', encoding='utf-8') as f2:for line in f2.readlines():line = line.strip().split(' ')for w in line:if w not in word_to_id:word_to_id[w] = countcount += 1print('训练集Y字典构造完毕, word_to_id容量: ', len(word_to_id))# 对测试集数据X进行处理
with open(test_x_pad_path, 'r', encoding='utf-8') as f3:for line in f3.readlines():line = line.strip().split(' ')for w in line:if w not in word_to_id:word_to_id[w] = countcount += 1print('测试集X字典构造完毕, word_to_id容量: ', len(word_to_id))
print('单词总数量count= ', count)# 构造逆向字典id_to_word
id_to_word = {}
for w, i in word_to_id.items():id_to_word[i] = wprint('逆向字典构造完毕, id_to_word容量: ', len(id_to_word))
print('\n')# 12. 更新vocab并保存
print('12. 更新vocab并保存')
save_vocab_as_txt(vocab_path, word_to_id)
save_vocab_as_txt(reverse_vocab_path, id_to_word)
print('字典映射器word_to_id, id_to_word保存完毕!')
print('\n')# 13. 数据集转换 将词转换成索引[<START> 方向机 重 ...] -> [32800, 403, 986, 246, 231]
print('13. 数据集转换 将词转换成索引[<START> 方向机 重 ...] -> [32800, 403, 986, 246, 231]')
print('训练集X执行transform_data中......')
train_ids_x = train_df['X'].apply(lambda x: transform_data(x, word_to_id))
print('训练集Y执行transform_data中......')
train_ids_y = train_df['Y'].apply(lambda x: transform_data(x, word_to_id))
print('测试集X执行transform_data中......')
test_ids_x = test_df['X'].apply(lambda x: transform_data(x, word_to_id))
print('\n')# 14. 数据转换成numpy数组(需等长)
# 将索引列表转换成矩阵 [32800, 403, 986, 246, 231] --> array([[32800, 403, 986, 246, 231], ...])
print('14. 数据转换成numpy数组(需等长)')
train_X = np.array(train_ids_x.tolist())
train_Y = np.array(train_ids_y.tolist())
test_X = np.array(test_ids_x.tolist())
print('转换为numpy数组的形状如下: \ntrain_X的shape为: ', train_X.shape, '\ntrain_Y的shape为: ', train_Y.shape, '\ntest_X的shape为: ', test_X.shape)
print('\n')# 15. 保存数据
print('15. 保存数据......')
np.save(train_x_path, train_X)
np.save(train_y_path, train_Y)
np.save(test_x_path, test_X)
print('\n')
print('数据集构造完毕, 存储于seq2seq/data/目录下.')

调用:

导入若干工具包

import re
import jieba
import pandas as pd
import numpy as np
import os
import sys

设定项目的root路径, 方便后续各个模块代码的导入

root_path = os.path.dirname(os.path.dirname(os.path.abspath(file)))
sys.path.append(root_path)

并行处理模块

from utils.multi_proc_utils import parallelize

配置模块

from utils.config import *

参数模块

from utils.params_utils import get_params

保存字典为txt

from utils.word2vec_utils import save_vocab_as_txt

载入词向量参数

params = get_params()

jieba载入自定义切词表

jieba.load_userdict(user_dict_path)

----------------------------------------------------

中间部分就是将前面1-8步的所有函数依然罗列在这里即可.

----------------------------------------------------

if name == 'main':
build_dataset(train_raw_data_path, test_raw_data_path)
输出结果:

Building prefix dict from the default dictionary ...
Loading model from cache /tmp/jieba.cache
Loading model cost 0.753 seconds.
Prefix dict has been built successfully.

  1. 加载原始数据
    /home/ec2-user/text_summary/seq2seq/data/train.csv
    原始训练集行数 82943, 测试集行数 20000

  2. 空值去除(对于一行数据,任意列只要有空值就去掉该行)
    空值去除后训练集行数 82871, 测试集行数 20000

  3. 多线程, 批量数据预处理(对每个句子执行sentence_proc,清除无用词,切词,过滤停用词,再用空格拼接为一个字符串)

sentences_proc has done!
4. 合并训练测试集,用于训练词向量
训练集行数82871, 测试集行数20000, 合并数据集行数102871

  1. 保存分割处理好的train_seg_data.csv、test_set_data.csv
    The csv_file has saved!

  2. 保存合并数据merged_seg_data.csv,用于训练词向量
    The word_to_vector file has saved!

总体单词总数count= 124520

进入到字典中的单词总数number= 32227
合并数据集的字典构造完毕, word_to_id容量: 32227

最终构造完毕字典, word_to_id容量= 32227
count= 32227
8. 将Question和Dialogue用空格连接作为模型输入形成train_df['X']

  1. 填充, , ,使数据变为等长
    填充前训练集样本的最大长度为: 298
    填充前测试集样本的最大长度为: 312
    填充前训练集标签的最大长度为: 38
    训练集X填充PAD,START,STOP,UNK处理中...
    测试集X填充PAD,START,STOP,UNK处理中...
    训练集Y填充PAD,START,STOP,UNK处理中...

  2. 保存填充, , 后的X和Y
    填充后的三个文件保存完毕!

训练集X字典构造完毕, word_to_id容量: 32101
训练集Y字典构造完毕, word_to_id容量: 32130
测试集X字典构造完毕, word_to_id容量: 32217
单词总数量count= 32217
逆向字典构造完毕, id_to_word容量: 32217

字典映射器word_to_id, id_to_word保存完毕!

  1. 数据集转换 将词转换成索引 [ 方向机 重 ...] -> [32800, 403, 986, 246, 231]
    训练集X执行transform_data中......
    训练集Y执行transform_data中......
    测试集X执行transform_data中......

  2. 数据转换成numpy数组(需等长)
    转换为numpy数组的形状如下:
    train_X的shape为: (82871, 314)
    train_Y的shape为: (82871, 40)
    test_X的shape为: (20000, 314)

  3. 保存数据

数据集构造完毕,于seq2seq/data/目录下
结论: 通过五个步骤实现了全部的工具函数, 并完成了数据预处理. 后续模型类需要数据的时候, 可以直接通过加载文件的方式读取数据, 非常方便. 对于任意工业级别的项目来说, 数据预处理都处于非常重要的地位, 代码量和耗费的时间也占了整个项目很大的比例.

模型类的实现¶
在模型类的实现过程中, 为了代码的解耦和结构清晰, 总共需要完成以下几个函数的实现: - 第一步: 实现批次数据加载的函数batcher.py

第二步: 实现模型中子层的函数layers.py

第三步: 实现模型类的函数model.py

第一步: 实现批次数据加载的函数batcher.py - 代码文件路径: /home/ec2-user/text_summary/seq2seq/src/batcher.py

导入工具包

import os
import sys
import numpy as np
import torch
import torch.nn as nn
from torch.utils.data import DataLoader, TensorDataset

设定项目的rootL路径, 方便后续相关代码文件的导入

root_path = os.path.dirname(os.path.dirname(os.path.abspath(file)))
sys.path.append(root_path)

导入项目相关的代码文件

from utils.data_loader import load_train_dataset, load_test_dataset

训练批次数据生成器函数

def train_batch_generator(batch_size, max_enc_len=300, max_dec_len=50, sample_num=None):
# batch_size: batch大小
# max_enc_len: 样本最大长度
# max_dec_len: 标签最大长度
# sample_num: 限定样本个数大小

# 直接从已经预处理好的数据文件中加载训练集数据
train_X, train_Y = load_train_dataset(max_enc_len, max_dec_len)
# 对数据进行限定长度的切分
if sample_num:train_X = train_X[:sample_num]train_Y = train_Y[:sample_num]# 将numpy类型的数据转换为Pytorch下的tensor类型, 因为TensorDataset只接收tensor类型数据
x_data = torch.from_numpy(train_X)
y_data = torch.from_numpy(train_Y)# 第一步: 先对数据进行封装
dataset = TensorDataset(x_data, y_data)# 第二步: 再对dataset进行迭代器的构建
# 如果机器没有GPU, 请采用下面的注释行代码
# dataset = DataLoader(dataset, batch_size=batch_size, shuffle=True, drop_last=True)# 如果机器有GPU, 请采用下面的代码, 可以加速训练流程
dataset = DataLoader(dataset, batch_size=batch_size, shuffle=True, drop_last=True,num_workers=4, pin_memory=True)# 计算每个epoch要循环多少次
steps_per_epoch = len(train_X) // batch_size# 将封装好的数据集和次数返回
return dataset, steps_per_epoch

测试批次数据生成器函数

def test_batch_generator(batch_size, max_enc_len=300):
# batch_size: batch大小
# max_enc_len: 样本最大长度

# 直接从已经预处理好的数据文件中加载测试集数据
test_X = load_test_dataset(max_enc_len)# 将numpy类型的数据转换为Pytorch下的tensor类型, 因为TensorDataset只接收tensor类型数据
x_data = torch.from_numpy(test_X)# 第一步: 先对数据进行封装
dataset = TensorDataset(x_data)# 第二步: 再对dataset进行迭代器的构建
dataset = DataLoader(dataset, batch_size=batch_size, shuffle=True, drop_last=True)# 计算每个epoch要循环多少次
steps_per_epoch = len(test_X) // batch_size# 将封装好的数据集和次数返回
return dataset, steps_per_epoch

调用:

if name == 'main':
dataset1, length1 = train_batch_generator(64)
dataset2, length2 = test_batch_generator(64)
print(dataset1)
print(length1)
print(dataset2)
print(length2)
输出结果:

/home/ec2-user/text_summary/seq2seq
Building prefix dict from the default dictionary ...
Loading model from cache /tmp/jieba.cache
Loading model cost 0.743 seconds.
Prefix dict has been built successfully.
<torch.utils.data.dataloader.DataLoader object at 0x7fcd0c862048>
1294
<torch.utils.data.dataloader.DataLoader object at 0x7fcd0473ca58>
312
第二步: 实现模型中子层的函数layers.py - 代码文件路径: /home/ec2-user/text_summary/seq2seq/src/layers.py

为了完成模型中子层的构建, 我们需要分3个小步骤: - 1: 实现编码器类Encoder.

2: 实现注意力类Attention.

3: 实现解码器类Decoder.

1: 实现编码器类Encoder.

导入工具包

import torch
import torch.nn as nn
import torch.nn.functional as F
import os
import sys

设定项目的root路径, 方便后续的代码文件导入

root_path = os.path.dirname(os.path.dirname(os.path.abspath(file)))
sys.path.append(root_path)

导入项目相关的代码文件

from utils.config import *
from utils.word2vec_utils import get_vocab_from_model

构建编码器类

class Encoder(nn.Module):
def init(self, vocab_size, embedding_dim, enc_units, batch_size):
super(Encoder, self).init()
self.vocab_size = vocab_size
self.embedding_dim = embedding_dim
self.enc_units = enc_units
self.batch_size = batch_size

    # 第一层: 词嵌入层self.embedding = nn.Embedding(vocab_size, embedding_dim)# 第二层: GRU层self.gru = nn.GRU(input_size=embedding_dim, hidden_size=enc_units, num_layers=1, batch_first=True)def forward(self, x, h0):# x.shape: (batch_size, sequence_length)# h0.shape: (num_layers, batch_size, enc_units)x = self.embedding(x)output, hn = self.gru(x, h0)return output, hn.transpose(1, 0)def initialize_hidden_state(self):# hidden state张量形状: (num_layers, batch_size, enc_units)return torch.zeros(1, self.batch_size, self.enc_units)

调用:

if name == 'main':
word_to_id, id_to_word = get_vocab_from_model(vocab_path, reverse_vocab_path)
vocab_size = len(word_to_id)
print('vocab_size: ', vocab_size)

# 测试用参数
EXAMPLE_INPUT_SEQUENCE_LEN = 300
BATCH_SIZE = 64
EMBEDDING_DIM = 500
GRU_UNITS = 512
ATTENTION_UNITS = 20encoder = Encoder(vocab_size, EMBEDDING_DIM, GRU_UNITS, BATCH_SIZE)input0 = torch.ones((BATCH_SIZE, EXAMPLE_INPUT_SEQUENCE_LEN), dtype=torch.long)
h0 = encoder.initialize_hidden_state()
output, hn = encoder(input0, h0)
print(output.shape)
print(hn.shape)

输出结果:

vocab_size: 32217
torch.Size([64, 300, 512])
torch.Size([64, 1, 512])
2: 实现注意力类Attention.

class Attention(nn.Module):
def init(self, enc_units, dec_units, attn_units):
super(Attention, self).init()
self.enc_units = enc_units
self.dec_units = dec_units
self.attn_units = attn_units

    # 计算注意力的三次矩阵乘法, 对应着3个全连接层.self.w1 = nn.Linear(enc_units, attn_units)self.w2 = nn.Linear(dec_units, attn_units)self.v = nn.Linear(attn_units, 1)def forward(self, query, value):# query为上次的decoder隐藏层,shape: (batch_size, dec_units)# values为编码器的编码结果enc_output,shape: (batch_size, enc_seq_len, enc_units)# 在应用self.V之前,张量的形状是(batch_size, enc_seq_len, attention_units)# 得到score的shape: (batch_size, seq_len, 1)score = self.v(torch.tanh(self.w1(value) + self.w2(query)))# 注意力权重,是score经过softmax,但是要作用在第一个轴上(seq_len的轴)attention_weights = F.softmax(score, dim=1)# (batch_size, enc_seq_len, 1) * (batch_size, enc_seq_len, enc_units)# 广播, encoder unit的每个位置都对应相乘context_vector = attention_weights * value# 在最大长度enc_seq_len这一维度上求和context_vector = torch.sum(context_vector, dim=1)# context_vector求和之后的shape: (batch_size, enc_units)return context_vector, attention_weights

调用:

if name == 'main':
word_to_id, id_to_word = get_vocab_from_model(vocab_path, reverse_vocab_path)
vocab_size = len(word_to_id)

# 测试用参数
EXAMPLE_INPUT_SEQUENCE_LEN = 300
BATCH_SIZE = 64
EMBEDDING_DIM = 500
GRU_UNITS = 512
ATTENTION_UNITS = 20encoder = Encoder(vocab_size, EMBEDDING_DIM, GRU_UNITS, BATCH_SIZE)input0 = torch.ones((BATCH_SIZE, EXAMPLE_INPUT_SEQUENCE_LEN), dtype=torch.long)
h0 = encoder.initialize_hidden_state()
output, hn = encoder(input0, h0)attention = Attention(GRU_UNITS, GRU_UNITS, ATTENTION_UNITS)
context_vector, attention_weights = attention(hn, output)
print(context_vector.shape)
print(attention_weights.shape)

输出结果:

torch.Size([64, 512])
torch.Size([64, 300, 1])
3: 实现解码器类Decoder.

class Decoder(nn.Module):
def init(self, vocab_size, embedding_dim, dec_units, batch_size):
super(Decoder, self).init()
self.vocab_size = vocab_size
self.embedding_dim = embedding_dim
self.dec_units = dec_units
self.batch_size = batch_size

    self.embedding = nn.Embedding(vocab_size, embedding_dim)self.gru = nn.GRU(input_size=embedding_dim + dec_units,hidden_size=dec_units,num_layers=1,batch_first=True)self.fc = nn.Linear(dec_units, vocab_size)def forward(self, x, context_vector):x = self.embedding(x)# x.shape after passing through embedding: (batch_size, 1, embedding_dim),1指的是一次只解码一个单词# 将上一循环的预测结果跟注意力权重值结合在一起作为本次的GRU网络输入x = torch.cat([torch.unsqueeze(context_vector, 1), x], dim=-1)output, hn = self.gru(x)output = output.squeeze(1)prediction = self.fc(output)return prediction, hn.transpose(1, 0)

调用:

if name == 'main':
word_to_id, id_to_word = get_vocab_from_model(vocab_path, reverse_vocab_path)
vocab_size = len(word_to_id)

# 测试用参数
EXAMPLE_INPUT_SEQUENCE_LEN = 300
BATCH_SIZE = 64
EMBEDDING_DIM = 500
GRU_UNITS = 512
ATTENTION_UNITS = 20encoder = Encoder(vocab_size, EMBEDDING_DIM, GRU_UNITS, BATCH_SIZE)input0 = torch.ones((BATCH_SIZE, EXAMPLE_INPUT_SEQUENCE_LEN), dtype=torch.long)
h0 = encoder.initialize_hidden_state()
output, hn = encoder(input0, h0)attention = Attention(GRU_UNITS, GRU_UNITS, ATTENTION_UNITS)
context_vector, attention_weights = attention(hn, output)decoder = Decoder(vocab_size, EMBEDDING_DIM, GRU_UNITS, BATCH_SIZE)
input1 = torch.ones((BATCH_SIZE, 1), dtype=torch.long)
output1, hn = decoder(input1, context_vector)
print(output1.shape)
print(hn.shape)

输出结果:

torch.Size([64, 32217])
torch.Size([64, 1, 512])
结论: 分别实现了编码器层, 注意力层, 解码器层, 通过这三个类可以在未来完整的构建模型.

第三步: 实现模型类的函数model.py - 代码文件路径: /home/ec2-user/text_summary/seq2seq/src/model.py

import os
import sys

设定项目的root路径, 方便后续代码文件的导入

root_path = os.path.dirname(os.path.dirname(os.path.abspath(file)))
sys.path.append(root_path)

导入工具包和项目相关的代码文件

import torch
import torch.nn as nn
from src.layers import Encoder, Attention, Decoder
from utils.config import *
from utils.word2vec_utils import get_vocab_from_model

构建完整的seq2seq模型

class Seq2Seq(nn.Module):
def init(self, params):
super(Seq2Seq, self).init()
self.params = params

    # 第一层: 编码器层self.encoder = Encoder(params['vocab_size'], params['embed_size'],params['enc_units'], params['batch_size'])# 第二层: 注意力机制层self.attention = Attention(params['enc_units'], params['dec_units'], params['attn_units'])# 第三层: 解码器层self.decoder = Decoder(params['vocab_size'], params['embed_size'],params['dec_units'], params['batch_size'])# 实质上是在调用解码器,因为需要注意力机制,直接封装到forward中. 要调用编码器直接encoder()即可
def forward(self, dec_input, dec_hidden, enc_output, dec_target):# 这里的dec_input实质是(batch_size, 1)大小的<START>predictions = []# 拿编码器的输出和最终隐含层向量来计算context_vector, attention_weights = self.attention(dec_hidden, enc_output)# 循环解码for t in range(dec_target.shape[1]):# dec_input (batch_size, 1); dec_hidden (batch_size, hidden_units)pred, dec_hidden = self.decoder(dec_input, context_vector)context_vector, attention_weights = self.attention(dec_hidden, enc_output)# 使用teacher forcing, 并扩展维度到三维张量dec_input = dec_target[:, t].unsqueeze(1)predictions.append(pred)return torch.stack(predictions, 1), dec_hidden

调用:

if name == 'main':
word_to_id, id_to_word = get_vocab_from_model(vocab_path, reverse_vocab_path)

vocab_size = len(word_to_id)
batch_size = 64
input_seq_len = 300# 模拟测试参数
params = {"vocab_size": vocab_size, "embed_size": 500, "enc_units": 512,"attn_units": 20, "dec_units": 512,"batch_size": batch_size}# 实例化类对象
model = Seq2Seq(params)# 初始化测试输入数据
sample_input_batch = torch.ones((batch_size, input_seq_len), dtype=torch.long)
sample_hidden = model.encoder.initialize_hidden_state()# 调用Encoder进行编码
sample_output, sample_hidden = model.encoder(sample_input_batch, sample_hidden)# 打印输出张量维度
print('Encoder output shape: (batch_size, enc_seq_len, enc_units) {}'.format(sample_output.shape))
print('Encoder Hidden state shape: (batch_size, enc_units) {}'.format(sample_hidden.shape))# 调用Attention进行注意力张量
context_vector, attention_weights = model.attention(sample_hidden, sample_output)print("Attention context_vector shape: (batch_size, enc_units) {}".format(context_vector.shape))
print("Attention weights shape: (batch_size, sequence_length, 1) {}".format(attention_weights.shape))# 调用Decoder进行解码
dec_input = torch.ones((batch_size, 1), dtype=torch.long)
sample_decoder_output, _, = model.decoder(dec_input, context_vector)print('Decoder output shape: (batch_size, vocab_size) {}'.format(sample_decoder_output.shape))
# 这里仅测试一步,没有用到dec_seq_len

输出结果:

Encoder output shape: (batch_size, enc_seq_len, enc_units) torch.Size([64, 300, 512])
Encoder Hidden state shape: (batch_size, enc_units) torch.Size([64, 1, 512])
Attention context_vector shape: (batch_size, enc_units) torch.Size([64, 512])
Attention weights shape: (batch_size, sequence_length, 1) torch.Size([64, 300, 1])
Decoder output shape: (batch_size, vocab_size) torch.Size([64, 32217])
训练和测试函数的实现¶
构建完成模型类后, 我们要分别实现训练函数和测试函数: - 第一步: 编写训练辅助函数train_helper.py

第二步: 编写训练主函数train.py

第三步: 编写测试辅助函数test_helper.py

第四步: 编写测试主函数test.py

第一步: 编写训练辅助函数train_helper.py - 代码文件路径: /home/ec2-user/text_summary/seq2seq/src/train_helper.py

import os
import sys
root_path = os.path.dirname(os.path.dirname(os.path.abspath(file)))
sys.path.append(root_path)

print(root_path)

import torch
import torch.nn as nn
import torch.optim as optim
from batcher import train_batch_generator
import time

def train_model(model, word_to_id, params):
# 载入参数:seq2seq模型的训练轮次以及batch大小
epochs = params['seq2seq_train_epochs']
batch_size = params['batch_size']

pad_index = word_to_id['<PAD>']
unk_index = word_to_id['<UNK>']
start_index = word_to_id['<START>']params['vocab_size'] = len(word_to_id)# 如果有GPU则将模型放在GPU上训练
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
model = model.to(device)
print('Model has put to GPU...')# 选定Adam优化器, 和交叉熵损失函数
optimizer = optim.Adam(model.parameters(), lr=params['learning_rate'])
criterion = nn.CrossEntropyLoss()# 定义损失函数
def loss_function(pred, real):# 相同为1, 不同为0. 则0的位置天然形成了掩码pad_mask = torch.eq(real, pad_index)unk_mask = torch.eq(real, unk_index)mask = torch.logical_not(torch.logical_or(pad_mask, unk_mask))pred = pred.transpose(2, 1)# 真实标签乘以掩码后, 表达的是真实参与损失计算的序列real = real * maskloss_ = criterion(pred, real)return torch.mean(loss_)def train_step(enc_input, dec_target):initial_hidden_state = model.encoder.initialize_hidden_state()initial_hidden_state = initial_hidden_state.to(device)# 老三样: 1.梯度归零optimizer.zero_grad()enc_output, enc_hidden = model.encoder(enc_input, initial_hidden_state)# 第一个decoder输入, 构造(batch_size, 1)的<START>标签作为起始dec_input = torch.tensor([start_index] * batch_size)dec_input = dec_input.unsqueeze(1)# 第一个隐藏层输入dec_hidden = enc_hidden# for循环逐个预测序列dec_input = dec_input.to(device)dec_hidden = dec_hidden.to(device)enc_output = enc_output.to(device)dec_target = dec_target.to(device)predictions, _ = model(dec_input, dec_hidden, enc_output, dec_target)# 计算损失, 两个张量形状均为(batch, dec_target的len-1)loss = loss_function(predictions, dec_target)# 老三样: 2.反向传播 + 3.梯度更新loss.backward()optimizer.step()return loss.item()# 读取数据
dataset, steps_per_epoch = train_batch_generator(batch_size)for epoch in range(epochs):start_time = time.time()total_loss = 0# 按批次数据进行训练for batch, (inputs, targets) in enumerate(dataset):inputs = inputs.to(device)targets = targets.to(device)# 将标签张量的类型转变成和输入张量一致targets = targets.type_as(inputs)batch_loss = train_step(inputs, targets)total_loss = total_loss + batch_loss# 每50个batch打印一次训练信息if (batch + 1) % 50 == 0:print('Epoch {} Batch {} Loss {:.4f}'.format(epoch + 1, batch + 1, batch_loss))if (epoch + 1) % 2 == 0:MODEL_PATH = root_path + '/src/saved_model/' + 'model_' + str(epoch) + '.pt'torch.save(model.state_dict(), MODEL_PATH)print('The model has saved for epoch {}'.format(epoch + 1))print('Epoch {} Total Loss {:.4f}'.format(epoch + 1, total_loss))print('*************************************')# 打印一个epoch所用时间print('Time taken for 1 epoch {} sec\n'.format(time.time() - start_time))

第二步: 编写训练主函数train.py - 代码文件路径: /home/ec2-user/text_summary/seq2seq/src/train.py

import os
import sys
root_path = os.path.dirname(os.path.dirname(os.path.abspath(file)))
sys.path.append(root_path)

import torch
import torch.nn as nn
import torch.optim as optim
from batcher import train_batch_generator
import time
from src.model import Seq2Seq
from src.train_helper import train_model
from utils.config import *
from utils.params_utils import get_params
from utils.word2vec_utils import get_vocab_from_model

def train(params):
# 读取word_to_id训练
word_to_id, _ = get_vocab_from_model(vocab_path, reverse_vocab_path)
# 动态添加字典大小参数
params['vocab_size'] = len(word_to_id)

# 构建模型
print("Building the model ...")
model = Seq2Seq(params)# 训练模型
print('开始训练模型')
train_model(model, word_to_id, params)

调用:

if name == 'main':
params = get_params()
train(params)
输出结果:

/home/ec2-user/text_summary/seq2seq
Building prefix dict from the default dictionary ...
Loading model from cache /tmp/jieba.cache
Loading model cost 0.742 seconds.
Prefix dict has been built successfully.
Building the model ...
开始训练模型
Model has put to GPU...
Epoch 1 Batch 50 Loss 2.5331
Epoch 1 Batch 100 Loss 2.0350
Epoch 1 Batch 150 Loss 2.2551
Epoch 1 Batch 200 Loss 2.1686
Epoch 1 Batch 250 Loss 2.2025
Epoch 1 Batch 300 Loss 2.1249
Epoch 1 Batch 350 Loss 1.9619
Epoch 1 Batch 400 Loss 1.6834
Epoch 1 Batch 450 Loss 2.2918
Epoch 1 Batch 500 Loss 2.1169
Epoch 1 Batch 550 Loss 2.1105
Epoch 1 Batch 600 Loss 2.0083
Epoch 1 Batch 650 Loss 1.8262
Epoch 1 Batch 700 Loss 1.7211
Epoch 1 Batch 750 Loss 2.0747
Epoch 1 Batch 800 Loss 1.7324
Epoch 1 Batch 850 Loss 2.1359
Epoch 1 Batch 900 Loss 1.8689
Epoch 1 Batch 950 Loss 1.8645
Epoch 1 Batch 1000 Loss 1.8017
Epoch 1 Batch 1050 Loss 1.9226
Epoch 1 Batch 1100 Loss 1.7405
Epoch 1 Batch 1150 Loss 1.5623
Epoch 1 Batch 1200 Loss 1.6556
Epoch 1 Batch 1250 Loss 1.7291
Time taken for 1 epoch 656.5679984092712 sec

......
......
......

Epoch 10 Batch 50 Loss 0.8314
Epoch 10 Batch 100 Loss 0.7645
Epoch 10 Batch 150 Loss 0.7843
Epoch 10 Batch 200 Loss 0.8006
Epoch 10 Batch 250 Loss 0.7855
Epoch 10 Batch 300 Loss 0.8918
Epoch 10 Batch 350 Loss 0.8440
Epoch 10 Batch 400 Loss 0.8019
Epoch 10 Batch 450 Loss 0.7360
Epoch 10 Batch 500 Loss 0.7858
Epoch 10 Batch 550 Loss 0.9141
Epoch 10 Batch 600 Loss 0.8353
Epoch 10 Batch 650 Loss 0.8990
Epoch 10 Batch 700 Loss 0.8440
Epoch 10 Batch 750 Loss 0.7974
Epoch 10 Batch 800 Loss 0.8453
Epoch 10 Batch 850 Loss 0.9953
Epoch 10 Batch 900 Loss 0.9022
Epoch 10 Batch 950 Loss 0.8742
Epoch 10 Batch 1000 Loss 0.7762
Epoch 10 Batch 1050 Loss 0.9823
Epoch 10 Batch 1100 Loss 0.8812
Epoch 10 Batch 1150 Loss 0.8549
Epoch 10 Batch 1200 Loss 0.9965
Epoch 10 Batch 1250 Loss 0.8885
The model has saved for epoch 10
Epoch 10 Total Loss 1079.8954


Time taken for 1 epoch 662.4355807304382 sec

......
......
......

Epoch 30 Batch 50 Loss 0.4469
Epoch 30 Batch 100 Loss 0.4077
Epoch 30 Batch 150 Loss 0.3973
Epoch 30 Batch 200 Loss 0.3445
Epoch 30 Batch 250 Loss 0.5289
Epoch 30 Batch 300 Loss 0.4306
Epoch 30 Batch 350 Loss 0.4639
Epoch 30 Batch 400 Loss 0.3316
Epoch 30 Batch 450 Loss 0.3984
Epoch 30 Batch 500 Loss 0.4391
Epoch 30 Batch 550 Loss 0.4079
Epoch 30 Batch 600 Loss 0.4483
Epoch 30 Batch 650 Loss 0.4407
Epoch 30 Batch 700 Loss 0.4505
Epoch 30 Batch 750 Loss 0.3744
Epoch 30 Batch 800 Loss 0.4040
Epoch 30 Batch 850 Loss 0.4668
Epoch 30 Batch 900 Loss 0.4470
Epoch 30 Batch 950 Loss 0.4438
Epoch 30 Batch 1000 Loss 0.4224
Epoch 30 Batch 1050 Loss 0.4048
Epoch 30 Batch 1100 Loss 0.4809
Epoch 30 Batch 1150 Loss 0.5258
Epoch 30 Batch 1200 Loss 0.4406
Epoch 30 Batch 1250 Loss 0.3827
The model has saved for epoch 30
Epoch 30 Total Loss 552.6610


Time taken for 1 epoch 661.1436426639557 sec
模型存储结果: /home/ec2-user/text_summary/seq2seq/src/saved_model/

-rw-rw-r-- 1 ec2-user ec2-user 210670299 3月 3 15:33 model_1.pt
-rw-rw-r-- 1 ec2-user ec2-user 210670299 3月 3 15:55 model_3.pt
-rw-rw-r-- 1 ec2-user ec2-user 210670299 3月 3 16:17 model_5.pt
-rw-rw-r-- 1 ec2-user ec2-user 210670299 3月 3 16:39 model_7.pt
-rw-rw-r-- 1 ec2-user ec2-user 210670299 3月 3 17:01 model_9.pt
-rw-rw-r-- 1 ec2-user ec2-user 210670299 3月 3 17:23 model_11.pt
-rw-rw-r-- 1 ec2-user ec2-user 210670299 3月 3 17:45 model_13.pt
-rw-rw-r-- 1 ec2-user ec2-user 210670299 3月 3 18:07 model_15.pt
-rw-rw-r-- 1 ec2-user ec2-user 210670299 3月 3 18:29 model_17.pt
-rw-rw-r-- 1 ec2-user ec2-user 210670299 3月 3 18:51 model_19.pt
-rw-rw-r-- 1 ec2-user ec2-user 210670299 3月 3 19:13 model_21.pt
-rw-rw-r-- 1 ec2-user ec2-user 210670299 3月 3 19:35 model_23.pt
-rw-rw-r-- 1 ec2-user ec2-user 210670299 3月 3 19:57 model_25.pt
-rw-rw-r-- 1 ec2-user ec2-user 210670299 3月 3 20:19 model_27.pt
-rw-rw-r-- 1 ec2-user ec2-user 210670299 3月 3 20:41 model_29.pt
训练模型结论: 每个epoch损失都在稳步下降, 经历30个epoch之后, 训练集上的损失已经下降到每个batch只有0.4左右, 相比于最初的2.2左右, 效果明显.

第三步: 编写测试辅助函数test_helper.py - 代码文件路径: /home/ec2-user/text_summary/seq2seq/src/test_helper.py

首先要明确一点, 测试任务的本质是什么? - 1: 模型参数不变, 衡量模型表现(准确率, 召回率, 泛化性能等).

2: 对于生成式任务, 经典模式就是"贪心解码".

import torch
import torch.nn as nn
import tqdm

def greedy_decode(model, test_x, word_to_id, id_to_word, params):
batch_size = params['batch_size']
results = []

total_test_num = len(test_x)
# batch操作轮数math.ceil向上取整+1, 因为最后一个batch可能不足一个batch size大小, 但是依然需要计算
step_epoch = total_test_num // batch_size + 1for i in range(step_epoch):batch_data = test_x[i * batch_size: (i + 1) * batch_size]results += batch_predict(model, batch_data, word_to_id, id_to_word, params)if (i + 1) % 10 == 0:print('i = ', i + 1)
return results

def batch_predict(model, inputs, word_to_id, id_to_word, params):
data_num = len(inputs)
# 开辟结果存储list
predicts = [''] * data_num

# 输入参数inputs是从文件中加载的numpy类型数据, 需要转换成tensor类型
inputs = torch.from_numpy(inputs)# 注意这里的batch_size与config中的batch_size不一定一致
# 原因是最后一个batch可能不是64, 因此应当按以下形式初始化隐藏层, 而不要直接调用类内函数
initial_hidden_state = torch.zeros(1, data_num, model.encoder.enc_units)# 第一步: 首先经过编码器的处理
enc_output, enc_hidden = model.encoder(inputs, initial_hidden_state)# 为注意力层和解码器层处理准备数据
dec_hidden = enc_hidden
dec_input = torch.tensor([word_to_id['<START>']] * data_num)
dec_input = dec_input.unsqueeze(1)# 第二步: 经过注意力层的处理, 得到语义内容分布张量context_vector
context_vector, _ = model.attention(dec_hidden, enc_output)# 第三步: 解码器的解码流程是经典的"自回归"模式, 以for循环连续解码max_dec_len次.
for t in range(params['max_dec_len']):# 计算上下文context_vector, attention_weights = model.attention(dec_hidden, enc_output)# 单步预测predictions, dec_hidden = model.decoder(dec_input, context_vector)# id转换成字符, 采用贪心解码predict_ids = torch.argmax(predictions, dim=1)# 内层for循环是为了处理batch中的每一条数据.for index, p_id in enumerate(predict_ids.numpy()):predicts[index] += id_to_word[p_id] + ' 'dec_input = predict_ids.unsqueeze(1)results = []
for pred in predicts:# 去掉句子前后空格pred = pred.strip()# 句子小于max_len就结束, 直接截断if '<STOP>' in pred:pred = pred[:pred.index('<STOP>')]results.append(pred)return results

第四步: 编写测试主函数test.py - 代码文件路径: /home/ec2-user/text_summary/seq2seq/src/test.py

import os
import sys
root_path = os.path.dirname(os.path.dirname(os.path.abspath(file)))
sys.path.append(root_path)

import torch
import torch.nn as nn
import time
import pandas as pd
from src.model import Seq2Seq
from src.test_helper import greedy_decode
from utils.data_loader import load_test_dataset
from utils.config import *
from utils.params_utils import get_params
from utils.word2vec_utils import get_vocab_from_model

def test(params):
print("创建字典")
word_to_id, id_to_word = get_vocab_from_model(word_vector_path)

# 动态添加字典大小参数
params['vocab_size'] = len(word_to_id)print("创建模型")
model = Seq2Seq(params)MODEL_PATH = root_path + '/src/saved_model/' + 'model_29.pt'
model.load_state_dict(torch.load(MODEL_PATH))
print("模型加载完毕!")# 预测结果、保存结果并返回
print("生成测试数据迭代器")
test_x = load_test_dataset()
print("开始解码......")
results = greedy_decode(model, test_x, word_to_id, id_to_word, params)# 去掉预测结果之间的空格
print("解码完毕, 开始保存结果......")
results = list(map(lambda x: x.replace(" ", ""), results))
save_predict_result(results)
return results

def save_predict_result(results):
# 读取原始文件(使用其QID列,合并新增的Prediction后再保存)
print("读取原始测试数据...")
test_df = pd.read_csv(test_raw_data_path)

# 填充结果
print("构建新的DataFrame并保存文件...")
test_df['Prediction'] = results# 提取ID和预测结果两列
test_df = test_df[['QID', 'Prediction']]# 保存结果, 这里自动生成一个结果名
test_df.to_csv(get_result_filename(), index=None, sep=',')
print("保存测试结果完毕!")

def get_result_filename():
now_time = time.strftime('%Y_%m_%d_%H_%M_%S')
filename = 'seq2seq_' + now_time + '.csv'
result_path = os.path.join(result_save_path, filename)
return result_path
调用:

if name == 'main':
params = get_params()
results = test(params)
print(results[:10])
输出结果:

Building prefix dict from the default dictionary ...
Loading model from cache /tmp/jieba.cache
Loading model cost 0.743 seconds.
Prefix dict has been built successfully.
创建字典
创建模型
模型加载完毕!
生成测试数据迭代器
开始解码......
i = 10
i = 20
i = 30
i = 40
i = 50
i = 60
i = 70
i = 80
i = 90
i = 100
i = 110
i = 120
i = 130
i = 140
i = 150
i = 160
i = 170
i = 180
i = 190
i = 200
i = 210
i = 220
i = 230
i = 240
i = 250
i = 260
i = 270
i = 280
i = 290
i = 300
i = 310
解码完毕, 开始保存结果......
读取原始测试数据...
构建新的DataFrame并保存文件...
保存测试结果完毕!
['发动机烧机油质量不好,建议发动机烧机油,需要分解发动机检查,发动机烧机油,需要分解发动机检查,发动机烧机油,需要分解发动机检查,发动机烧机油,需要分解发动机检查,发动机烧机油,需要分解',
'更换更换更换更换更换更换更换更换更换更换更换更换更换更换更换更换更换',
'更换转向柱上面,更换方向盘,质量好。',
'发动机散热风扇发动机怠速不稳,导致缸压堵塞。',
',这种情况来看,这种情况来看,这种情况来看,这种情况来看,这种情况来看,这种情况来看,这种情况来看,这种情况来看,这种情况来看,这种情况来看,这种情况来看,这种情况来看,',
'排气管排气管排气管排气管排气管排气管排气管排气管排气管排气管排气管排气管排气管排气管排气管排气管排气管',
',添加,加,加,加,加,加,加,加,加,加,加,加,加,加,加,加,加,加,加,加,加,加,加,加,',
'情况发动机本身发动机进行检查修理,检查燃油液位',
'轮胎出现裂纹,轮胎使用寿命,轮胎使用寿命,轮胎使用寿命,轮胎使用寿命,轮胎使用寿命,轮胎使用寿命,轮胎使用寿命,轮胎使用寿命,轮胎使用寿命,轮胎使用寿命,轮胎使用寿命,轮胎使用寿命,轮胎使用寿命,轮胎使用寿命,轮胎使用寿命,',
'这种情况主要发动机舱没有声音。属于正常,声音大主要受到碰撞发出声音。']
模型测试结果: /home/ec2-user/text_summary/seq2seq/data/result/

-rw-rw-r-- 1 ec2-user ec2-user 3129895 3月 4 03:41 seq2seq_2021_03_04_03_41_22.csv

Q19,检查一下变速箱油是否问题
Q20,描述,应该雨刷器不回位,受外力喇叭线路故障
Q21,变速箱油清洗,油门踏板传感器清洗,油门踏板传感器清洗,油门踏板传感器清洗,油门踏板传感器清洗,油门>踏板传感器清洗,油门踏板传感器清洗,油门踏板传感器清洗,油门踏板传感器清洗,油门踏板传感器清洗,
Q22,,描述情况分析,现在比较假机油试试,
Q23,先电脑匹配一下,
Q24,,这种情况需要电脑检测一下故障码,才能确定具体故障,具体故障码,检测一下故障码,才能确定具体故障,>具体故障码,检测一下故障码,才能确定具体故障,具体故障码,检测一下
Q25,这款车无法调节螺丝,两侧单,取下
Q26,这种情况,这种情况,这种情况,这种情况,这种情况,这种情况,这种情况,这种情况,这种情况,这种情况>,这种情况,这种情况,这种情况,这种情况,这种情况,这种情况,这种
Q27,二手车检查是否漏油性能是否少
Q28,电瓶没电,电瓶没电,电瓶没电,电瓶没电,电瓶没电,电瓶没电,电瓶没电,电瓶没电,电瓶没电,电瓶没电>,电瓶没电,电瓶没电,电瓶没电,电瓶没电,电瓶没电,电瓶没电,电瓶
Q29,600左右
Q30,汽车汽车汽车汽车汽车汽车汽车汽车汽车汽车汽车汽车汽车汽车汽车汽车汽车汽>车汽车汽车汽车汽车汽车汽车汽车汽车汽车汽车汽车汽车汽车汽车汽车汽车汽
车汽车汽车
Q31,首保,首保,首保做首保,首保
测试模型结论: 相比于第二章的textRank模型, seq2seq模型的优点是可以自由生成语句, 不受限于原始文本的表达. 但是肉眼可见的缺点是"重复子串生成", 这也是经典seq2seq模型解决文本摘要问题最大的痛点!!!

小节总结¶
实现文本摘要的seq2seq架构: - 经典的seq2seq实现文本摘要, 优点在于这是生成式模型, 可以摆脱对于原文的依赖.

学习了seq2seq架构不仅仅可以实现机器翻译任务, 诗歌生成任务, 还可以实现关键信息总结的任务.

关于baseline-1模型的实现: - 很大的代码篇幅都在进行数据预处理, 将原始数据处理成模型需要的形式, 是整个数据预处理的核心任务主线.

完成了数据清洗, 有用信息列汇总, 分词, 填充, 数字化映射等一系列处理流程, 并最终将模型要用的数据以文件格式存储, 模型训练, 预测时直接加载即可.

整个模型类采用了经典seq2seq架构中的三个子层类, Encoder, Attention, Decoder, 分贝实现了相关代码.

分开实现训练代码, 预测代码. 预测阶段采用自回归模式, 并采用贪心解码策略.

关于baseline-1模型效果: - 优点: - 可以不限于原文自由生成摘要文本.
- 摘要中也展示了原文中的关键信息.

缺点: - 最大痛点就是"重复子串生成"问题.

这种摘要"看上去"还不如baseline-0的效果更易读.

3.2 Seq2Seq优化
利用预训练词向量对baseline-1模型优化¶
学习目标¶
掌握如何预训练词向量.

掌握在拥有预训练词向量的基础上如何训练模型.

预训练词向量¶
词向量在NLP中的作用¶
大家思考这样一个问题: 为什么在CV领域从来没有听说过"图向量训练"?

这里面涉及到CV和NLP的一个非常本质的区别. - CV中的图像"天然的"是"数字化"输入数据.

NLP中的文本"天然的"是自然语言, 字符样式, 是"非数字化"输入数据.

只有通过词向量的映射, 才能将人类读懂的"自然语言"转换成计算机读懂的"数字化语言". 而在NLP的发展历史上, 总共有如下几个词向量的阶段: - one-hot词向量.

Word2Vec, Glove静态词向量.

ELMo, BERT动态词向量.

模型中的词向量总体上有两种引入方式: - 第一种: 即同学们非常熟悉的self.embedding = nn.Embedding(vocab_size, embedding_dim). - 这种方式等于将词向量的训练过程融入到整个模型的训练过程中.
- 优点: 省去了单独处理词向量的过程.
- 缺点: 训练模型的时间开销大, 算力开销大. 不同的模型结构无法复用同源数据集.

第二种: 提前预训练词向量, 模型中直接加载进来. - 这种方式就是前面用BERT的方式.

优点: 训练模型的过程中省去了词向量训练, 加速了模型的训练. 不同的模型结构可以复用同源数据集.

缺点: 需要单独训练词向量的过程.

在本项目中, 针对于baseline-1模型的优化, 我们采用Word2Vec词向量. 至于BERT的引入, 就可以作为未来baseline-1.1版本的优化了.

数据预处理¶
数据预处理的流程需要如下几个步骤: - 第一步: 编写config.py文件.

第二步: 编写multi_proc_utils.py文件.

第三步: 编写params_utils.py文件.

第四步: 编写data_loader.py文件.

第一步: 编写config.py文件. - 代码文件路径: /home/ec2-user/text_summary/seq2seq/utils/config.py

关于config.py文件, 在3.1小节的相同文件基础上添加一行即可.

增加词向量模型路径

word_vector_path = os.path.join(root_path, 'data', 'wv', 'word2vec.model')
第二步: 编写multi_proc_utils.py文件. - 代码文件路径: /home/ec2-user/text_summary/seq2seq/utils/multi_proc_utils.py

关于multi_proc_utils.py文件, 和3.1小节的代码文件完全相同, 是为了多核CPU多线程处理数据.

import pandas as pd
import numpy as np
from multiprocessing import cpu_count, Pool

计算当前服务器CPU的数量

cores = cpu_count()

将分块个数设置为CPU的数量

partitions = cores

def parallelize(df, func):
# 数据切分
data_split = np.array_split(df, partitions)
# 初始化线程池
pool = Pool(cores)
# 数据分发, 处理, 再合并
data = pd.concat(pool.map(func, data_split))
# 关闭线程池
pool.close()
# 执行完close后不会有新的进程加入到pool, join函数等待所有子进程结束
pool.join()
# 返回处理后的数据
return data
第三步: 编写params_utils.py文件. - 代码文件路径: /home/ec2-user/text_summary/seq2seq/utils/params_utils.py

关于params_utils.py文件, 在3.1小节的代码文件基础上添加一行词向量训练轮次的配置信息即可.

import argparse

def get_params():
parser = argparse.ArgumentParser()
# 编码器和解码器的最大序列长度
parser.add_argument("--max_enc_len", default=300, help="Encoder input max sequence length", type=int)
parser.add_argument("--max_dec_len", default=50, help="Decoder input max sequence length", type=int)
# 一个训练批次的大小
parser.add_argument("--batch_size", default=64, help="Batch size", type=int)
# seq2seq训练轮数
parser.add_argument("--seq2seq_train_epochs", default=20, help="Seq2seq model training epochs", type=int)
# 词嵌入大小
parser.add_argument("--embed_size", default=500, help="Words embeddings dimension", type=int)

# ----------------------------------------------------------------------------------------
# 相比3.1小节代码文件添加下面一行
# word2vec模型训练轮数
parser.add_argument("--wv_train_epochs", default=10, help="Word2vec model training epochs", type=int)
# -----------------------------------------------------------------------------------------# 编码器、解码器以及attention的隐含层单元数
parser.add_argument("--enc_units", default=512, help="Encoder GRU cell units number", type=int)
parser.add_argument("--dec_units", default=512, help="Decoder GRU cell units number", type=int)
parser.add_argument("--attn_units", default=20, help="Used to compute the attention weights", type=int)
# 学习率
parser.add_argument("--learning_rate", default=0.001, help="Learning rate", type=float)
args = parser.parse_args()# param是一个字典类型的变量,键为参数名,值为参数值
params = vars(args)
return params

调用:

if name == 'main':
res = get_params()
print(res)
输出结果:

{'max_enc_len': 300, 'max_dec_len': 50, 'batch_size': 64, 'seq2seq_train_epochs': 20, 'embed_size': 500, 'wv_train_epochs': 10, 'enc_units': 512, 'dec_units': 512, 'attn_units': 20, 'learning_rate': 0.001}
第四步: 编写data_loader.py文件. - 代码文件路径: /home/ec2-user/text_summary/seq2seq/utils/data_loader.py

关于data_loader.py文件, 分成两个主要部分: - 1: 关于原始数据处理的部分.

2: 关于预训练词向量的部分.

在本模块中, 我们只处理到词向量预训练之前的部分即可.

首先明确一点, 在3.1节中的下列函数, 完全copy到3.2小节中即可, 代码一模一样.

load_train_dataset(max_enc_len=300, max_dec_len=50)

load_test_dataset(max_enc_len=300)

get_max_len(data)

transform_data(sentence, word_to_id)

pad_proc(sentence, max_len, word_to_id)

load_stop_words(stop_word_path)

clean_sentence(sentence)

filter_stopwords(seg_list)

sentence_proc(sentence)

sentences_proc(df)
接下来直展示bulid_dataset()函数的部分:

导入项目需要的工具包

import re
import jieba
import pandas as pd
import numpy as np
import os
import sys

配置项目的root目录, 方便后续代码文件的导入

root_path = os.path.dirname(os.path.dirname(os.path.abspath(file)))
sys.path.append(root_path)

并行处理模块

from utils.multi_proc_utils import parallelize

配置模块

from utils.config import *

参数模块

from utils.params_utils import get_params

载入词向量参数

params = get_params()

jieba载入自定义切词表

jieba.load_userdict(user_dict_path)

数据预处理总函数, 用于数据加载 + 预处理 (注意: 只需执行一次)

def build_dataset(train_raw_data_path, test_raw_data_path):
# 1. 加载原始数据
print('1. 加载原始数据')
print(train_raw_data_path)
# 必须设定数据格式为utf-8
train_df = pd.read_csv(train_raw_data_path, engine='python', encoding='utf-8')
test_df = pd.read_csv(test_raw_data_path, engine='python', encoding='utf-8')

# 82943, 20000
print('原始训练集行数 {}, 测试集行数 {}'.format(len(train_df), len(test_df)))
print('\n')# 2. 空值去除(对于一行数据, 任意列只要有空值就去掉该行)
print('2. 空值去除(对于一行数据,任意列只要有空值就去掉该行)')
train_df.dropna(subset=['Question', 'Dialogue', 'Report'], how='any', inplace=True)
test_df.dropna(subset=['Question', 'Dialogue'], how='any', inplace=True)
print('空值去除后训练集行数 {}, 测试集行数 {}'.format(len(train_df), len(test_df)))
print('\n')# 3. 多线程, 批量数据预处理(对每个句子执行sentence_proc, 清除无用词, 分词, 过滤停用词, 再用空格拼接为一个字符串)
print('3. 多线程, 批量数据预处理(对每个句子执行sentence_proc, 清除无用词, 分词, 过滤停用词, 再用空格拼接为一个字符串)')
train_df = parallelize(train_df, sentences_proc)
test_df = parallelize(test_df, sentences_proc)
print('\n')
print('sentences_proc has done!')# 4. 合并训练测试集, 用于构造映射字典word_to_id
print('4. 合并训练测试集, 用于构造映射字典word_to_id')
# 新建一列, 按行堆积
train_df['merged'] = train_df[['Question', 'Dialogue', 'Report']].apply(lambda x: ' '.join(x), axis=1)
# 新建一列, 按行堆积
test_df['merged'] = test_df[['Question', 'Dialogue']].apply(lambda x: ' '.join(x), axis=1)
# merged列是训练集三列和测试集两列按行连接在一起再按列堆积, 用于构造映射字典
# 按列堆积, 用于构造映射字典
merged_df = pd.concat([train_df[['merged']], test_df[['merged']]], axis=0)
print('训练集行数{}, 测试集行数{}, 合并数据集行数{}'.format(len(train_df), len(test_df), len(merged_df)))
print('\n')# 5. 保存分割处理好的train_seg_data.csv, test_set_data.csv
print('5. 保存分割处理好的train_seg_data.csv, test_set_data.csv')
# 把建立的列merged去掉, 该列对于神经网络无用
train_df = train_df.drop(['merged'], axis=1)
test_df = test_df.drop(['merged'], axis=1)
# 将处理后的数据存入持久化文件
train_df.to_csv(train_seg_path, index=None, header=True)
test_df.to_csv(test_seg_path, index=None, header=True)
print('The csv_file has saved!')
print('\n')# 6. 保存合并数据merged_seg_data.csv, 用于构造映射字典word_to_id
print('6. 保存合并数据merged_seg_data.csv, 用于构造映射字典word_to_id')
merged_df.to_csv(merged_seg_path, index=None, header=False)
print('The word_to_vector file has saved!')
print('\n')

上述代码处理到第6步结束, 这个merged_seg_path路径下保存的数据文件merged_seg_data.csv, 就是接下来训练词向量的语料.

利用gensim训练词向量¶
avatar

gensim工具包是一款非常便捷, 工业化的训练词向量的工具. 支持Word2Vec算法中的两种模式(SkipGram, CBOW), 默认采用CBOW模式.

当要预训练词向量时, 只需要在data_loader.py代码文件中添加如下代码即可:

from gensim.models.word2vec import LineSentence, Word2Vec

# 7. 训练词向量, LineSentence传入csv文件名
print('7. 训练词向量, LineSentence传入csv文件名.')
# gensim中的Word2Vec算法默认采用CBOW模式训练.
wv_model = Word2Vec(LineSentence(merged_seg_path), vector_size=params['embed_size'],negative=5, workers=8, epochs=params['wv_train_epochs'],window=3, min_count=5)
print('The wv_model has trained over!')
print('\n')

完成第7步后, 已经有了词向量, 这个时候可以对第5步分割好的数据进行特殊字符填充.

8. 将Question和Dialogue用空格连接作为模型输入形成train_df['X']

print("8. 将Question和Dialogue用空格连接作为模型输入形成train_df['X']")
train_df['X'] = train_df[['Question', 'Dialogue']].apply(lambda x: ' '.join(x), axis=1)
test_df['X'] = test_df[['Question', 'Dialogue']].apply(lambda x: ' '.join(x), axis=1)
print('\n')# 9. 填充<START>, <STOP>, <UNK>和<PAD>, 使数据变为等长
print('9. 填充<START>, <STOP>, <UNK> 和 <PAD>, 使数据变为等长')# 获取适当的最大长度
train_x_max_len = get_max_len(train_df['X'])
test_x_max_len = get_max_len(test_df['X'])
train_y_max_len = get_max_len(train_df['Report'])print('填充前训练集样本的最大长度为: ', train_x_max_len)
print('填充前测试集样本的最大长度为: ', test_x_max_len)
print('填充前训练集标签的最大长度为: ', train_y_max_len)# 选训练集和测试集中较大的值
x_max_len = max(train_x_max_len, test_x_max_len)# 训练集X填充处理
# train_df['X'] = train_df['X'].apply(lambda x: pad_proc(x, x_max_len, vocab))
print('训练集X填充PAD, START, STOP, UNK处理中...')
train_df['X'] = train_df['X'].apply(lambda x: pad_proc(x, x_max_len, word_to_id))
# 测试集X填充处理
print('测试集X填充PAD, START, STOP, UNK处理中...')
test_df['X'] = test_df['X'].apply(lambda x: pad_proc(x, x_max_len, word_to_id))
# 训练集Y填充处理
print('训练集Y填充PAD, START, STOP, UNK处理中...')
train_df['Y'] = train_df['Report'].apply(lambda x: pad_proc(x, train_y_max_len, word_to_id))
print('\n')# 10. 保存填充<START>, <STOP>, <UNK>和<PAD>后的X和Y
print('10. 保存填充<START>, <STOP>, <UNK>和<PAD>后的X和Y')
train_df['X'].to_csv(train_x_pad_path, index=None, header=False)
train_df['Y'].to_csv(train_y_pad_path, index=None, header=False)
test_df['X'].to_csv(test_x_pad_path, index=None, header=False)
print('填充后的三个文件保存完毕!')
print('\n')

二次训练词向量: 填充后, 总体数据文件中很明显多出来4个字符 ,
,
,
. 因此需要二次训练词向量.

11. 重新训练词向量,将, , , 加入词典最后

print('11. 重新训练词向量, 将<START>, <STOP>, <UNK>, <PAD>加入词典最后')
wv_model.build_vocab(LineSentence(train_x_pad_path), update=True)
wv_model.train(LineSentence(train_x_pad_path),epochs=params['wv_train_epochs'],total_examples=wv_model.corpus_count)
print('1/3 train_x_pad_path')
wv_model.build_vocab(LineSentence(train_y_pad_path), update=True)
wv_model.train(LineSentence(train_y_pad_path),epochs=params['wv_train_epochs'],total_examples=wv_model.corpus_count)
print('2/3 train_y_pad_path')
wv_model.build_vocab(LineSentence(test_x_pad_path), update=True)
wv_model.train(LineSentence(test_x_pad_path),epochs=params['wv_train_epochs'],total_examples=wv_model.corpus_count)
print('3/3 test_x_pad_path')# 保存词向量模型.model
wv_model.save(word_vector_path)
print('词向量训练完成')
print('最终词向量的词典大小为: ', len(wv_model.wv.key_to_index))
print('\n')

二次训练词向量结束后, 我们要构建单词映射字典word_to_id并保存, 然后利用word_to_id完成最重要的文本数字化映射.

12. 更新vocab并保存

print('12. 更新vocab并保存')
word_to_id = {word: index for index, word in enumerate(wv_model.wv.key_to_index)}
id_to_word = {index: word for index, word in enumerate(wv_model.wv.key_to_index)}
save_vocab_as_txt(vocab_path, word_to_id)
save_vocab_as_txt(reverse_vocab_path, id_to_word)
print('更新后的word_to_id, id_to_word保存完毕!')
print('\n')# 13. 数据集转换 将词转换成索引  [<START> 方向机 重 ...] -> [32800, 403, 986, 246, 231]
print('13. 数据集转换 将词转换成索引  [<START> 方向机 重 ...] -> [32800, 403, 986, 246, 231]')
print('训练集X执行transform_data中......')
train_ids_x = train_df['X'].apply(lambda x: transform_data(x, word_to_id))
print('训练集Y执行transform_data中......')
train_ids_y = train_df['Y'].apply(lambda x: transform_data(x, word_to_id))
print('测试集X执行transform_data中......')
test_ids_x = test_df['X'].apply(lambda x: transform_data(x, word_to_id))
print('\n')# 14. 数据转换成numpy数组(需等长)
# 将索引列表转换成矩阵 [32800, 403, 986, 246, 231] --> array([[32800, 403, 986, 246, 231], ...])
print('14. 数据转换成numpy数组(需等长)')
train_X = np.array(train_ids_x.tolist())
train_Y = np.array(train_ids_y.tolist())
test_X = np.array(test_ids_x.tolist())
print('转换为numpy数组的形状如下: \ntrain_X的shape为: ', train_X.shape, '\ntrain_Y的shape为: ', train_Y.shape, '\ntest_X的shape为: ', test_X.shape)
print('\n')# 15. 保存数据
print('15. 保存数据')
np.save(train_x_path, train_X)
np.save(train_y_path, train_Y)
np.save(test_x_path, test_X)
print('\n')
print('数据集构造完毕,于seq2seq/data/目录下')

调用:

if name == 'main':
build_dataset(train_raw_data_path, test_raw_data_path)
输出结果:

Building prefix dict from the default dictionary ...
Loading model from cache /tmp/jieba.cache
Loading model cost 0.758 seconds.
Prefix dict has been built successfully.

  1. 加载原始数据
    /home/ec2-user/text_summary/seq2seq/data/train.csv
    原始训练集行数 82943, 测试集行数 20000

  2. 空值去除(对于一行数据,任意列只要有空值就去掉该行)
    空值去除后训练集行数 82871, 测试集行数 20000

  3. 多线程, 批量数据预处理(对每个句子执行sentence_proc,清除无用词,切词,过滤停用词,再用空格拼接为一个字符串)

sentences_proc has done!
4. 合并训练测试集,用于训练词向量
训练集行数 82871, 测试集行数 20000, 合并数据集行数 102871

  1. 保存分割处理好的train_seg_data.csv、test_set_data.csv
    The csv_file has saved!

  2. 保存合并数据merged_seg_data.csv,用于训练词向量
    The word_to_vector file has saved!

  3. 训练词向量,LineSentence传入csv文件名
    The wv_model has trained over!

  4. 将Question和Dialogue用空格连接作为模型输入形成train_df['X']

  5. 填充, , ,使数据变为等长
    填充前训练集样本的最大长度为: 298
    填充前测试集样本的最大长度为: 312
    填充前训练集标签的最大长度为: 38
    训练集X填充PAD,START,STOP,UNK处理中...
    测试集X填充PAD,START,STOP,UNK处理中...
    训练集Y填充PAD,START,STOP,UNK处理中...

  6. 保存填充, , 后的X和Y
    填充后的三个文件保存完毕!

  7. 重新训练词向量,将 加入词典最后
    1/3 train_x_pad_path
    2/3 train_y_pad_path
    3/3 test_x_pad_path
    词向量训练完成
    最终词向量的词典大小为: 32230

  8. 更新vocab并保存
    更新后的word_to_id, id_to_word保存完毕!

  9. 数据集转换 将词转换成索引 [ 方向机 重 ...] -> [32800, 403, 986, 246, 231]
    训练集X执行transform_data中......
    训练集Y执行transform_data中......
    测试集X执行transform_data中......

  10. 数据转换成numpy数组(需等长)
    转换为numpy数组的形状如下:
    train_X的shape为: (82871, 314)
    train_Y的shape为: (82871, 40)
    test_X的shape为: (20000, 314)

  11. 保存数据

数据集构造完毕,于seq2seq/data/目录下
来到数据存储路径下, 查看新生成的数据文件: - 数据文件路径: /home/ec2-user/text_summary/seq2seq/data/

-rw-rw-r-- 1 ec2-user ec2-user 218 3月 1 05:17 demo.py
-rw-rw-r-- 1 ec2-user ec2-user 72257320 3月 5 02:30 merged_seg_data.csv
drwxrwxr-x 2 ec2-user ec2-user 4096 3月 1 05:17 result
-rwxr-xr-x 1 ec2-user ec2-user 5211 3月 1 05:17 stopwords.txt
-rwxr-xr-x 1 ec2-user ec2-user 17514902 3月 1 05:17 test.csv
-rw-rw-r-- 1 ec2-user ec2-user 13073980 3月 5 02:30 test_seg_data.csv
-rw-rw-r-- 1 ec2-user ec2-user 50240128 3月 5 02:35 test_X.npy
-rw-rw-r-- 1 ec2-user ec2-user 37908611 3月 5 02:32 test_X_pad_data.csv
-rwxr-xr-x 1 ec2-user ec2-user 82314291 3月 1 05:17 train.csv
-rw-rw-r-- 1 ec2-user ec2-user 61658661 3月 5 02:30 train_seg_data.csv
-rw-rw-r-- 1 ec2-user ec2-user 208172080 3月 5 02:35 train_X.npy
-rw-rw-r-- 1 ec2-user ec2-user 157164957 3月 5 02:32 train_X_pad_data.csv
-rw-rw-r-- 1 ec2-user ec2-user 26518848 3月 5 02:35 train_Y.npy
-rw-rw-r-- 1 ec2-user ec2-user 20664099 3月 5 02:32 train_Y_pad_data.csv
-rwxr-xr-x 1 ec2-user ec2-user 16639 3月 1 05:17 user_dict.txt
drwxrwxr-x 2 ec2-user ec2-user 4096 3月 5 02:35 wv
查看词向量文件夹: /home/ec2-user/text_summary/seq2seq/data/wv/

-rw-rw-r-- 1 ec2-user ec2-user 420560 3月 5 02:35 reverse_vocab.txt
-rw-rw-r-- 1 ec2-user ec2-user 420560 3月 5 02:35 vocab.txt
-rw-rw-r-- 1 ec2-user ec2-user 2020869 3月 5 02:35 word2vec.model
-rw-rw-r-- 1 ec2-user ec2-user 64460128 3月 5 02:35 word2vec.model.trainables.syn1neg.npy
-rw-rw-r-- 1 ec2-user ec2-user 64460128 3月 5 02:35 word2vec.model.wv.vectors.npy
结论: 利用gensim训练词向量的过程, 其实是穿插在数据预处理的流程中. 而且在添加特殊字符后, 我们也学习了如何二次训练词向量, 本质是一个追加新单词和重新寻找语义映射的过程.

利用词向量优化baseline-1模型¶
首先, 需要明确在3.1小节基础上, 有哪些代码文件需要进行修改和添加. - 批次数据生成器函数batcher.py

子层类函数layers.py

模型类函数model.py

训练函数train_helper.py和train.py

测试函数test_helper.py和test.py

批次数据生成器函数batcher.py¶
这个函数完全不需要改动, 照搬过来即可. - 代码文件路径: /home/ec2-user/text_summary/seq2seq/src/batcher.py

子层类函数layers.py¶
这个函数需要改动, 因为采用了预训练词向量的加载模式. - 代码文件路径: /home/ec2-user/text_summary/seq2seq/src/layers.py

修改部分集中在Encoder, Decoder.

类Attention完全不改动(因为不涉及到词向量加载部分).

导入项目所需的工具包

import torch
import torch.nn as nn
import torch.nn.functional as F
import os
import sys

设置项目的root目录, 方便后续相关代码文件的导入

root_path = os.path.dirname(os.path.dirname(os.path.abspath(file)))
sys.path.append(root_path)

导入关键的预训练词向量加载路径

from utils.config import word_vector_path

在utils.word2vec_utils.py文件中添加两个新函数, 用于读取预训练词向量

from utils.word2vec_utils import get_vocab_from_model, load_embedding_matrix_from_model

class Encoder(nn.Module):
def init(self, vocab_size, embedding_dim, embedding_matrix, enc_units, batch_size):
# 新增的重要参数embedding_matrix: 这就是预训练好的词向量矩阵[vocab_size, embedding_dim]
super(Encoder, self).init()
self.vocab_size = vocab_size
self.embedding_dim = embedding_dim
self.enc_units = enc_units
self.batch_size = batch_size

    # ---------------------------------------------------------------------------------------# 此处为在3.1小节基础上添加的代码, 用于加载预训练词向量, 代码文件的其他部分不变.# 当采用直接加载词向量的模式时, 代码从nn.Embedding(), 变成了nn.Embedding.from_pretrained()# 参数embedding_matrix: 从文件中读取的词向量矩阵, 直接作为参数传入即可.self.embedding = nn.Embedding.from_pretrained(embedding_matrix)# ---------------------------------------------------------------------------------------self.gru = nn.GRU(input_size=embedding_dim,hidden_size=enc_units,num_layers=1,batch_first=True)def forward(self, x, h0):# x.shape: (batch_size, sequence_length)# h0.shape: (num_layers, batch_size, enc_units)x = self.embedding(x)output, hn = self.gru(x, h0)return output, hn.transpose(1, 0)def initialize_hidden_state(self):return torch.zeros(1, self.batch_size, self.enc_units)

class Attention(nn.Module):
def init(self, enc_units, dec_units, attn_units):
super(Attention, self).init()
self.enc_units = enc_units
self.dec_units = dec_units
self.attn_units = attn_units

    self.w1 = nn.Linear(enc_units, attn_units)self.w2 = nn.Linear(dec_units, attn_units)self.v = nn.Linear(attn_units, 1)def forward(self, query, value):# query为上次的decoder隐藏层,shape: (batch_size, dec_units)# values为编码器的编码结果enc_output,shape: (batch_size, enc_seq_len, enc_units)# 在应用self.V之前,张量的形状是(batch_size, enc_seq_len, attention_units)# 得到score的shape: (batch_size, seq_len, 1)score = self.v(torch.tanh(self.w1(value) + self.w2(query)))# 注意力权重,是score经过softmax,但是要作用在第一个轴上(seq_len的轴)attention_weights = F.softmax(score, dim=1)# (batch_size, enc_seq_len, 1) * (batch_size, enc_seq_len, enc_units)# 广播, encoder unit的每个位置都对应相乘context_vector = attention_weights * value# 在最大长度enc_seq_len这一维度上求和context_vector = torch.sum(context_vector, dim=1)# context_vector求和之后的shape: (batch_size, enc_units)return context_vector, attention_weights

class Decoder(nn.Module):
def init(self, vocab_size, embedding_dim, embedding_matrix, dec_units, batch_size):
# 新增的重要参数embedding_matrix: 这就是预训练好的词向量矩阵[vocab_size, embedding_dim]
super(Decoder, self).init()
self.vocab_size = vocab_size
self.embedding_dim = embedding_dim
self.dec_units = dec_units
self.batch_size = batch_size

    # ---------------------------------------------------------------------------------------# 此处为在3.1小节基础上添加的代码, 用于加载预训练词向量, 代码文件的其他部分不变.# 当采用直接加载词向量的模式时, 代码从nn.Embedding(), 变成了nn.Embedding.from_pretrained()# 参数embedding_matrix: 从文件中读取的词向量矩阵, 直接作为参数传入即可.self.embedding = nn.Embedding.from_pretrained(embedding_matrix)# ---------------------------------------------------------------------------------------self.gru = nn.GRU(input_size=embedding_dim + dec_units,hidden_size=dec_units,num_layers=1,batch_first=True)self.fc = nn.Linear(dec_units, vocab_size)def forward(self, x, context_vector):x = self.embedding(x)# x.shape after passing through embedding: (batch_size, 1, embedding_dim),1指的是一次只解码一个单词# 将上一循环的预测结果跟注意力权重值结合在一起作为本次的GRU网络输入x = torch.cat([torch.unsqueeze(context_vector, 1), x], dim=-1)output, hn = self.gru(x)output = output.squeeze(1)prediction = self.fc(output)return prediction, hn.transpose(1, 0)

在调用上述代码之前, 我们先要在word2vec_utils.py文件中添加两个辅助函数: - 代码文件路径: /home/ec2-user/text_summary/seq2seq/utils/word2vec_utils.py

from gensim.models.word2vec import Word2Vec

从word2vec模型中获取词向量矩阵

def load_embedding_matrix_from_model(wv_model_path):
# wv_model_path: word2vec模型的路径
wv_model = Word2Vec.load(wv_model_path)

# wv_model.wv.vectors包含词向量矩阵
embedding_matrix = wv_model.wv.vectorsreturn embedding_matrix

从word2vec模型中获取正向和反向词典

def get_vocab_from_model(wv_model_path):
# wv_model_path: word2vec模型的路径
wv_model = Word2Vec.load(wv_model_path)

# 创建单词映射字典word_to_id和反向映射字典id_to_word
id_to_word = {index: word for index, word in enumerate(wv_model.wv.index2word)}
word_to_id = {word: index for index, word in enumerate(wv_model.wv.index2word)}return word_to_id, id_to_word

调用(layers.py):

if name == 'main':
word_to_id, id_to_word = get_vocab_from_model(word_vector_path)
vocab_size = len(word_to_id)
print('vocab_size: ', vocab_size)

embedding_matrix = load_embedding_matrix_from_model(word_vector_path)
print('词向量矩阵形状: ', embedding_matrix.shape)
embedding_matrix = torch.from_numpy(embedding_matrix)
print('embedding_matrix类型: ', type(embedding_matrix))# 测试用参数
EXAMPLE_INPUT_SEQUENCE_LEN = 300
BATCH_SIZE = 64
EMBEDDING_DIM = 500
GRU_UNITS = 512
ATTENTION_UNITS = 20encoder = Encoder(vocab_size, EMBEDDING_DIM, embedding_matrix, GRU_UNITS, BATCH_SIZE)input0 = torch.ones((BATCH_SIZE, EXAMPLE_INPUT_SEQUENCE_LEN), dtype=torch.long)
h0 = encoder.initialize_hidden_state()
output, hn = encoder(input0, h0)
print('Encoder output: ', output.shape)
print('Encoder hn: ', hn.shape)attention = Attention(GRU_UNITS, GRU_UNITS, ATTENTION_UNITS)
context_vector, attention_weights = attention(hn, output)
print('Attention context_vector: ', context_vector.shape)
print('Attention attention_weights: ', attention_weights.shape)decoder = Decoder(vocab_size, EMBEDDING_DIM, embedding_matrix, GRU_UNITS, BATCH_SIZE)
input1 = torch.ones((BATCH_SIZE, 1), dtype=torch.long)
output1, hn = decoder(input1, context_vector)
print('Decoder output: ', output1.shape)
print('Decoder hn: ', hn.shape)

输出结果:

vocab_size: 32230
词向量矩阵形状: (32230, 500)
embedding_matrix类型: <class 'torch.Tensor'>
Encoder output: torch.Size([64, 300, 512])
Encoder hn: torch.Size([64, 1, 512])
Attention context_vector: torch.Size([64, 512])
Attention attention_weights: torch.Size([64, 300, 1])
Decoder output: torch.Size([64, 32230])
Decoder hn: torch.Size([64, 1, 512])
模型类函数model.py¶
这个函数需要改动, 因为采用了预训练词向量的加载模式. - 代码文件路径: /home/ec2-user/text_summary/seq2seq/src/model.py

import os
import sys
root_path = os.path.dirname(os.path.dirname(os.path.abspath(file)))
sys.path.append(root_path)

import torch
import torch.nn as nn
from src.layers import Encoder, Attention, Decoder

导入3.2节新增的函数, 方便预训练词向量的导入

from utils.config import word_vector_path
from utils.word2vec_utils import load_embedding_matrix_from_model, get_vocab_from_model

class Seq2Seq(nn.Module):
def init(self, params):
super(Seq2Seq, self).init()

    # -----------------------------------------------------------------------------------# 下面代码属于3.2节修改部分, 从训练词向量转变为直接加载预训练的词向量embedding_matrix = load_embedding_matrix_from_model(word_vector_path)self.embedding_matrix = torch.from_numpy(embedding_matrix)# -----------------------------------------------------------------------------------self.params = paramsself.encoder = Encoder(params['vocab_size'], params['embed_size'], self.embedding_matrix,params['enc_units'], params['batch_size'])self.attention = Attention(params['enc_units'], params['dec_units'], params['attn_units'])self.decoder = Decoder(params['vocab_size'], params['embed_size'], self.embedding_matrix,params['dec_units'], params['batch_size'])# 实质上是在调用解码器,因为需要注意力机制,直接封装到forward中. 要调用编码器直接encoder()即可
def forward(self, dec_input, dec_hidden, enc_output, dec_target):# 这里的dec_input实质是(batch_size, 1)大小的<START>predictions = []# 拿编码器的输出和最终隐含层向量来计算context_vector, attention_weights = self.attention(dec_hidden, enc_output)# 循环解码for t in range(dec_target.shape[1]):# dec_input (batch_size, 1);dec_hidden (batch_size, hidden_units)pred, dec_hidden = self.decoder(dec_input, context_vector)context_vector, attention_weights = self.attention(dec_hidden, enc_output)# 使用teacher forcing, 并扩展维度到三维张量dec_input = dec_target[:, t].unsqueeze(1)predictions.append(pred)return torch.stack(predictions, 1), dec_hidden

调用:

if name == 'main':
word_to_id, id_to_word = get_vocab_from_model(word_vector_path)

vocab_size = len(word_to_id)
batch_size = 64
input_seq_len = 300# 模拟测试参数
params = {"vocab_size": vocab_size, "embed_size": 500, "enc_units": 512,"attn_units": 20, "dec_units": 512,"batch_size": batch_size}# 实例化类对象
model = Seq2Seq(params)# 初始化测试输入数据
sample_input_batch = torch.ones((batch_size, input_seq_len), dtype=torch.long)
sample_hidden = model.encoder.initialize_hidden_state()# 调用Encoder进行编码
sample_output, sample_hidden = model.encoder(sample_input_batch, sample_hidden)# 打印输出张量维度
print('Encoder output shape: (batch_size, enc_seq_len, enc_units) {}'.format(sample_output.shape))
print('Encoder Hidden state shape: (batch_size, enc_units) {}'.format(sample_hidden.shape))# 调用Attention进行注意力张量
context_vector, attention_weights = model.attention(sample_hidden, sample_output)print("Attention context_vector shape: (batch_size, enc_units) {}".format(context_vector.shape))
print("Attention weights shape: (batch_size, sequence_length, 1) {}".format(attention_weights.shape))# 调用Decoder进行解码
dec_input = torch.ones((batch_size, 1), dtype=torch.long)
sample_decoder_output, _, = model.decoder(dec_input, context_vector)print('Decoder output shape: (batch_size, vocab_size) {}'.format(sample_decoder_output.shape))
# 这里仅测试一步, 没有用到dec_seq_len

输出结果:

Encoder output shape: (batch_size, enc_seq_len, enc_units) torch.Size([64, 300, 512])
Encoder Hidden state shape: (batch_size, enc_units) torch.Size([64, 1, 512])
Attention context_vector shape: (batch_size, enc_units) torch.Size([64, 512])
Attention weights shape: (batch_size, sequence_length, 1) torch.Size([64, 300, 1])
Decoder output shape: (batch_size, vocab_size) torch.Size([64, 32230])
训练函数train_helper.py和train.py¶
这两个函数完全不需要改动, 照搬过来即可. - 代码文件路径: /home/ec2-user/text_summary/seq2seq/src/train_helper.py

代码文件路径: /home/ec2-user/text_summary/seq2seq/src/train.py

直接启动训练代码, 训练baseline-1的第一版优化模型(预训练词向量).

cd /home/ec2-user/text_summary/seq2seq/src/

python train.py
输出结果:

Building prefix dict from the default dictionary ...
Loading model from cache /tmp/jieba.cache
Loading model cost 0.744 seconds.
Prefix dict has been built successfully.
Building the model ...
开始训练模型
Model has put to GPU...
Epoch 1 Batch 20 Loss 2.4901
Epoch 1 Batch 40 Loss 2.6017
Epoch 1 Batch 60 Loss 2.4710
Epoch 1 Batch 80 Loss 2.4963
Epoch 1 Batch 100 Loss 2.6839
Epoch 1 Batch 120 Loss 2.3770
Epoch 1 Batch 140 Loss 2.3004
Epoch 1 Batch 160 Loss 1.9315
Epoch 1 Batch 180 Loss 2.1637
Epoch 1 Batch 200 Loss 1.8733
Epoch 1 Batch 220 Loss 2.2211
Epoch 1 Batch 240 Loss 1.8080
Epoch 1 Batch 260 Loss 1.9343
Epoch 1 Batch 280 Loss 2.0968
Epoch 1 Batch 300 Loss 2.3351
Epoch 1 Batch 320 Loss 1.9463
Epoch 1 Batch 340 Loss 1.7708
Epoch 1 Batch 360 Loss 2.0452
Epoch 1 Batch 380 Loss 2.0610
Epoch 1 Batch 400 Loss 1.8950
Epoch 1 Batch 420 Loss 2.3568
Epoch 1 Batch 440 Loss 2.0780
Epoch 1 Batch 460 Loss 1.9301
Epoch 1 Batch 480 Loss 2.0048
Epoch 1 Batch 500 Loss 1.8250
Epoch 1 Batch 520 Loss 1.8906
Epoch 1 Batch 540 Loss 1.7807
Epoch 1 Batch 560 Loss 1.9331
Epoch 1 Batch 580 Loss 1.8724
Epoch 1 Batch 600 Loss 1.8327
Epoch 1 Batch 620 Loss 1.7531
Epoch 1 Batch 640 Loss 1.6628
Epoch 1 Batch 660 Loss 1.7954
Epoch 1 Batch 680 Loss 1.8484
Epoch 1 Batch 700 Loss 1.8397
Epoch 1 Batch 720 Loss 2.0916
Epoch 1 Batch 740 Loss 1.7825
Epoch 1 Batch 760 Loss 1.9343
Epoch 1 Batch 780 Loss 1.7332
Epoch 1 Batch 800 Loss 1.5696
Epoch 1 Batch 820 Loss 1.7244
Epoch 1 Batch 840 Loss 1.6454
Epoch 1 Batch 860 Loss 1.7286
Epoch 1 Batch 880 Loss 1.8121
Epoch 1 Batch 900 Loss 1.8182
Epoch 1 Batch 920 Loss 1.7598
Epoch 1 Batch 940 Loss 1.6819
Epoch 1 Batch 960 Loss 1.7877
Epoch 1 Batch 980 Loss 1.7388
Epoch 1 Batch 1000 Loss 1.5945
Epoch 1 Batch 1020 Loss 1.6095
Epoch 1 Batch 1040 Loss 1.6016
Epoch 1 Batch 1060 Loss 1.6756
Epoch 1 Batch 1080 Loss 1.5669
Epoch 1 Batch 1100 Loss 1.4655
Epoch 1 Batch 1120 Loss 1.5950
Epoch 1 Batch 1140 Loss 1.7769
Epoch 1 Batch 1160 Loss 1.5538
Epoch 1 Batch 1180 Loss 1.5974
Epoch 1 Batch 1200 Loss 1.9278
Epoch 1 Batch 1220 Loss 1.7174
Epoch 1 Batch 1240 Loss 1.8733
Epoch 1 Batch 1260 Loss 1.5951
Epoch 1 Batch 1280 Loss 1.5825
Time taken for 1 epoch 582.40704870224 sec

......
......
......
查看模型训练结果文件: - 模型文件路径: /home/ec2-user/text_summary/seq2seq/src/saved_model/

......
......
......
-rw-rw-r-- 1 ec2-user ec2-user 210748955 3月 1 05:17 model_15.pt
-rw-rw-r-- 1 ec2-user ec2-user 210748955 3月 1 05:17 model_17.pt
-rw-rw-r-- 1 ec2-user ec2-user 210748955 3月 1 05:17 model_19.pt
结论: 随着训练的递进, 损失值稳步下降, 同时考虑到在Tesla T4 GPU的算力下, 每个epoch也需耗时接近10分钟, 这样在CPU机器上的训练压力就更大了. 最终保存了10个模型文件, 也可以适当减少.

测试函数test_helper.py和test.py¶
这两个函数完全不需要改动, 照搬过来即可. - 代码文件路径: /home/ec2-user/text_summary/seq2seq/src/test_helper.py

代码文件路径: /home/ec2-user/text_summary/seq2seq/src/test.py

直接启动测试代码, 测试baseline-1的第一版优化模型(预训练词向量).

cd /home/ec2-user/text_summary/seq2seq/src/

python test.py
输出结果:

Building prefix dict from the default dictionary ...
Loading model from cache /tmp/jieba.cache
Loading model cost 0.744 seconds.
Prefix dict has been built successfully.
创建字典
创建模型
模型加载完毕!
生成测试数据迭代器
开始解码......
i = 20
i = 40
i = 60
i = 80
i = 100
i = 120
i = 140
i = 160
i = 180
i = 200
i = 220
i = 240
i = 260
i = 280
i = 300
解码完毕, 开始保存结果......
读取原始测试数据...
构建新的DataFrame并保存文件...
保存测试结果完毕!
['说情况,建议去4s店进行检查,检查发动机内部损坏,活塞环老化损坏,活塞环老化损坏,活塞环老化损坏,活塞环老化损坏,活塞环老化损坏,活塞环老化损坏,活塞环老化损坏,活塞环老化损坏,活塞环老化损坏',
'修理厂喷漆处理,去修理厂喷漆处理,去修理厂喷漆处理,去修理厂喷漆处理,去修理厂喷漆处理,去修理厂喷漆处理,去修理厂喷漆处理,去修理厂喷漆处理,去修理厂喷漆处理,去修理厂喷漆处理,',
'费用方面,价格差不多,价格差不多,价格差不多,价格差不多,价格差不多,价格差不多,价格差不多,价格差不多,价格差不多,价格差不多,价格差不多,价格差不多,价格差不多,价格差不多,价格差不多,价格',
'发动机怠速高,需要检查发动机是否漏气地方。',
',轮胎动平衡不好引起抖动,建议轮胎动平衡不好引起抖动,建议轮胎动平衡不好引起抖动,建议轮胎动平衡不好引起抖动,建议轮胎动平衡不好引起抖动,建议轮胎动平衡不好引起抖动,建议轮胎动平衡不好引起抖动,',
'洗车清洗一下!',
',说,建议购买,建议购买。',
'发动机故障灯亮,可能发动机故障,可能发动机故障,可能发动机故障,可能发动机故障,可能发动机故障,可能发动机故障,可能发动机故障,可能发动机故障,可能发动机故障,可能发动机故障,可能发动机故障,可能',
'这种情况无需担心,继续使用,无需更换。',
'这种情况不用担心。']
-rw-rw-r-- 1 ec2-user ec2-user 3100038 3月 5 08:33 seq2seq_2021_03_05_08_33_28.csv

Q19,建议电脑检测一下变速箱
Q20,正常情况下索赔更换
Q21,更换变速箱油,不行更换变速箱油更换变速箱油过脏清洗,不行更换变速箱油更换变速箱油过脏清洗,不行更换>变速箱油更换变速箱油过脏清洗,不行更换变速箱油更换变速箱油过脏清洗,不行更换变速箱油更换
Q22,建议检查机油量是否足够,建议先对机油,检查下机油量是否足够,建议先对机油,检查下机油量是否足够,建>议先对机油,检查下机油量是否足够,建议先对机油,检查下机油量是否
Q23,建议更换车窗模块
Q24,,可能发动机是否严重,可能发动机是否严重,可能发动机是否严重,可能发动机是否严重,可能发动机是否严>重,可能发动机是否严重,可能发动机是否严重,可能发动机是否严重,可能发动机是否严重,可能发动机是否
Q25,这款车型号,长套筒,长套筒,长套筒,长套筒,长套筒,长套筒,长套筒,长套筒,长套筒,长套筒,长套筒>,长套筒,长套筒,长套筒,长套筒,
Q26,,一键启动按钮,感应开关感应齿感应开关,感应开关感应齿感应开关,感应开关感应齿感应开关,感应开关感>应齿感应开关,感应开关感应齿感应开关,感应开关感应齿感应开关,感应开关
Q27,检查底盘是否异响,异响,需要检查底盘是否异响,异响,需要检查底盘是否异响,异响,需要检查底盘是否异>响,异响,需要检查底盘是否异响,异响,需要检查底盘是否异响,异响,需要检查
Q28,检查电瓶坏,建议更换电瓶
Q29,修理厂更换1000元
Q30,,车辆出过相关参考。
Q31,没有影响,首保没有影响,首保没有影响,首保没有影响,首保没有影响,首保没有影响,首保没有影响,首保>没有影响,首保没有影响,首保没有影响,首保没有影响,首保没有影响,首保没有
测试模型结论: 相比于第二章的textRank模型, seq2seq模型的优点是可以自由生成语句, 不受限于原始文本的表达. 但是肉眼可见的缺点是"重复子串生成", 这也是经典seq2seq模型解决文本摘要问题最大的痛点!!! 对比于3.1小节的模型预测效果, 至少从直观目测来看, 没有明显的改进!

小节总结:¶
3.2小节的核心任务是在3.1小节基础上对seq2seq模型进行优化. 模型的优化技术路线有很多, 在这里向同学们介绍了一种前面课程没有用过的新方法-预训练词向量法. 这也是很多公司企业里面常用的做法.

预训练词向量: - 采用gensim工具包训练词向量, 默认采用了CBOW的模式(也支持SkipGram的模式).

训练好的词向量以文件的形式存储, 当构建模型的时候直接加载.

具体的加载方式为nn.Embedding.from_pretrained(embedding_matrix)

单纯使用预训练词向量来优化文本摘要任务, 至少从直观上看效果很一般, 没有明显改进.

4.1 PGN架构解析
PGN模型架构¶
学习目标¶
理解seq2seq架构的缺点和改进点.

理解PGN架构.

seq2seq架构的缺点和改进点¶
avatar

从上面的seq2seq架构图中可以看出来, 对原文本Source Text应用注意力机制, 得到内容分布张量Context Vector. 再结合解码器端当前时间步的输入Decoder Hidden State, 共同产生Vocabulary Distribution. 从中利用贪心解码, 取概率最大的单词作为当前时间步解码器的输出.

seq2seq架构优点: 属于生成式模型, 区别于TextRank抽取式模型, 可以摆脱原文本的束缚, 自由生成相同语义的短文本摘要.

seq2seq架构缺点¶
我们看一下seq2seq模型的预测结果:

['发动机烧机油质量不好,建议发动机烧机油,需要分解发动机检查,发动机烧机油,需要分解发动机检查,发动机烧机油,需要分解发动机检查,发动机烧机油,需要分解发动机检查,发动机烧机油,需要分解',
'更换更换更换更换更换更换更换更换更换更换更换更换更换更换更换更换更换',
'更换转向柱上面,更换方向盘,质量好。',
'发动机散热风扇发动机怠速不稳,导致缸压堵塞。',
',这种情况来看,这种情况来看,这种情况来看,这种情况来看,这种情况来看,这种情况来看,这种情况来看,这种情况来看,这种情况来看,这种情况来看,这种情况来看,这种情况来看,',
'排气管排气管排气管排气管排气管排气管排气管排气管排气管排气管排气管排气管排气管排气管排气管排气管排气管',
',添加,加,加,加,加,加,加,加,加,加,加,加,加,加,加,加,加,加,加,加,加,加,加,加,',
'情况发动机本身发动机进行检查修理,检查燃油液位',
'轮胎出现裂纹,轮胎使用寿命,轮胎使用寿命,轮胎使用寿命,轮胎使用寿命,轮胎使用寿命,轮胎使用寿命,轮胎使用寿命,轮胎使用寿命,轮胎使用寿命,轮胎使用寿命,轮胎使用寿命,轮胎使用寿命,轮胎使用寿命,轮胎使用寿命,轮胎使用寿命,',
'这种情况主要发动机舱没有声音。属于正常,声音大主要受到碰撞发出声音。']
我们再看一部分seq2seq模型预测结果:

['说情况,建议去4s店进行检查,检查发动机内部损坏,活塞环老化损坏,活塞环老化损坏,活塞环老化损坏,活塞环老化损坏,活塞环老化损坏,活塞环老化损坏,活塞环老化损坏,活塞环老化损坏,活塞环老化损坏',
'修理厂喷漆处理,去修理厂喷漆处理,去修理厂喷漆处理,去修理厂喷漆处理,去修理厂喷漆处理,去修理厂喷漆处理,去修理厂喷漆处理,去修理厂喷漆处理,去修理厂喷漆处理,去修理厂喷漆处理,',
'费用方面,价格差不多,价格差不多,价格差不多,价格差不多,价格差不多,价格差不多,价格差不多,价格差不多,价格差不多,价格差不多,价格差不多,价格差不多,价格差不多,价格差不多,价格差不多,价格',
'发动机怠速高,需要检查发动机是否漏气地方。',
',轮胎动平衡不好引起抖动,建议轮胎动平衡不好引起抖动,建议轮胎动平衡不好引起抖动,建议轮胎动平衡不好引起抖动,建议轮胎动平衡不好引起抖动,建议轮胎动平衡不好引起抖动,建议轮胎动平衡不好引起抖动,',
'洗车清洗一下!',
',说,建议购买,建议购买。',
'发动机故障灯亮,可能发动机故障,可能发动机故障,可能发动机故障,可能发动机故障,可能发动机故障,可能发动机故障,可能发动机故障,可能发动机故障,可能发动机故障,可能发动机故障,可能发动机故障,可能',
'这种情况无需担心,继续使用,无需更换。',
'这种情况不用担心。']
综上两个模型的预测结果, 很明显的问题有3个: - 第一: 重复! - 相同短语, 短句的重复. (这是经典seq2seq架构解决文本摘要问题所面对的最大痛点!!!)

第二: 不能准确再现事实细节! - 有的摘要很短却没有点明原文核心语义; 有的摘要很长却无效的重复.

第三: 无法处理OOV单词! - 所有不在单词映射字典中的单词都被UNK替代了.

以上分析本质上就是人工智能领域开发中著名的bas case分析. 分析出缺点后, 这个缺点就是改进点, 就要根据改进点提出改进措施.

seq2seq改进点¶
第一: 采用新的架构, 力求可以将描述原文细节的单词直接用到摘要中.

第二: 如果模型具备第一点的能力, 可以copy原始文本的单词, 那么将极大的解决OOV问题.

第三: 引入机制来控制和跟踪原始文本的重复范围, 减少生成摘要的重复问题.

PGN架构¶
PGN架构图¶
根据上述的改进点, 学术界提出了PGN(Pointer-Generator Network)指针生成网络, 并从2019年以来都作为文本摘要大厂第一线, 最强大的模型工具 (直到2023年大模型时代的到来).
avatar

看上面的PGN架构图, 基本上是在seq2seq架构基础上多了一层. - 先计算出来一个p{gen}
p{gen}.

然后用(1 - p{gen})​
(1 - p{gen})​乘以Attention Distribution, 得到原始文本的信息;

再用p{gen}
p{gen}乘以Vocabulary Distribution, 得到生成文本的信息. 这两部分加和, 得到Final Distribution.

PGN论文解读¶
PGN架构的核心思想: 对于每一个解码器的时间步, 计算一个生成概率p{gen} = [0, 1]
p{gen} = [0, 1]之间的实数值, 相当于一个权值参数. 用这个概率调节两种选择的取舍, 要么从词汇表生成一个单词, 要么从原文本复制一个单词.

从原始论文< Get To The Point: Summarization with Pointer-Generator Networks >中, 可以很清晰的看出整个网络的计算流程: - 第一步: 注意力机制attention distribution计算.

第二步: 内容张量context vector计算.

第三步: 单词分布张量P{vocab}
P{vocab}计算.

第四步: 损失值loss计算.

第五步: 指针值p{gen}​
p{gen}​计算.

第六步: 最终分布张量P_w
P_w计算.

第一步: 注意力机制attention distribution计算. - 对于Encoder, 对于原文本中的单词w_i
w_i, 通过一个双向LSTM网络产生一个encoder hidden states h_i
h_i

对于Decoder, 在时间步t
t, 通过一个单向LSTM网络产生一个decoder state s_t
s_t.

上述两个变量直接利用注意力计算规则得到下图所示的计算公式:
avatar

上面公式中的a_t就是时间步t时刻的attention distribution张量.

第二步: 内容张量context vector计算. - 将第一步得到的attention distribution应用在encoder hidden states上, 即得到context vector h_t
h_t
avatar

第三步: 单词分布张量P{vocab}
P{vocab}计算. - 将第二步得到的context vector和decoder state s_t
s_t结合起来, 经历两个全连接层并计算softmax, 就得到vocabulary distribution P{vocab}
P{vocab}.
avatar
avatar

第四步: 损失值loss计算. - 在训练模型时, 时间步 t
t 时刻针对target word w_t
w_t的损失值, 计算negative log likelihood损失.

遍历整个序列的损失加和就是最终的损失值.
avatar
avatar

第五步: 指针值p{gen}
p{gen}计算. - 整个PGN网络的核心创新点就在于p{gen}
p{gen}的引入, 它使得网络具备了既可以从原始文本中copy words的能力, 也可以单词分布中generate words的能力.

p{gen}
p{gen}的本质是一个generation probability, 是一个介于[0, 1]之间的实数值.

p{gen}
p{gen}是通过context vector h_t
h_t, decoder state s_t
s_t, decoder input x_t
x_t, 三个部分共同计算出来的.
avatar

第六步: 最终分布张量P_w​
P_w​计算. - 有了第五步计算出的p{gen}
p{gen}, 就可以作为一个调节参数来决定最终的分布张量P_w
P_w.
avatar

解析PGN网路最终的分布公式
P_w
P_w
: 可以很清楚的展现PGN的核心思想, 模型可以权衡一个概率, 从source document中copy一个单词, 或者利用词表分布生成一个单词.

如果
w
w
是一个OOV单词(out-of-vocabulary), 公式左半部分的
P{vocab}(w)
P{vocab}(w)
等于0, 此时生成的概率分布完全取决于
w​
w​
在原始文本中的分布概率加和, 依然拥有被从原文本中copy到生成摘要的机会.

如果
w
w
不仅仅是一个OOV单词, 同时
w
w
从未在source document中出现过, 则公式的右半部分加和等于0, 这个时候模型不具备产生这样一个单词
w
w
的能力.

小节总结¶
seq2seq架构的缺点和改进点: - 第一: 重复! - 相同短语, 短句的重复. (这是经典seq2seq架构解决文本摘要问题所面对的最大痛点!!!)

第二: 不能准确再现事实细节! - 有的摘要很短却没有点明原文核心语义; 有的摘要很长却无效的重复.

第三: 无法处理OOV单词! - 所有不在单词映射字典中的单词都被UNK替代了.

seq2seq改进点: - 第一: 采用新的架构, 力求可以将描述原文细节的单词直接用到摘要中.

第二: 如果模型具备第一点的能力, 可以copy原始文本的单词, 那么将极大的解决OOV问题.

第三: 引入机制来控制和跟踪原始文本的重复范围, 减少生成摘要的重复问题.

PGN架构图: - 分析了经典论文中的架构图, 展示了模型的组成部分和运行机制.

PGN论文解读: - 细致的展现了原始论文的技术路线, 以及计算细节.

这些计算公式就是未来整个项目代码的基础.

4.2 PGN模型的数据处理
PGN数据预处理¶
学习目标¶
理解PGN模型应用在本项目时数据处理的特殊性.

掌握PGN模型的数据预处理.

原始数据预处理¶
第一轮数据处理¶
本项目中, 针对于第一轮数据处理, 要达到的目的是以下两点: - 原始数据挑选.

原始数据清洗, 过滤, 分词, 合并.

有了第二章, 第三章的基础铺垫, 在本章中, 重复性的数据预处理函数不做过多的解释, 直接拿来用就好. 主要完成3个代码文件的编写: - 第1步: 数据预处理配置文件config1.py

第2步: 多核多进程处理函数文件multi_proc_utils.py

第3步: 预处理代码文件preprocess.py

第1步: 数据预处理配置文件config1.py - 代码文件路径: /home/ec2-user/text_summary/pgn/utils/config1.py

import os

设置项目的root目录, 方便后续代码文件的导入

root_path = os.path.dirname(os.path.dirname(os.path.abspath(file)))

原始数据文本存储路径

train_raw_data_path = os.path.join(root_path, 'data', 'train.csv')
test_raw_data_path = os.path.join(root_path, 'data', 'test.csv')

停用词表和用户自定义字典的存储路径

stop_words_path = os.path.join(root_path, 'data', 'stopwords.txt')
user_dict_path = os.path.join(root_path, 'data', 'user_dict.txt')

预处理+切分后的训练测试数据路径

train_seg_path = os.path.join(root_path, 'data', 'train_seg_data.csv')
test_seg_path = os.path.join(root_path, 'data', 'test_seg_data.csv')

经过第一轮处理后的最终训练集数据和测试集数据

train_data_path = os.path.join(root_path, 'data', 'train.txt')
test_data_path = os.path.join(root_path, 'data', 'test.txt')

词向量模型路径

word_vector_path = os.path.join(root_path, 'data', 'wv', 'word2vec.model')
第2步: 多核多进程处理函数文件multi_proc_utils.py - 代码文件路径: /home/ec2-user/text_summary/pgn/utils/multi_proc_utils.py

import pandas as pd
import numpy as np
from multiprocessing import cpu_count, Pool

cpu 数量

cores = cpu_count()

分块个数

partitions = cores

def parallelize(df, func):
# 数据切分
data_split = np.array_split(df, partitions)
# 线程池
pool = Pool(cores)
# 数据分发 合并
data = pd.concat(pool.map(func, data_split))
# 关闭线程池
pool.close()
# 执行完close后不会有新的进程加入到pool,join函数等待所有子进程结束
pool.join()
return data
第3步: 预处理代码文件preprocess.py - 代码文件路径: /home/ec2-user/text_summary/pgn/utils/preprocess.py

导入若干工具包

import re
import jieba
import pandas as pd
import numpy as np
import os
import sys

设置项目的root目录, 方便后续相关代码包的导入

root_path = os.path.dirname(os.path.dirname(os.path.abspath(file)))
sys.path.append(root_path)

导入文本预处理的配置信息config1

from utils.config1 import *

导入多核CPU并行处理数据的函数

from utils.multi_proc_utils import *

jieba载入自定义切词表

jieba.load_userdict(user_dict_path)

根据max_len和vocab填充

def pad_proc(sentence, max_len, word_to_id):
# 1: 按空格统计切分出词
words = sentence.strip().split(' ')
# 2: 截取规定长度的词数
words = words[:max_len]
# 3: 填充
sentence = [w if w in word_to_id else '' for w in words]
# 4: 填充,
sentence = [''] + sentence + ['']
# 5: 判断长度, 填充
sentence = sentence + [''] * (max_len - len(words))
return ' '.join(sentence)

加载停用词

def load_stop_words(stop_word_path):
# stop_word_path: 停用词路径
# 打开文件
f = open(stop_word_path, 'r', encoding='utf-8')
# 读取所有行
stop_words = f.readlines()
# 去除每一个停用词前后的空格, 换行符
stop_words = [stop_word.strip() for stop_word in stop_words]
return stop_words

加载停用词

stop_words = load_stop_words(stop_words_path)

清洗文本, 删除特殊符号(被sentence_proc调用)

def clean_sentence(sentence):
# sentence: 待处理的字符串
if isinstance(sentence, str):
# 删除1. 2. 3. 这些标题
r = re.compile("\D(\d.)\D")
sentence = r.sub("", sentence)

    # 删除带括号的 进口 海外r = re.compile(r"[((]进口[))]|\(海外\)")sentence = r.sub("", sentence)# 删除除了汉字数字字母和,!?。.- 以外的字符r = re.compile("[^,!?。\.\-\u4e00-\u9fa5_a-zA-Z0-9]")# 用中文输入法下的,!?来替换英文输入法下的,!?sentence = sentence.replace(",", ",")sentence = sentence.replace("!", "!")sentence = sentence.replace("?", "?")sentence = r.sub("", sentence)# 删除--- 车主说, 技师说, 语音, 图片, 你好, 您好r = re.compile(r"车主说|技师说|语音|图片|你好|您好")sentence = r.sub("", sentence)return sentence
else:return ''

过滤一句切好词的话中的停用词(被sentence_proc调用)

def filter_stopwords(seg_list):
# seg_list: 切好词的列表 [word1 ,word2 .......]
# 首先去掉多余空字符
words = [word for word in seg_list if word]
# 去掉停用词
return [word for word in words if word not in stop_words]

预处理模块(处理一条句子, 被sentences_proc调用)

def sentence_proc(sentence):
# sentence:待处理字符串
# 清除无用词
sentence = clean_sentence(sentence)
# 切词, 默认精确模式, 全模式cut参数cut_all=True
words = jieba.cut(sentence)
# 过滤停用词
words = filter_stopwords(words)
# 拼接成一个字符串, 按空格分隔
return ' '.join(words)

预处理模块(处理一个句子列表, 对每个句子调用sentence_proc操作)

def sentences_proc(df):
# df: 数据集
# 批量预处理训练集和测试集
for col_name in ['Brand', 'Model', 'Question', 'Dialogue']:
df[col_name] = df[col_name].apply(sentence_proc)

if 'Report' in df.columns:# 训练集 Report 预处理df['Report'] = df['Report'].apply(sentence_proc)return df

用于数据加载+预处理(只需执行一次)

def build_dataset(train_raw_data_path, test_raw_data_path):
# 1. 加载原始数据
print('1. 加载原始数据.')
print(train_raw_data_path)
# 必须指定解码格式为utf-8
train_df = pd.read_csv(train_raw_data_path, engine='python', encoding='utf-8')
test_df = pd.read_csv(test_raw_data_path, engine='python', encoding='utf-8')
print('原始训练集行数 {}, 测试集行数 {}'.format(len(train_df), len(test_df)))
print('\n')

# 2. 空值去除(对于一行数据, 任意列只要有空值就去掉该行)
print('2. 空值去除(对于一行数据, 任意列只要有空值就去掉该行).')
train_df.dropna(subset=['Question', 'Dialogue', 'Report'], how='any', inplace=True)
test_df.dropna(subset=['Question', 'Dialogue'], how='any', inplace=True)
print('空值去除后训练集行数 {}, 测试集行数 {}'.format(len(train_df), len(test_df)))
print('\n')# 3. 多线程, 批量数据预处理(对每个句子执行sentence_proc, 清除无用词, 切词, 过滤停用词, 再用空格拼接为一个>字符串)
print('3. 多线程, 批量数据预处理(对每个句子执行sentence_proc, 清除无用词, 切词, 过滤停用词, 再用空格拼接为

一个字符串).')
train_df = parallelize(train_df, sentences_proc)
test_df = parallelize(test_df, sentences_proc)
print('\n')
print('sentences_proc has done!')

# 4. 合并训练测试集,用于训练词向量
print('4. 合并训练测试集, 用于训练词向量.')
# 新建一列,按行堆积
train_df['X'] = train_df[['Question', 'Dialogue']].apply(lambda x: ' '.join(x), axis=1)
train_df['Y'] = train_df[['Report']]
# 新建一列,按行堆积
test_df['X'] = test_df[['Question', 'Dialogue']].apply(lambda x: ' '.join(x), axis=1)
# 5. 保存分割处理好的train_seg_data.csv、test_set_data.csv
print('5. 保存处理好的train_seg_data.csv, test_set_data.csv.')
# 把建立的列merged去掉,该列对于神经网络无用,只用于训练词向量
train_df = train_df.drop(['Question'], axis=1)
train_df = train_df.drop(['Dialogue'], axis=1)
train_df = train_df.drop(['Brand'], axis=1)
train_df = train_df.drop(['Model'], axis=1)
train_df = train_df.drop(['Report'], axis=1)
train_df = train_df.drop(['QID'], axis=1)
test_df = test_df.drop(['Question'], axis=1)
test_df = test_df.drop(['Dialogue'], axis=1)
test_df = test_df.drop(['Brand'], axis=1)
test_df = test_df.drop(['Model'], axis=1)
test_df = test_df.drop(['QID'], axis=1)
# 将处理后的数据存入持久化文件
# train_df.to_csv(train_seg_path, index=None, header=True)
test_df.to_csv(test_seg_path, index=None, header=True)
train_df['data'] = train_df[['X', 'Y']].apply(lambda x: '<sep>'.join(x), axis=1)
train_df = train_df.drop(['X'], axis=1)
train_df = train_df.drop(['Y'], axis=1)
train_df.to_csv(train_seg_path, index=None, header=True)
print('The csv_file has saved!')
print('\n')
print('6. 后续工作是将第5步的结果文件进行适当处理, 并保存为.txt文件.')
print('本程序代码所有工作执行完毕!')

调用:

if name == 'main':
build_dataset(train_raw_data_path, test_raw_data_path)
cd /home/ec2-user/text_summary/pgn/utils

python preprocess.py
输出结果:

Building prefix dict from the default dictionary ...
Loading model from cache /tmp/jieba.cache
Loading model cost 0.831 seconds.
Prefix dict has been built successfully.

  1. 加载原始数据.
    /home/ec2-user/text_summary/pgn/data/train.csv
    原始训练集行数 82943, 测试集行数 20000

  2. 空值去除(对于一行数据, 任意列只要有空值就去掉该行).
    空值去除后训练集行数 82871, 测试集行数 20000

  3. 多线程, 批量数据预处理(对每个句子执行sentence_proc, 清除无用词, 切词, 过滤停用词, 再用空格拼接为一个字符串).

sentences_proc has done!
4. 合并训练测试集, 用于训练词向量.
5. 保存处理好的train_seg_data.csv, test_set_data.csv.
The csv_file has saved!

  1. 后续工作是将第5步的结果文件进行适当处理, 并保存为.txt文件.
    本程序代码所有工作执行完毕!
    结果文件查看:

cd /home/ec2-user/text_summary/pgn/data/

ll

在当前路径下, 多出了两个数据文件train_seg_data.csv和test_seg_data.csv

-rwxr-xr-x 1 ec2-user ec2-user 5211 2月 19 16:18 stopwords.txt
-rwxr-xr-x 1 ec2-user ec2-user 17514902 2月 19 16:18 test.csv
-rw-rw-r-- 1 ec2-user ec2-user 12593705 3月 6 08:34 test_seg_data.csv
-rwxr-xr-x 1 ec2-user ec2-user 82314291 2月 19 16:18 train.csv
-rw-rw-r-- 1 ec2-user ec2-user 59993863 3月 6 08:34 train_seg_data.csv
-rwxr-xr-x 1 ec2-user ec2-user 18103 2月 19 16:18 user_dict.txt
接下来要对结果文件做进一步的处理: - 代码文件路径: /home/ec2-user/text_summary/pgn/data/make_data.py

import os
import sys

打开最终的结果文件

train_writer = open('train.txt', 'w', encoding='utf-8')
test_writer = open('test.txt', 'w', encoding='utf-8')

对训练数据做处理, 将article和abstract中间用''分隔

n = 0
with open('train_seg_data.csv', 'r', encoding='utf-8') as f1:
for line in f1.readlines():
line = line.strip().strip('\n')
article, abstract = line.split('')
text = article + '' + abstract + '\n'
train_writer.write(text)
n += 1

print('train n=', n)
n = 0

对测试数据做处理, 仅将文件存储格式从.csv换成.txt

with open('test_seg_data.csv', 'r', encoding='utf-8') as f2:
for line in f2.readlines():
line = line.strip().strip('\n')
text = line + '\n'
test_writer.write(text)
n += 1

print('test n=', n)
调用:

cd /home/ec2-user/text_summary/pgn/data/

python make_data.py
输出结果:

train n= 82871
test n= 20000
注意: 我们需要明确一点, 这里面的test.txt中只有article, 没有abstract. 本质上来说不是真实的测试集. 因此我们需要从train.txt中分离出模型所需的训练集和测试集.

tail -n 12871 train.txt > dev.txt

head -n 70000 train.txt > train.txt
结果查看:

train.txt将作为模型真实的训练集, dev.txt将作为模型真实的测试集

-rw-rw-r-- 1 ec2-user ec2-user 50965199 2月 20 12:52 train.txt
-rw-rw-r-- 1 ec2-user ec2-user 9028642 2月 20 12:53 dev.txt
PGN数据的特殊性分析¶
在分析PGN数据前, 我们来看一下第三章中实现seq2seq架构时的数据迭代器代码:

训练批次数据生成器函数

def train_batch_generator(batch_size, max_enc_len=300, max_dec_len=50, sample_num=None):
# batch_size: batch大小
# max_enc_len: 样本最大长度
# max_dec_len: 标签最大长度
# sample_num: 限定样本个数大小

# 直接从已经预处理好的数据文件中加载训练集数据
train_X, train_Y = load_train_dataset(max_enc_len, max_dec_len)
# 对数据进行限定长度的切分
if sample_num:train_X = train_X[:sample_num]train_Y = train_Y[:sample_num]# 将numpy类型的数据转换为Pytorch下的tensor类型, 因为TensorDataset只接收tensor类型数据
x_data = torch.from_numpy(train_X)
y_data = torch.from_numpy(train_Y)# 第一步: 先对数据进行封装
dataset = TensorDataset(x_data, y_data)# 第二步: 再对dataset进行迭代器的构建
# 如果机器没有GPU, 请采用下面的注释行代码
# dataset = DataLoader(dataset, batch_size=batch_size, shuffle=True, drop_last=True)# 如果机器有GPU, 请采用下面的代码, 可以加速训练流程
dataset = DataLoader(dataset, batch_size=batch_size, shuffle=True, drop_last=True,num_workers=4, pin_memory=True)# 计算每个epoch要循环多少次
steps_per_epoch = len(train_X) // batch_size# 将封装好的数据集和次数返回
return dataset, steps_per_epoch

结论: 在seq2seq架构中, 我们只需要将处理好的x_data, y_data封装后, 直接传入DataLoader()即完成了数据迭代器的构造.

PGN数据的特殊性: 因为存在pointer, 在某些时候模型需要具备从source document中copy原文的能力. 也就是说即使某一个token属于OOV, 不在单词分布中, 模型"也能访问到这个token". - 第一: 构造数据迭代器的时候, 需要保存那些"属于source document, 但不属于word_to_id"的token.

第二: 考虑到每一条样本数据都会有"特有的OOV"词表, 这些特殊token的长度也应该被保存下来以供模型使用.

结论: 因为PGN中存在特殊要存储的数据字段, 就不能使用DataLoader中默认的处理方式, 后续需要对内部函数collate_fn做"个性化"处理.

工具函数代码¶
在实现PGN模型的数据迭代器之前, 需要若干工具函数, 按照第三章的开发套路, 一般工程上都会专门用一个代码文件存放这些工具函数, 以便未来修改, 添加, 删除, 实现工程代码的解耦.

本项目中所有相关的工具函数都放在func_utils.py文件中: - 代码文件路径: /home/ec2-user/text_summary/pgn/utils/func_utils.py

在整个代码文件func_utils.py中, 需要实现若干个函数, 接下来一个个实现:

1: 计量函数耗时的函数timer(module)

导入系统工具包

import os
import sys

设置项目的root路径, 方便后续相关代码文件的导入

root_path = os.path.dirname(os.path.dirname(os.path.abspath(file)))
sys.path.append(root_path)

导入项目中的工具包

import numpy as np
import time
import heapq
import random
import pathlib
from utils import config
import torch

函数耗时计量函数

def timer(module):
def wrapper(func):
# func: 一个函数名, 下面的计时函数就是计算这个func函数的耗时.
def cal_time( *args, **kwargs):
t1 = time.time()
res = func( *args, **kwargs)
t2 = time.time()
cost_time = t2 - t1
print(f'{cost_time} secs used for ', module)
return res
return cal_time
return wrapper
调用:

@timer(module='test a demo program')
def test():
s = 0
for i in range(100000000):
s += i
print('s = ', s)

if name == 'main':
test()
输出结果:

s = 4999999950000000
5.171767950057983 secs used for test a demo program
2: 对文本执行按空格切分的函数simple_tokenizer(text)

将一段文本按空格切分, 返回结果列表

def simple_tokenizer(text):
return text.split()
调用:

if name == 'main':
sentence = '技师说:你好!以前也出现过该故障吗?|技师说:缸压多少有没有测量一下?|车主说:没有过|车主说:没测缸压|技师说:测量一下缸压 看一四缸缸压是否偏低|车主说:用电脑测,只是14缸缺火|车主说:[语音]|车主说:[语音]|技师说:点火线圈 火花塞 喷油嘴不用干活 直接和二三缸对倒一下 跑一段在测量一下故障码进行排除|车主说:[语音]|>车主>说:[语音]|车主说:[语音]|车主说:[语音]|车主说:师傅还在吗|技师说:调一下喷油嘴 测一下缸压 都正常则为>发动机电脑板问题|车主说:[语音]|车主说:[语音]|车主说:[语音]|技师说:这个影响不大的|技师说:缸压八个以上正常|车主说:[语音]|技师说:所以说让你测量缸压 只要缸压正常则没有问题|车主说:[语音]|车主说:[语音]|技师说:可以点击头像关注我 有什么问题随时询问 一定真诚用心为你解决|车主说:师傅,谢谢了|技师说:不用客气'
res = simple_tokenizer(sentence)
print('res=', res)
print('res length = ', len(res))
输出结果:

res= ['技师说:你好!以前也出现过该故障吗?|技师说:缸压多少有没有测量一下?|车主说:没有过|车主说:没测缸压|技师说:测量一下缸压', '看一四缸缸压是否偏低|车主说:用电脑测,只是14缸缺火|车主说:[语音]|车主说:[语音]|技师说:点火线圈', '火花塞', '喷油嘴不用干活', '直接和二三缸对倒一下', '跑一段在测量一下故障码进行排除|车主说:[语音]|车主>说:[语音]|车主说:[语音]|车主说:[语音]|车主说:师傅还在吗|技师说:调一下喷油嘴', '测一下缸压', '都正常则为发动机电脑板问题|车主说:[语音]|车主说:[语音]|车主说:[语音]|技师说:这个影响不大的|技师说:缸压八个以上正常|车主说:[语音]|技师说:所以说让你测量缸压', '只要缸压正常则没有问题|车主说:[语音]|车主说:[语音]|技师说:可以点击头像关注我', '有什么问题随时询问', '一定真诚用心为你解决|车主说:师傅,谢谢了|技师说:不用客气']
res length = 11
3: 以字典统计文本中单词的数量函数count_words(counter, text)

以字典计数的方式统计一段文本中不同单词的数量

def count_words(counter, text):
for sentence in text:
for word in sentence:
counter[word] += 1
调用:

if name == 'main':
counter = Counter()
text = ['以前也出现过该故障吗?缸压多少有没有测量一下?', '14缸缺火点火线圈火花塞喷油嘴不用干活直接和二三缸对倒一下跑一段在测量一下故障码']
count_words(counter, text)
for w, c in counter.most_common(100):
print(w, ': ', c)
输出结果:

一 : 4
缸 : 3
下 : 3
火 : 3
故 : 2
障 : 2
有 : 2
测 : 2
量 : 2
以 : 1
前 : 1
也 : 1
出 : 1
现 : 1
过 : 1
该 : 1
吗 : 1
? : 1
压 : 1
多 : 1
少 : 1
没 : 1
? : 1
1 : 1
4 : 1
缺 : 1
点 : 1
线 : 1
圈 : 1
花 : 1
塞 : 1
喷 : 1
油 : 1
嘴 : 1
不 : 1
用 : 1
干 : 1
活 : 1
直 : 1
接 : 1
和 : 1
二 : 1
三 : 1
对 : 1
倒 : 1
跑 : 1
段 : 1
在 : 1
码 : 1
4: 对一个批次的数据按某一列长度进行排序的函数sort_batch_by_len(data_batch)

对一个批次batch_size个样本, 按照x_len字段长短进行排序, 并返回排序后的结果

def sort_batch_by_len(data_batch):
# 初始化一个结果字典, 其中包含的6个字段都是未来数据迭代器中的6个字段
res = {'x': [],
'y': [],
'x_len': [],
'y_len': [],
'OOV': [],
'len_OOV': []}

# 遍历批次数据, 分别将6个字段数据按照字典key值, 添加进各自的列表中
for i in range(len(data_batch)):res['x'].append(data_batch[i]['x'])res['y'].append(data_batch[i]['y'])res['x_len'].append(len(data_batch[i]['x']))res['y_len'].append(len(data_batch[i]['y']))res['OOV'].append(data_batch[i]['OOV'])res['len_OOV'].append(data_batch[i]['len_OOV'])# 以x_len字段大小进行排序, 并返回下标结果的列表
sorted_indices = np.array(res['x_len']).argsort()[::-1].tolist()# 返回的data_batch依然保持字典类型
data_batch = {name: [_tensor[i] for i in sorted_indices] for name, _tensor in res.items()}return data_batch

5: 原始文本映射成ids张量的函数source2ids(source_words, vocab)

原始文本映射成ids张量

def source2ids(source_words, vocab):
ids = []
oovs = []
unk_id = vocab.UNK
for w in source_words:
i = vocab[w]
if i == unk_id: # 如果w是OOV单词
if w not in oovs: # 将w添加进OOV列表中
oovs.append(w)
# 索引0对应第一个source document OOV, 索引1对应第二个source document OOV, 以此类推......
oov_num = oovs.index(w)
# 在本项目中索引vocab_size对应第一个source document OOV, vocab_size+1对应第二个source document OOV
ids.append(vocab.size() + oov_num)
else:
ids.append(i)
return ids, oovs
6: 摘要文本映射成数字化ids函数abstract2ids(abstract_words, vocab, source_oovs)

摘要文本映射成数字化ids张量

def abstract2ids(abstract_words, vocab, source_oovs):
ids = []
unk_id = vocab.UNK
for w in abstract_words:
i = vocab[w]
if i == unk_id: # 如果w是OOV单词
if w in source_oovs: # 如果w是source document OOV
# 对这样的w计算出一个新的映射id值
vocab_idx = vocab.size() + source_oovs.index(w)
ids.append(vocab_idx)
else: # 如果w不是一个source document OOV
ids.append(unk_id) # 对这样的w只能用UNK的id值来代替
else:
ids.append(i) # 如果w是词表中的单词, 直接取id值映射
return ids
7: 将输出张量ids结果映射成文本函数outputids2words(id_list, source_oovs, vocab)

将输出张量ids结果映射成自然语言文本

def outputids2words(id_list, source_oovs, vocab):
words = []
for i in id_list:
try:
# w可能是
w = vocab.index2word[i]
# w是OOV单词
except IndexError:
assert_msg = "Error: 无法在词典中找到该ID值."
assert source_oovs is not None, assert_msg
# 寄希望索引i是一个source document OOV单词
source_oov_idx = i - vocab.size()
try:
# 如果成功取到, 则w是source document OOV单词
w = source_oovs[source_oov_idx]
# i不仅是OOV单词, 也不对应source document中的原文单词
except ValueError:
raise ValueError('Error: 模型生成的ID: %i, 原始文本中的OOV ID: %i
但是当前样本中只有%i个OOVs'
% (i, source_oov_idx, len(source_oovs)))
# 向结果列表中添加原始字符
words.append(w)
# 空格连接成字符串返回
return ' '.join(words)
8: 向小顶堆数据结构添加元素的函数add2heap(heap, item, k)

创建小顶堆, 包含k个点的特殊二叉树, 始终保持二叉树中最小的值在root根节点

def add2heap(heap, item, k):
if len(heap) < k:
heapq.heappush(heap, item)
else:
heapq.heappushpop(heap, item)
9: 将文本中OOV单词替换成的函数replace_oovs(in_tensor, vocab)

将文本张量中所有OOV单词的id, 全部替换成对应的id

def replace_oovs(in_tensor, vocab):
# oov_token = torch.full(in_tensor.shape, vocab.UNK).long().to(config.DEVICE)
# 在Pytorch1.5.0以及更早的版本中, torch.full()默认返回float类型
# 在Pytorch1.7.0最新版本中, torch.full()会将bool返回成torch.bool, 会将integer返回成torch.long.
# 上面一行代码在Pytorch1.6.0版本中会报错, 因为必须指定成long类型, 如下面代码所示
oov_token = torch.full(in_tensor.shape, vocab.UNK, dtype=torch.long).to(config.DEVICE)

out_tensor = torch.where(in_tensor > len(vocab) - 1, oov_token, in_tensor)
return out_tensor

10: 获取模型中超参数信息的函数config_info(config)

获取模型训练中若干超参数信息

def config_info(config):
info = 'model_name = {}, pointer = {}, coverage = {}, fine_tune = {}, scheduled_sampling = {}, weight_tying = {},' + 'source = {} '
return (info.format(config.model_name, config.pointer, config.coverage, config.fine_tune, config.scheduled_sampling, config.weight_tying, config.source))
调用:

from utils import config

if name == 'main':
res = config_info(config)
print(res)
输出结果:

model_name = pgn_model, pointer = True, coverage = False, fine_tune = False, scheduled_sampling = False, weight_tying = False,source = train
PGN模型的数据迭代器预备函数和类¶
字典类构建¶
在数据准备阶段, 有一个重要的类, 就是数据字典vocab(起到的作用就是word_to_id):

代码文件路径: /home/ec2-user/text_summary/pgn/utils/vocab.py

导入工具包

import os
import sys

设置项目的root路径, 方便后续相关代码文件的导入

root_path = os.path.dirname(os.path.dirname(os.path.abspath(file)))
sys.path.append(root_path)

导入相关工具包

from collections import Counter
import numpy as np
import torch
import torch.nn as nn

如果采用预训练词向量的策略, 则导入相关配置和工具包

from utils.config import word_vector_model_path
from gensim.models import word2vec

词典类的创建

class Vocab(object):
PAD = 0
SOS = 1
EOS = 2
UNK = 3

def __init__(self):self.word2index = {}self.word2count = Counter()self.reserved = ['<PAD>', '<SOS>', '<EOS>', '<UNK>']self.index2word = self.reserved[:]# 如果预训练词向量, 则后续直接载入; 否则置为None即可self.embedding_matrix = None# 向类词典中增加单词
def add_words(self, words):for word in words:if word not in self.word2index:self.word2index[word] = len(self.index2word)self.index2word.append(word)# 因为引入Counter()工具包, 直接执行update()更新即可.self.word2count.update(words)# 如果已经提前预训练的词向量, 则执行类内函数对embedding_matrix赋值
def load_embeddings(self, word_vector_model_path):# 直接下载预训练词向量模型wv_model = word2vec.Word2Vec.load(word_vector_model_path)# 从模型中直接提取词嵌入矩阵self.embedding_matrix = wv_model.wv.vectors# 根据id值item读取字典中的单词
def __getitem__(self, item):if type(item) is int:return self.index2word[item]return self.word2index.get(item, self.UNK)# 获取字典的当前长度(等效于单词总数)
def __len__(self):return len(self.index2word)# 获取字典的当前单词总数
def size(self):return len(self.index2word)

调用:

if name == 'main':
vocab = Vocab()
print(vocab)
print('')
print(vocab.size())
print('
')
print(vocab.embedding_matrix)
输出结果:

<main.Vocab object at 0x7f65b8dd3b70>


4


None
创建相关类和函数¶
对于PGN的数据处理来说, 整个4.2小节最后的一个步骤就是构建数据迭代器的相关类和函数的创建.

代码文件路径: /home/ec2-user/text_summary/pgn/utils/dataset.py - 第一步: 创建(编码器, 解码器)数据对的类PairDataset()

第二步: 创建生成迭代器数据的类SampleDataset()

第三步: 创建"个性化"处理函数collate_fn()

第一步: 创建(编码器, 解码器)数据对的类PairDataset()

导入相关工具包

import sys
import os
from collections import Counter
import torch
from torch.utils.data import Dataset

设置项目的root路径, 方便后续相关代码文件的导入

root_path = os.path.dirname(os.path.dirname(os.path.abspath(file)))
sys.path.append(root_path)

导入项目中的自定义代码文件

from utils.func_utils import simple_tokenizer, count_words, sort_batch_by_len, source2ids, abstract2ids
from utils.vocab import Vocab
from utils import config

创建数据对的类

class PairDataset(object):
def init(self, filename, tokenize=simple_tokenizer, max_enc_len=None, max_dec_len=None,
truncate_enc=False, truncate_dec=False):
print("Reading dataset %s..." % filename, end=' ', flush=True)
self.filename = filename
self.pairs = []

    # 直接读取训练集数据文件, 切分成编码器数据, 解码器数据, 并做长度的统一with open(filename, 'r', encoding='utf-8') as f:next(f)for i, line in enumerate(f):# 在数据预处理阶段已经约定好了x, y之间以<SEP>分隔pair = line.strip().split('<SEP>')if len(pair) != 2:print("Line %d of %s is error formed." % (i, filename))print(line)continue# 前半部分是编码器数据, 即article原始文本.enc = tokenize(pair[0])if max_enc_len and len(enc) > max_enc_len:if truncate_enc:enc = enc[:max_enc_len]else:continue# 后半部分是解码器数据, 即abstract摘要文本dec = tokenize(pair[1])if max_dec_len and len(dec) > max_dec_len:if truncate_dec:dec = dec[:max_dec_len]else:continue# 以元组数据对的格式存储进结果列表中self.pairs.append((enc, dec))print("%d pairs." % len(self.pairs))# 构建模型所需的字典
def build_vocab(self, embed_file=None):# 对读取的文件进行单词计数统计word_counts = Counter()count_words(word_counts, [enc + dec for enc, dec in self.pairs])# 初始化字典类vocab = Vocab()# 如果有预训练词向量就直接加载, 如果没有则随着模型一起训练获取vocab.load_embeddings(embed_file)# 将计数得到的结果写入字典类中for word, count in word_counts.most_common(config.max_vocab_size):vocab.add_words([word])# 返回在vocab.py代码文件中定义的字典类结果return vocab

第二步: 创建生成迭代器数据的类SampleDataset()

直接为后续创建DataLoader提供服务的数据集预处理类

class SampleDataset(Dataset):
def init(self, data_pair, vocab):
self.src_sents = [x[0] for x in data_pair]
self.trg_sents = [x[1] for x in data_pair]
self.vocab = vocab
self._len = len(data_pair)

# 需要自定义__getitem__()取元素的函数
def __getitem__(self, index):# 调用工具函数获取输入x和oovx, oov = source2ids(self.src_sents[index], self.vocab)# 完全按照模型需求, 自定义返回格式, 共有6个字段, 每个字段"个性化定制"即可return {'x': [self.vocab.SOS] + x + [self.vocab.EOS],'OOV': oov,'len_OOV': len(oov),'y': [self.vocab.SOS] + abstract2ids(self.trg_sents[index], self.vocab, oov) + [self.vocab.EOS],'x_len': len(self.src_sents[index]),'y_len': len(self.trg_sents[index])}def __len__(self):return self._len

第三步: 创建"个性化"处理函数collate_fn()

创建DataLoader时自定义的数据处理函数

def collate_fn(batch):
# 按照最大长度的限制, 对张量进行填充0
def padding(indice, max_length, pad_idx=0):
pad_indice = [item + [pad_idx] * max(0, max_length - len(item)) for item in indice]
return torch.tensor(pad_indice)

# 对一个批次中的数据, 按照x_len字段进行排序
data_batch = sort_batch_by_len(batch)# 依次取得所需的字段, 作为构建DataLoader的返回数据, 本模型需要6个字段
x = data_batch['x']
x_max_length = max([len(t) for t in x])
y = data_batch['y']
y_max_length = max([len(t) for t in y])OOV = data_batch['OOV']
len_OOV = torch.tensor(data_batch['len_OOV'])x_padded = padding(x, x_max_length)
y_padded = padding(y, y_max_length)x_len = torch.tensor(data_batch['x_len'])
y_len = torch.tensor(data_batch['y_len'])return x_padded, y_padded, x_len, y_len, OOV, len_OOV

结论: 编写完上面的2个类 + 1个函数, 就可以针对PGN的模型特点来定制化提供特殊的数据格式, 未来就可以直接构建定制化的DataLoader了.

小节总结:¶
原始数据处理: - PGN数据的特殊性: 因为存在OOV的存储, 所以不能放弃source document.

增加了若干新的工具函数, 扩充知识库武器.

数据迭代器的预备函数和类: - 字典类Vocab的创建.

为了给PGN模型提供特殊的数据格式, 重写SampleDataset类.

自定义特殊的collate_fn()函数, 为自定的DataLoader提供个性化服务.

4.3 PGN实现模型
PGN模型的实现¶
学习目标¶
掌握PGN模型类的创建.

掌握对PGN实现文本摘要的训练过程.

掌握对PGN实现文本摘要的预测过程.

PGN模型类创建¶
PGN模型的构建也要分别编写几个子层类: - 第一步: 编码器类Encoder.

第二步: 注意力层类Attention.

第三步: 解码器类Decoder.

第四步: 降维加和类ReduceState.

第五步: 完整的PGN网络类.

五个步骤的全部代码都在model.py中: - 代码文件路径: /home/ec2-user/text_summary/pgn/src/model.py

第一步: 编码器类Encoder¶
编码器类Encoder的创建:

构建编码器类

class Encoder(nn.Module):
def init(self, vocab_size, embed_size, hidden_size, rnn_drop=0):
super(Encoder, self).init()
# 词嵌入层采用跟随模型一起训练的模式
self.embedding = nn.Embedding(vocab_size, embed_size)
self.hidden_size = hidden_size
# 编码器的主体采用单层, 双向LSTM结构
self.lstm = nn.LSTM(embed_size, hidden_size, bidirectional=True, dropout=rnn_drop, batch_first=True)

def forward(self, x):embedded = self.embedding(x)output, hidden = self.lstm(embedded)return output, hidden

第二步: 注意力层类Attention¶
注意力层类Attention的创建:

构建注意力类

class Attention(nn.Module):
def init(self, hidden_units):
super(Attention, self).init()
# 定义前向传播层, 对应论文中的公式1中的Wh, Ws
self.Wh = nn.Linear(2 * hidden_units, 2 * hidden_units, bias=False)
self.Ws = nn.Linear(2 * hidden_units, 2 * hidden_units)
# 定义全连接层, 对应论文中的公式1中最外层的v
self.v = nn.Linear(2 * hidden_units, 1, bias=False)

def forward(self, decoder_states, encoder_output, x_padding_masks):h_dec, c_dec = decoder_states# 将两个张量在最后一个维度拼接, 得到deocder state St: (1, batch_size, 2*hidden_units)s_t = torch.cat([h_dec, c_dec], dim=2)# 将batch_size置于第一个维度上: (batch_size, 1, 2*hidden_units)s_t = s_t.transpose(0, 1)# 按照hi的维度扩展St的维度: (batch_size, seq_length, 2*hidden_units)s_t = s_t.expand_as(encoder_output).contiguous()# 根据论文中的公式1来计算et, 总共有三步# 第一步: 分别经历各自的全连接层矩阵乘法# Wh * h_i: (batch_size, seq_length, 2*hidden_units)encoder_features = self.Wh(encoder_output.contiguous())# Ws * s_t: (batch_size, seq_length, 2*hidden_units)decoder_features = self.Ws(s_t)# 第二步: 两部分执行加和运算# (batch_size, seq_length, 2*hidden_units)attn_inputs = encoder_features + decoder_features# 第三步: 执行tanh运算和一个全连接层的运算# (batch_size, seq_length, 1)score = self.v(torch.tanh(attn_inputs))# 得到score后, 执行论文中的公式2# (batch_size, seq_length)attention_weights = F.softmax(score, dim=1).squeeze(2)# 添加一步执行padding mask的运算, 将编码器端无效的PAD字符全部遮掩掉attention_weights = attention_weights * x_padding_masks# 最整个注意力层执行一次正则化操作normalization_factor = attention_weights.sum(1, keepdim=True)attention_weights = attention_weights / normalization_factor# 执行论文中的公式3,将上一步得到的attention distributon应用在encoder hidden states上,得到context_vector# (batch_size, 1, 2*hidden_units)context_vector = torch.bmm(attention_weights.unsqueeze(1), encoder_output)# (batch_size, 2*hidden_units)context_vector = context_vector.squeeze(1)return context_vector, attention_weights

第三步: 解码器类Decoder¶
解码器类Decoder的创建:

class Decoder(nn.Module):
def init(self, vocab_size, embed_size, hidden_size, enc_hidden_size=None):
super(Decoder, self).init()
# 解码器端也采用跟随模型一起训练的方式, 得到词嵌入层
self.embedding = nn.Embedding(vocab_size, embed_size)
self.vocab_size = vocab_size
self.hidden_size = hidden_size

    # 解码器的主体结构采用单向LSTM, 区别于编码器端的双向LSTMself.lstm = nn.LSTM(embed_size, hidden_size, batch_first=True)# 因为要将decoder hidden state和context vector进行拼接, 因此需要3倍的hidden_size维度设置self.W1 = nn.Linear(self.hidden_size * 3, self.hidden_size)self.W2 = nn.Linear(self.hidden_size, vocab_size)if config.pointer:# 因为要根据论文中的公式8进行运算, 所谓输入维度上匹配的是4 * hidden_size + embed_sizeself.w_gen = nn.Linear(self.hidden_size * 4 + embed_size, 1)def forward(self, x_t, decoder_states, context_vector):# 首先计算Decoder的前向传播输出张量decoder_emb = self.embedding(x_t)decoder_output, decoder_states = self.lstm(decoder_emb, decoder_states)# 接下来就是论文中的公式4的计算.# 将context vector和decoder state进行拼接, (batch_size, 3*hidden_units)decoder_output = decoder_output.view(-1, config.hidden_size)concat_vector = torch.cat([decoder_output, context_vector], dim=-1)# 经历两个全连接层V和V1后,再进行softmax运算, 得到vocabulary distribution# (batch_size, hidden_units)FF1_out = self.W1(concat_vector)# (batch_size, vocab_size)FF2_out = self.W2(FF1_out)# (batch_size, vocab_size)p_vocab = F.softmax(FF2_out, dim=1)# 构造decoder state s_t.h_dec, c_dec = decoder_states# (1, batch_size, 2*hidden_units)s_t = torch.cat([h_dec, c_dec], dim=2)# p_gen是通过context vector h_t, decoder state s_t, decoder input x_t, 三个部分共同计算出来的.# 下面的部分是计算论文中的公式8.p_gen = Noneif config.pointer:# 这里面采用了直接拼接3部分输入张量, 然后经历一个共同的全连接层w_gen, 和原始论文的计算不同.# 这也给了大家提示, 可以提高模型的复杂度, 完全模拟原始论文中的3个全连接层来实现代码.x_gen = torch.cat([context_vector, s_t.squeeze(0), decoder_emb.squeeze(1)], dim=-1)p_gen = torch.sigmoid(self.w_gen(x_gen))return p_vocab, decoder_states, p_gen

第四步: 降维加和类ReduceState¶
降维加和类ReduceState的创建:

构造加和state的类, 方便模型运算

class ReduceState(nn.Module):
def init(self):
super(ReduceState, self).init()

def forward(self, hidden):h, c = hiddenh_reduced = torch.sum(h, dim=0, keepdim=True)c_reduced = torch.sum(c, dim=0, keepdim=True)return (h_reduced, c_reduced)

第五步: 完整的PGN网络类¶
PGN类的创建:

导入系统工具包

import os
import sys

设置项目的root路径, 方便后续相关代码文件的导入

root_path = os.path.dirname(os.path.dirname(os.path.abspath(file)))
sys.path.append(root_path)

导入若干工具包

import torch
import torch.nn as nn
import torch.nn.functional as F

导入项目中的相关代码文件

from utils import config
from utils.func_utils import timer, replace_oovs
from utils.vocab import Vocab

构建PGN类

class PGN(nn.Module):
def init(self, v):
super(PGN, self).init()
# 初始化字典对象
self.v = v
self.DEVICE = config.DEVICE

    # 依次初始化4个类对象self.attention = Attention(config.hidden_size)self.encoder = Encoder(len(v), config.embed_size, config.hidden_size)self.decoder = Decoder(len(v), config.embed_size, config.hidden_size)self.reduce_state = ReduceState()# 计算最终分布的函数
def get_final_distribution(self, x, p_gen, p_vocab, attention_weights, max_oov):if not config.pointer:return p_vocabbatch_size = x.size()[0]# 进行p_gen概率值的裁剪, 具体取值范围可以调参p_gen = torch.clamp(p_gen, 0.001, 0.999)# 接下来两行代码是论文中公式9的计算.p_vocab_weighted = p_gen * p_vocab# (batch_size, seq_len)attention_weighted = (1 - p_gen) * attention_weights# 得到扩展后的单词概率分布(extended-vocab probability distribution)# extended_size = len(self.v) + max_oovsextension = torch.zeros((batch_size, max_oov)).float().to(self.DEVICE)# (batch_size, extended_vocab_size)p_vocab_extended = torch.cat([p_vocab_weighted, extension], dim=1)# 根据论文中的公式9, 累加注意力值attention_weighted到对应的单词位置xfinal_distribution = p_vocab_extended.scatter_add_(dim=1, index=x, src=attention_weighted)return final_distributiondef forward(self, x, x_len, y, len_oovs, batch, num_batches, teacher_forcing):x_copy = replace_oovs(x, self.v)x_padding_masks = torch.ne(x, 0).byte().float()# 第一步: 进行Encoder的编码计算encoder_output, encoder_states = self.encoder(x_copy)decoder_states = self.reduce_state(encoder_states)# 初始化每一步的损失值step_losses = []# 第二步: 循环解码, 每一个时间步都经历注意力的计算, 解码器层的计算.# 初始化解码器的输入, 是ground truth中的第一列, 即真实摘要的第一个字符x_t = y[:, 0]for t in range(y.shape[1] - 1):# 如果使用Teacher_forcing, 则每一个时间步用真实标签来指导训练if teacher_forcing:x_t = y[:, t]x_t = replace_oovs(x_t, self.v)y_t = y[:, t + 1]# 通过注意力层计算context vectorcontext_vector, attention_weights = self.attention(decoder_states,encoder_output,x_padding_masks)# 通过解码器层计算得到vocab distribution和hidden statesp_vocab, decoder_states, p_gen = self.decoder(x_t.unsqueeze(1), decoder_states, context_vector)# 得到最终的概率分布final_dist = self.get_final_distribution(x,p_gen,p_vocab,attention_weights,torch.max(len_oovs))# 第t个时间步的预测结果, 将作为第t + 1个时间步的输入(如果采用Teacher-forcing则不同).x_t = torch.argmax(final_dist, dim=1).to(self.DEVICE)# 根据模型对target tokens的预测, 来获取到预测的概率if not config.pointer:y_t = replace_oovs(y_t, self.v)target_probs = torch.gather(final_dist, 1, y_t.unsqueeze(1))target_probs = target_probs.squeeze(1)# 将解码器端的PAD用padding mask遮掩掉, 防止计算loss时的干扰mask = torch.ne(y_t, 0).byte()# 为防止计算log(0)而做的数学上的平滑处理loss = -torch.log(target_probs + config.eps)# 先遮掩, 再添加损失值mask = mask.float()loss = loss * maskstep_losses.append(loss)# 第三步: 计算一个批次样本的损失值, 为反向传播做准备.sample_losses = torch.sum(torch.stack(step_losses, 1), 1)# 统计非PAD的字符个数, 作为当前批次序列的有效长度seq_len_mask = torch.ne(y, 0).byte().float()batch_seq_len = torch.sum(seq_len_mask, dim=1)# 计算批次样本的平均损失值batch_loss = torch.mean(sample_losses / batch_seq_len)return batch_loss

调用:

if name == 'main':
v = Vocab()
model = PGN(v)
print(model)
输出结果:

PGN(
(attention): Attention(
(Wh): Linear(in_features=1024, out_features=1024, bias=False)
(Ws): Linear(in_features=1024, out_features=1024, bias=True)
(v): Linear(in_features=1024, out_features=1, bias=False)
)
(encoder): Encoder(
(embedding): Embedding(4, 512)
(lstm): LSTM(512, 512, batch_first=True, bidirectional=True)
)
(decoder): Decoder(
(embedding): Embedding(4, 512)
(lstm): LSTM(512, 512, batch_first=True)
(W1): Linear(in_features=1536, out_features=512, bias=True)
(W2): Linear(in_features=512, out_features=4, bias=True)
(w_gen): Linear(in_features=2560, out_features=1, bias=True)
)
(reduce_state): ReduceState()
)
结论: 搭建完整的PGN网络后, 发现本质上就是4个子层共同构成了PGN. 参数量主要集中在Attention和Decoder.

PGN模型训练¶
完成了PGN模型类的构建后, 其实训练过程无非就是将数据迭代器DataLoader和模型类Model结合起来, 再加上"老三样"而已.

整个训练模型的相关代码需要完成3个文件的编写: - 第一步: 配置文件config.py

第二步: 评估代码evaluate.py

第三步: 训练代码train.py

第一步: 配置文件config.py¶
代码文件路径: /home/ec2-user/text_summary/pgn/utils/config.py

import torch
import os
import sys
root_path = os.path.dirname(os.path.dirname(os.path.abspath(file)))
sys.path.append(root_path)

神经网络通用参数

hidden_size = 512
dec_hidden_size = 512
embed_size = 512
pointer = True

模型相关配置参数

max_vocab_size = 20000
model_name = 'pgn_model'
embed_file = root_path + '/wv/word2vec_pad.model'
source = 'train'
train_data_path = root_path + '/data/train.txt'
val_data_path = root_path + '/data/dev.txt'
test_data_path = root_path + '/data/test.txt'
stop_word_file = root_path + '/data/stopwords.txt'
losses_path = root_path + '/data/loss.txt'
log_path = root_path + '/data/log_train.txt'
word_vector_model_path = root_path + '/wv/word2vec_pad.model'
encoder_save_name = root_path + '/saved_model/model_encoder.pt'
decoder_save_name = root_path + '/saved_model/model_decoder.pt'
attention_save_name = root_path + '/saved_model/model_attention.pt'
reduce_state_save_name = root_path + '/saved_model/model_reduce_state.pt'
model_save_path = root_path + '/saved_model/pgn_model.pt'
max_enc_len = 300
max_dec_len = 100
truncate_enc = True
truncate_dec = True

下面两个参数关系到predict阶段的展示效果, 需要按业务场景调参

min_dec_steps = 30

在Greedy Decode的时候设置为50

max_dec_steps = 50

在Beam-search Decode的时候设置为30

max_dec_steps = 30
enc_rnn_dropout = 0.5
enc_attn = True
dec_attn = True
dec_in_dropout = 0
dec_rnn_dropout = 0
dec_out_dropout = 0

训练参数

trunc_norm_init_std = 1e-4
eps = 1e-31
learning_rate = 0.001
lr_decay = 0.0
initial_accumulator_value = 0.1
epochs = 10
batch_size = 32
is_cuda = True

下面4个参数都是优化策略

coverage = False
fine_tune = False
scheduled_sampling = False
weight_tying = False

max_grad_norm = 2.0
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
LAMBDA = 1
第二步: 评估代码evaluate.py¶
代码文件路径: /home/ec2-user/text_summary/pgn/src/evaluate.py

导入工具包

import os
import sys
import torch
from tqdm import tqdm
import numpy as np
from torch.utils.data import DataLoader

设定项目的root路径, 方便后续代码文件的导入

root_path = os.path.dirname(os.path.dirname(os.path.abspath(file)))
sys.path.append(root_path)

导入项目的相关代码文件

from utils.dataset import collate_fn
from utils import config

编写评估函数

def evaluate(model, val_data, epoch):
print('validating')
val_loss = []
# 评估模型需要设定参数不变
with torch.no_grad():
DEVICE = config.DEVICE
# 创建数据迭代器, pin_memory=True是对于GPU机器的优化设置
# 为了PGN模型数据的特殊性, 传入自定义的collate_fn提供个性化服务
val_dataloader = DataLoader(dataset=val_data,
batch_size=config.batch_size,
shuffle=True,
pin_memory=True,
drop_last=True,
collate_fn=collate_fn)

    # 遍历测试集数据进行评估for batch, data in enumerate(tqdm(val_dataloader)):x, y, x_len, y_len, oov, len_oovs = dataif config.is_cuda:x = x.to(DEVICE)y = y.to(DEVICE)x_len = x_len.to(DEVICE)len_oovs = len_oovs.to(DEVICE)total_num = len(val_dataloader)loss = model(x, x_len, y, len_oovs, batch=batch, num_batches=total_num, teacher_forcing=True)val_loss.append(loss.item())
# 返回整个测试集的平均损失值
return np.mean(val_loss)

第三步: 训练代码train.py¶
代码文件路径: /home/ec2-user/text_summary/pgn/src/train.py

导入系统工具包

import pickle
import os
import sys

设置项目的root路径, 方便后续相关代码文件的导入

root_path = os.path.dirname(os.path.dirname(os.path.abspath(file)))
sys.path.append(root_path)

导入项目中用到的工具包

import numpy as np
from torch import optim
from torch.utils.data import DataLoader
import torch
from torch.nn.utils import clip_grad_norm_
from tqdm import tqdm
from tensorboardX import SummaryWriter

导入项目中自定义的代码文件, 类, 函数等

from src.model import PGN
from utils import config
from src.evaluate import evaluate
from utils.dataset import PairDataset, collate_fn, SampleDataset
from utils.func_utils import config_info

编写训练模型的主逻辑函数.

def train(dataset, val_dataset, v, start_epoch=0):
DEVICE = config.DEVICE

# 实例化PGN类对象并移动到GPU上(CPU).
model = PGN(v)
model.to(DEVICE)print("loading data......")
train_data = SampleDataset(dataset.pairs, v)
val_data = SampleDataset(val_dataset.pairs, v)print("initializing optimizer......")# 定义模型训练的优化器.
optimizer = optim.Adam(model.parameters(), lr=config.learning_rate)# 定义训练集的数据迭代器(这里用到了自定义的collate_fn以服务于PGN特殊的数据结构).
train_dataloader = DataLoader(dataset=train_data,batch_size=config.batch_size,shuffle=True,collate_fn=collate_fn)# 验证集上的损失值初始化为一个大整数.
val_losses = 10000000.0# SummaryWriter: 为服务于TensorboardX写日志的可视化工具.
writer = SummaryWriter(config.log_path)num_epochs =  len(range(start_epoch, config.epochs))# 训练阶段采用Teacher-forcing的策略
teacher_forcing = True
print('teacher_forcing = {}'.format(teacher_forcing))# 根据配置文件config.py中的设置, 对整个数据集进行一定轮次的迭代训练.
with tqdm(total=config.epochs) as epoch_progress:for epoch in range(start_epoch, config.epochs):# 每一个epoch之前打印模型训练的相关配置信息.print(config_info(config))# 初始化每一个batch损失值的存放列表batch_losses = []num_batches = len(train_dataloader)# 针对每一个epoch, 按batch读取数据迭代训练模型with tqdm(total=num_batches//100) as batch_progress:for batch, data in enumerate(tqdm(train_dataloader)):x, y, x_len, y_len, oov, len_oovs = dataassert not np.any(np.isnan(x.numpy()))# 如果配置有GPU, 则加速训练if config.is_cuda:x = x.to(DEVICE)y = y.to(DEVICE)x_len = x_len.to(DEVICE)len_oovs = len_oovs.to(DEVICE)# 设置模型进入训练模式(参数参与反向传播和更新)model.train()# "老三样"中的第一步: 梯度清零optimizer.zero_grad()# 调用模型进行训练并返回损失值loss = model(x, x_len, y,len_oovs, batch=batch,num_batches=num_batches,teacher_forcing=teacher_forcing)batch_losses.append(loss.item())# "老三样"中的第二步: 反向传播loss.backward()# 为防止梯度爆炸(gradient explosion)而进行梯度裁剪.clip_grad_norm_(model.encoder.parameters(), config.max_grad_norm)clip_grad_norm_(model.decoder.parameters(), config.max_grad_norm)clip_grad_norm_(model.attention.parameters(), config.max_grad_norm)# "老三样"中的第三步: 参数更新optimizer.step()# 每隔100个batch记录一下损失值信息.if (batch % 100) == 0:batch_progress.set_description(f'Epoch {epoch}')batch_progress.set_postfix(Batch=batch, Loss=loss.item())batch_progress.update()# 向tensorboard中写入损失值信息.writer.add_scalar(f'Average loss for epoch {epoch}',np.mean(batch_losses),global_step=batch)# 将一个轮次中所有batch的平均损失值作为这个epoch的损失值.epoch_loss = np.mean(batch_losses)epoch_progress.set_description(f'Epoch {epoch}')epoch_progress.set_postfix(Loss=epoch_loss)epoch_progress.update()# 结束每一个epoch训练后, 直接在验证集上跑一下模型效果avg_val_loss = evaluate(model, val_data, epoch)print('training loss:{}'.format(epoch_loss), 'validation loss:{}'.format(avg_val_loss))# 更新更小的验证集损失值evaluating loss.if (avg_val_loss < val_losses):torch.save(model.encoder, config.encoder_save_name)torch.save(model.decoder, config.decoder_save_name)torch.save(model.attention, config.attention_save_name)torch.save(model.reduce_state, config.reduce_state_save_name)torch.save(model.state_dict(), config.model_save_path)val_losses = avg_val_loss# 将更小的损失值写入文件中with open(config.losses_path, 'wb') as f:pickle.dump(val_losses, f)writer.close()

调用:

if name == 'main':
DEVICE = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
print('DEVICE: ', DEVICE)

# 构建训练用的数据集对
dataset = PairDataset(config.train_data_path,max_enc_len=config.max_enc_len,max_dec_len=config.max_dec_len,truncate_enc=config.truncate_enc,truncate_dec=config.truncate_dec)# 构建测试用的数据集对
val_dataset = PairDataset(config.val_data_path,max_enc_len=config.max_enc_len,max_dec_len=config.max_dec_len,truncate_enc=config.truncate_enc,truncate_dec=config.truncate_dec)# 创建模型的单词字典
vocab = dataset.build_vocab(embed_file=config.embed_file)# 调用训练函数进行训练并测试
train(dataset, val_dataset, vocab, start_epoch=0)

注意: 当前设置的batch_size = 64, 在本项目中报错, 说明16GB显存大小的GPU无法支持, 只能减小到batch_size = 32.

RuntimeError: CUDA out of memory. Tried to allocate 76.00 MiB (GPU 0; 14.76 GiB total capacity; 13.93 GiB already allocated; 9.75 MiB free; 13.99 GiB reserved in total by PyTorch)
输出结果:

DEVICCE: cuda
Reading dataset /home/ec2-user/text_summary/pgn/data/train.txt... 70000 pairs.
Reading dataset /home/ec2-user/text_summary/pgn/data/dev.txt...
0%| | 0/8 [00:00<?, ?it/s]

0%| | 0/2188 [00:00<?, ?it/s]

[[Epoch 0: 0%| | 0/21 [00:01<?, ?it/s]
[[Epoch 0: 0%| | 0/21 [00:01<?, ?it/s, Batch=0, Loss=5.64]
[[Epoch 0: 5%|▍ | 1/21 [00:01<00:32, 1.63s/it, Batch=0, Loss=5.64]

[[[[0%| | 1/2188 [00:01<59:29, 1.63s/it]
[[[[0%| | 2/2188 [00:03<57:50, 1.59s/it]
[[[[0%| | 3/2188 [00:04<53:04, 1.46s/it]
[[[[0%| | 4/2188 [00:06<1:03:04, 1.73s/it]
[[[[0%| | 5/2188 [00:08<1:01:03, 1.68s/it]

......
......
......

[[[[100%|█████████▉| 2186/2188 [1:00:34<00:03, 1.93s/it]
[[[[100%|█████████▉| 2187/2188 [1:00:35<00:01, 1.68s/it]
[[[[100%|██████████| 2188/2188 [1:00:35<00:00, 1.36s/it]
[[[[100%|██████████| 2188/2188 [1:00:35<00:00, 1.66s/it]
Epoch 0: : 22it [1:00:35, 165.27s/it, Batch=2100, Loss=3.46]
Epoch 0: 0%| | 0/8 [1:00:35<?, ?it/s]
Epoch 0: 0%| | 0/8 [1:00:35<?, ?it/s, Loss=3.49]
Epoch 0: 12%|█▎ | 1/8 [1:00:35<7:04:11, 3635.92s/it, Loss=3.49]
[[0%| | 0/402 [00:00<?, ?it/s]
[[0%| | 1/402 [00:00<02:20, 2.85it/s]
[[0%| | 2/402 [00:01<03:41, 1.81it/s]
[[1%| | 3/402 [00:02<03:51, 1.72it/s]
[[1%| | 4/402 [00:02<04:32, 1.46it/s]
[[1%| | 5/402 [00:03<04:49, 1.37it/s]
[[1%|▏ | 6/402 [00:04<04:02, 1.63it/s]^[[A

......
......
......

[[[[100%|█████████▉| 2186/2188 [1:00:50<00:03, 1.61s/it]
[[[[100%|█████████▉| 2187/2188 [1:00:51<00:01, 1.57s/it]
[[[[100%|██████████| 2188/2188 [1:00:52<00:00, 1.41s/it]
[[[[100%|██████████| 2188/2188 [1:00:52<00:00, 1.67s/it]
Epoch 7: : 22it [1:00:52, 166.03s/it, Batch=2100, Loss=1.22]
Epoch 7: 88%|████████▊ | 7/8 [8:33:08<1:04:10, 3850.63s/it, Loss=1.34]
Epoch 7: 88%|████████▊ | 7/8 [8:33:08<1:04:10, 3850.63s/it, Loss=1.22]
Epoch 7: 100%|██████████| 8/8 [8:33:08<00:00, 3860.46s/it, Loss=1.22]
[[0%| | 0/402 [00:00<?, ?it/s]
[[0%| | 1/402 [00:00<05:03, 1.32it/s]
[[0%| | 2/402 [00:01<04:27, 1.49it/s]
[[1%| | 3/402 [00:01<03:59, 1.67it/s]
[[1%| | 4/402 [00:02<03:46, 1.75it/s]
[[1%| | 5/402 [00:02<03:41, 1.79it/s]
[[1%|▏ | 6/402 [00:03<03:39, 1.80it/s]
[[2%|▏ | 7/402 [00:03<03:22, 1.95it/s]
[[2%|▏ | 8/402 [00:04<03:17, 1.99it/s]

......
......
......

[[99%|█████████▊| 396/402 [03:46<00:03, 1.87it/s]
[[99%|█████████▉| 397/402 [03:46<00:02, 1.96it/s]
[[99%|█████████▉| 398/402 [03:47<00:02, 1.92it/s]
[[99%|█████████▉| 399/402 [03:48<00:01, 1.57it/s]
[[100%|█████████▉| 400/402 [03:48<00:01, 1.74it/s]
[[100%|█████████▉| 401/402 [03:49<00:00, 1.90it/s]
[[100%|██████████| 402/402 [03:49<00:00, 2.11it/s]
[[100%|██████████| 402/402 [03:49<00:00, 1.75it/s]
^MEpoch 7: 100%|██████████| 8/8 [8:36:57<00:00, 3877.20s/it, Loss=1.22]
12870 pairs.
loading data......
initializing optimizer......
model_name = pgn_model, pointer = True, coverage = False, fine_tune = False, scheduled_sampling = False, weight_tying = False,source = train
teacher_forcing = True
validating
training loss:3.4892952252567575 validation loss:3.344072110024258
model_name = pgn_model, pointer = True, coverage = False, fine_tune = False, scheduled_sampling = False, weight_tying = False,source = train
teacher_forcing = True
validating
training loss:2.835234389444592 validation loss:3.267519224342422
model_name = pgn_model, pointer = True, coverage = False, fine_tune = False, scheduled_sampling = False, weight_tying = False,source = train
teacher_forcing = True
validating
training loss:2.4213740300434816 validation loss:3.3334008900087273
model_name = pgn_model, pointer = True, coverage = False, fine_tune = False, scheduled_sampling = False, weight_tying = False,source = train
teacher_forcing = True
validating
training loss:2.0445967139012198 validation loss:3.5206758821781596
model_name = pgn_model, pointer = True, coverage = False, fine_tune = False, scheduled_sampling = False, weight_tying = False,source = train
teacher_forcing = True
validating
training loss:1.7436198293617917 validation loss:3.7173273100781796
model_name = pgn_model, pointer = True, coverage = False, fine_tune = False, scheduled_sampling = False, weight_tying = False,source = train
teacher_forcing = True
validating
training loss:1.516176729386424 validation loss:3.9629455300705945
model_name = pgn_model, pointer = True, coverage = False, fine_tune = False, scheduled_sampling = False, weight_tying = False,source = train
teacher_forcing = True
validating
training loss:1.3425214516724266 validation loss:4.164657424338421
model_name = pgn_model, pointer = True, coverage = False, fine_tune = False, scheduled_sampling = False, weight_tying = False,source = train
teacher_forcing = True
validating
training loss:1.215759369011771 validation loss:4.379757683075483
查看GPU的运行信息: nvidia-smi

+-----------------------------------------------------------------------------+
| NVIDIA-SMI 450.51.05 Driver Version: 450.51.05 CUDA Version: 11.0 |
|-------------------------------+----------------------+----------------------+
| GPU Name Persistence-M| Bus-Id Disp.A | Volatile Uncorr. ECC |
| Fan Temp Perf Pwr:Usage/Cap| Memory-Usage | GPU-Util Compute M. |
| | | MIG M. |
|=++==============|
| 0 Tesla T4 On | 00000000:00:08.0 Off | 0 |
| N/A 68C P0 68W / 70W | 14336MiB / 15109MiB | 95% Default |
| | | N/A |
+-------------------------------+----------------------+----------------------+

+-----------------------------------------------------------------------------+
| Processes: |
| GPU GI CI PID Type Process name GPU Memory |
| ID ID Usage |
|=============================================================================|
| 0 N/A N/A 13392 C python 14333MiB |
+-----------------------------------------------------------------------------+
模型训练结论: 随着训练的进行, 训练集损失值一路下降, 但是注意到在关键的验证集上, 我们的最小损失值在第2个epoch出现, 后续反倒一路上升, 可以认为出现了过拟合的现象. 因此在当前任务, 当前数据集的情况下, 后续保持3-4个epoch训练模型即可.

PGN模型预测¶
当PGN模型训练结束后, 就可以在测试集上进行预测了. - 代码文件路径: /home/ec2-user/text_summary/pgn/src/predict.py

导入工具包

import random
import os
import sys
import torch
import jieba

设定项目的root路径, 方便后续相关代码文件的导入

root_path = os.path.dirname(os.path.dirname(os.path.abspath(file)))
sys.path.append(root_path)

导入项目的相关代码文件

from utils import config
from src.model import PGN
from utils.dataset import PairDataset
from utils.func_utils import source2ids, outputids2words, timer, add2heap, replace_oovs

构建预测类

class Predict():
@timer(module='initalize predicter')
def init(self):
self.DEVICE = config.DEVICE

    dataset = PairDataset(config.train_data_path,max_enc_len=config.max_enc_len,max_dec_len=config.max_dec_len,truncate_enc=config.truncate_enc,truncate_dec=config.truncate_dec)self.vocab = dataset.build_vocab(embed_file=config.embed_file)self.model = PGN(self.vocab)self.stop_word = list(set([self.vocab[x.strip()] for x in open(config.stop_word_file).readlines()]))# 导入已经训练好的模型, 并转移到GPU上.self.model.load_state_dict(torch.load(config.model_save_path))self.model.to(self.DEVICE)def greedy_search(self, x, max_sum_len, len_oovs, x_padding_masks):encoder_output, encoder_states = self.model.encoder(replace_oovs(x, self.vocab))# 用encoder的hidden state初始化decoder的hidden statedecoder_states = self.model.reduce_state(encoder_states)# 利用SOS作为解码器的初始化输入字符x_t = torch.ones(1) * self.vocab.SOSx_t = x_t.to(self.DEVICE, dtype=torch.int64)summary = [self.vocab.SOS]# 循环解码, 最多解码max_sum_len步while int(x_t.item()) != (self.vocab.EOS) and len(summary) < max_sum_len:context_vector, attention_weights = self.model.attention(decoder_states,encoder_output,x_padding_masks)p_vocab, decoder_states, p_gen = self.model.decoder(x_t.unsqueeze(1),decoder_states,context_vector)final_dist = self.model.get_final_distribution(x, p_gen, p_vocab,attention_weights,torch.max(len_oovs))# 以贪心解码策略预测字符x_t = torch.argmax(final_dist, dim=1).to(self.DEVICE)decoder_word_idx = x_t.item()# 将预测的字符添加进结果摘要中summary.append(decoder_word_idx)x_t = replace_oovs(x_t, self.vocab)return summary@timer(module='doing prediction')
def predict(self, text, tokenize=True):if isinstance(text, str) and tokenize:text = list(jieba.cut(text))# 将原始文本映射成数字化张量x, oov = source2ids(text, self.vocab)x = torch.tensor(x).to(self.DEVICE)# 获取OOV的长度和padding mask张量len_oovs = torch.tensor([len(oov)]).to(self.DEVICE)x_padding_masks = torch.ne(x, 0).byte().float()# 利用贪心解码函数得到摘要结果.summary = self.greedy_search(x.unsqueeze(0),max_sum_len=config.max_dec_steps,len_oovs=len_oovs,x_padding_masks=x_padding_masks)# 将得到的摘要数字化张量转换成自然语言文本summary = outputids2words(summary, oov, self.vocab)# 删除掉特殊字符<SOS>和<EOS>return summary.replace('<SOS>', '').replace('<EOS>', '').strip()

调用:

if name == "main":
print('实例化Predict对象, 构建dataset和vocab......')
pred = Predict()
print('vocab_size: ', len(pred.vocab))
# Randomly pick a sample in test set to predict.
with open(config.val_data_path, 'r') as test:
picked = random.choice(list(test))
source, ref = picked.strip().split('')

print('source: ', source, '\n')
print('---------------------------------------------------------------')
print('ref: ', ref, '\n')
print('---------------------------------------------------------------')
greedy_prediction = pred.predict(source.split())
print('greedy: ', greedy_prediction, '\n')

输出结果:

实例化Predict对象, 构建dataset和vocab......
Reading dataset /home/ec2-user/text_summary/pgn/data/train.txt... 70000 pairs.
9.547770738601685 secs used for initalize predicter
vocab_size: 20004
source: 18 款 斯柯达 明锐 旅行 版 , 通电 时 发动机 舱内 会 呜呜 响 , 重新 通电 后 异响 消失 。 280tsi 豪华版 , 车主 。 异响 持续 时间 不会 很长 , ? 属于 用电器 接通 电源 工作 声音 , 仪表 里面 后面 汽油 泵 发出 。 响声 会 车 打着 以后 存在 , 存在 , 建议您 去 检查一下 , 看 是不是 接通 电源 之后 , 用电器 工作 引起 共振 。 位置 打开 引擎盖 看 一下 。 加 冷却液 下面 一点 位置 检测 状态 , 属于 正常 , 持续时间 5 10 秒 冷却液 下面 一点 位置 , 没 问题 。 响 不会 响去 检查 检查 不 出来 大众 斯 科达 现在 新车 , 冷却液 基本 都 下 刻度 线下 一点点 。 , 上面 操作 一下 试试 。 加 冷却液 地方 下面 一点 地方 响用 扎 带 扎起来 。 EA211 发动机 很多 都 地方 共振 , 试试 。 看到 我发 。 意思 管子 响 ? 看到 谢谢 客气 , 车 扎起来 。 管子 发出 声音 ? 管子 产生 共振 。 有时候 响 , 有时候 不响 , 响 关了 电门 重新 通电 以后 不会 响 感觉 电流 声放 不 方便 加 一下 微信 录音 听听 , 响 间歇性 。 好 , 谢谢 请问 一下 换 机油 时 换 30 40 比较 好 一点 跑 一万八千 公里 差不多 都 加 0.5 升 机油 跑 八千 公里 不是 一万八千 新车 建议 使用 30 , 粘稠度 低 一点 , 增加 发动机 润滑 , 降低 发动机 磨损 平台 相关 规定 , 无法 留下 联系方式 地址 , 请 谅解 8000 公里 消耗 0.6 升 机油 算 正常 ? 不 , 1.41 . 4T 高功率 发动机 , 没有 出现 烧 机油 现象 。 机油 冷车 看 热车 看 ? 冷车 看 全部 看 都 冷车 看 加个 weixin ? 973788701 冷车 看 , 机油 消耗 不 正常 。 这种 办法 维权 ? 去 检测 , 检测 问题 , 免费 维修 更换 发动机 。 下次 保养 4s店 称重 ? 说 出现 烧 机油 现象 , 师傅 会 做 相关 检测 。 上次 说 4s店 一点 , 不够 加点 。 。 。 。 去 ! 8000km 加 0.6 L 机油 。 都 不到 机油 尺 目前 8000km 目前 车子 开 8000km ? 总共 跑 一万二 。 首保 过后 跑 八千 不到 点 ! 想会 不会 30 机油 太稀 容易 蒸发 EA211 涡轮 增压 发动机 烧 机油 可能性 很小 , 加 发动机 抗磨损 剂 使用 。 30 机油 , 嘉实多 极护 5w 30 。 我用 4s店 极护 感觉 有点 怪 , 正面 英文版 进口 。 问 说 厂家 发 背面 中文 不是 进口 中文 标签 纸 ? 不是 师傅 发现 灯 响 副 驾 自动 头灯 开着 会响 。 关 以后 重新 打开 不响 原车 氙气灯 ? 原车 led 没有 改过 原车 led LED 灯 灯光 响 , 灯头 问题 , BCM 控制器 控制 大灯 参数 问题 。 无论是 一种 , 都 需要 检查 。 好 谢谢 。 客气 。


ref: 描述 对话 , 建议 检查 副 水壶 下面 油管 , 很多 EA211 发动机 油管 都 震动 问题 。 灯光 灯头 声音 , 建议 检查 灯头 BCM 控制器 。


0.14189767837524414 secs used for doing prediction
greedy: , BCM 发动机 内部 , 电源 , 引起 , 需要 检查 发动机 是否 烧 机油 , 建议 更换 。 , 建议 使用 30 , 粘稠度 低 , 增加 发动机 烧 机油 。 降低 油耗 。 增加 发动机 烧 机油 。 降低 油耗 。 消耗 , 建议 去 修理厂 检查 , 质量 问题 , 免费 保修
模型预测结论: PGN模型的预测效果相比于seq2seq已经有了很大的进步, 但是重复描述同一个短语的问题依然还在, 需要后续的进一步优化.

小节总结¶
小节总结: 4.3小节实现了PGN模型的搭建, 并完整的训练出baseline-2模型, 效果得到了很大的改进, 但仍有不足.

5.1 文本摘要评估方法
文本摘要评估方法¶
学习目标¶
理解文本自动摘要的常用评估方法.

理解什么是ROUGE评估算法.
avatar

文本摘要的常用评估方法¶
首先回顾一下之前在NLP基础课, 项目课中用到的评估手段. - 最主要的就是预测值一对一的和真实标签直接比较, 比如分类问题, 比如char-to-char文本生成问题.

再就是NER任务中, 需要"多个位置匹配"才能得到"一个标签匹配"的效果.

一般用C表示机器翻译的译文, 或文本摘要的摘要, 另外人工提供m个参考的译文或摘要S1, S2, ..., Sm. 评价指标就是衡量C和S1, S2, ..., Sm的匹配程度.

文本摘要的常用评估方法有两种: - 内部评价方法(Intrinsic): 直接分析摘要的质量来进行评价. - 标准: 信息量, 连贯性, 可读性, 长度, 冗余度.
- 优点: 直接对摘要内容进行评价, 独立于应用环境.
- 缺点: 人工评价的主观性, 不准确性, 代价太大.

外部评价方法(Extrinsic): 间接评价, 将摘要应用于某一个特殊任务中, 根据摘要完成这项特殊任务的效果来评价文本摘要的表现. - 标准: 检索的准确度, 分类的准确度.

优点: 较少主观性, 易与多个摘要系统进行对比.

缺点: 每次测评只是针对一个特定任务, 有一定的局限性, 不利于系统性能的全面改进.

对于更普遍的机器翻译, 文本摘要等任务, 需要有不一样的评估方法, 最主要的有如下两种: - BLEU评估方法(2002年).

ROUGE评估方法(2003年).

BLEU评估方法¶
BLEU全称是Bilingual evaluation understudy(双语评估替换研究), BLEU的分数取值范围是[0, 1], 所谓的understudy是指"代替人工完成结果的评估", 主要针对机器翻译和文本摘要等生成式模型任务.

BLEU的核心点: 是衡量一个有多个正确输出结果的模型的精确度的评估指标!

BLEU指标: - BLEU分数值范围是[0.0, 1.0]: - 如果两个句子完美匹配(perfect match), 则BLEU取值为1.0
- 如果两个句子完美不匹配(perfect mismatch), 则BLEU取值为0.0

BLEU指标的5个优点(compelling benefits): - 计算代价小, 速度快.

指标意义容易理解.

语言无关性.

与人工评价结果高度相关.

被学术界和工业界广泛采用.

注意: BLEU的实现方法时分别计算candidate sentences和reference sentence的n-grams模型, 然后统计匹配的单词或短语个数. 因此BLEU显然与句子语序无关!!!

BLEU评估思想: 机器生成的文本摘要结果越接近专业人工给出的摘要结果, 就认为越好. BLEU算法实际上做的事情, 就是判断两个文本语句的相似程度. 如果我们想知道人工给出的摘要文本的语义和要模型给出的摘要文本的语义是否一致, 这个任务显然无法直接比较, 因为机器并不懂人类的语义! 那我们退而求其次, 用这两个结果比较相似性, 如果语义很相似, 那么也可以认为模型表现好. BLEU的做法是: 一段模型给出的摘要与对应的几条人工摘要文本作比较, 算出来一个综合的分数. 这个综合分数越高说明模型的摘要表现越好.

BLEU算法解析¶
BLEU既然是一个算法, 主要计算精确度(Precision), 那么就一定有计算公式:
avatar

上述图中公式变量的解析: - Pn: n-gram的精确率.

Wn: n-gram的权重, 一般设置为均匀权重, 即对于任意n, 都有Wn = 1/n.

BP: 惩罚因子, 如果模型摘要的长度小于最短的参考摘要, 则BP < 1.

BLEU中的1-gram精确率表示模型摘要忠于人工摘要的程度, 2-gram, 3-gram等等表示模型摘要的语义流畅程度.

关于n-gram精确率的计算: 机器摘要中的n-gram单元在人工摘要中出现的次数.

假设有两段文本C和S1如下: - 机器摘要C: a cat is on the table

人工摘要S1: there is a cat on the table

则可以根据规则计算出1-gram, 2-gram的准确率如下所示:
avatar

但是如上所计算出来的precision存在一些问题, 有时候还很严重, 比如: - 机器摘要C: there there there there there

人工摘要S1: there is a cat on the table

那么按照前面的计算规则, 可以得到1-gram的precision等于1, 这显然不正确!

惩罚因子: BLEU计算规则容易导致上面的问题, 那就是模型摘要较短时, 反倒容易造成分数较高. 但是这里面会损失很多信息, 甚至根本无法表达核心语义. 比如: - 机器摘要C: a cat

人工摘要S1: there is a cat on the table

因此引入了惩罚因子计算规则:
avatar

ROUGE评估方法¶
ROUGE的全称是Recall-Oriented Understudy for Gisting Evaluation, 是一种基于召回率指标的评价算法.

ROUGE核心思想: 由多个专家分别生成人工摘要, 构成标准摘要集. 将模型生成的自动摘要和人工摘要做对比, 通过统计两者之间重叠的基本单元的数量, 来评价模型摘要的表现. 通过多条人类专家的摘要做对比, 提高了评价系统的稳定性和健壮性.

最主流的评价标准有两个: - ROUGE-N评价: 主要统计n-gram上的召回率, 比较人工摘要和模型摘要分别计算n-gram的结果.

ROUGE-L评价: L指最长公共子序列(Longest Common Subsequence, LCS), 比较人工摘要和模型摘要的最长公共子序列.

ROUGE算法解析¶
ROUGE-N算法:
avatar

上图中公式的分母是统计在人工摘要中的n-gram数量, 分子是统计模型摘要和人工摘要中共同拥有的n-gram数量.

还是这个例子, 有两段文本C和S1如下: - 机器摘要C: a cat is on the table

人工摘要S1: there is a cat on the table

则分别按照公式计算ROUGE-1, ROUGE-2的分数如下图所示:
avatar

如果摘要集中有多个人工参考摘要, 假设有m个参考摘要S1, S2, ..., Sm, ROUGE-N会分别计算模型摘要和参考摘要的分数, 并取最大值作为当前模型摘要的ROUGE-N分数, 计算规则如下:
avatar

ROUGE-N算法的优缺点: - 优点: 直观, 简洁, 能反应文本的词序.

缺点: 区分度不高, 且当N > 3时, ROUGE-N值通常很小.

ROUGE-L算法:
avatar

上图公式变量解析: - R_LCS: 表示召回率.

P_LCS: 表示精确率.

F_LCS: 表示ROUGE-L分数.

做公式的变形后如下图:
avatar

上图很清晰的展示了这条规律: 一般在实践中, 会将beta设置为很大的数值, 因此第一项可以忽略, ROUGE-L分数几乎只考虑"召回率"指标!

还是这个例子, 有两段文本C和S1如下: - 机器摘要C: a cat is on the table

人工摘要S1: there is a cat on the table

按照公式R_LCS计算, 分母len(S1) = 7, 分子LCS(C, S1) = 5 (a cat on the table), 因此R_LCS = 5/7, 即ROUGE-L分数等于5/7.

注意: 计算ROUGE-L时, 不要求词的连续匹配, 只要求按词的顺序匹配即可. 这也是子序列和子串的区别!

介绍完了ROUGE-N, ROUGE-L算法后, 我们再考虑一个例子, 来看看这种主流算法的缺陷:

这个例子中有如下4段文本: - 人工摘要 S1: police killed the gunman

人工摘要 S2: the gunman was shot down by police

机器摘要 C1: police ended the gunman

机器摘要 C2: the gunman murdered police

ROUGE-N计算: - C1的ROUGE-1 = (3 + 3)/(4 + 7) = 6/11

C2的ROUGE-1 = (3 + 3)/(4 + 7) = 6/11

C1的ROUGE-2 = (1 + 1)/(3 + 6) = 2/9

C2的ROUGE-2 = (1 + 1)/(3 + 6) = 2/9

从上面的计算结果很清楚的显示, 机器摘要C1和C2, 在和两条人工摘要对比后, 得到了完全一样的分数, 从自动评估的角度看两条摘要语义结果一致. 但是从人类语义的角度看, C1和C2明显是不同的意思!

ROUGE-L计算: - (C1, S1)的ROUGE-L = ¾ (LCS: police the gunman)

(C2, S1)的ROUGE-L = 2/4 (LCS: the gunman)

(C1, S2)的ROUGE-L = 2/7 (LCS: the gunman)

(C2, S2)的ROUGE-L = 3/7 (LCS: the gunman police)

从上面的计算结果可知, C1的两条结果平均值是¾ + 2/7 = 29/56, C2的两条结果平均值是2/4 + 3/7 = 26/56, 从而知道C1 > C2, 可以认为机器摘要C1的结果比C2更好. 从人类语义的角度看, C1的确更真实的反应了原文的核心语义.

ROUGE-L算法的优缺点: - 优点: 不要求词的连续匹配, 只要求按词的出现顺序匹配即可, 能够像n-gram一样反应句子级别的词序. 自动匹配最长公共子序列, 不需要预定义n-gram的长度超参数.

缺点: 只计算一个最长子序列, 最终的值忽略了其他备选的最长子序列及较短子序列的特征和影响.

小节总结¶
常用评估方法¶
文本摘要的常用评估方法: - 宏观角度: 内部评价, 外部评价.

内部评价方法: 直接分析摘要的质量来进行评价. - 标准: 信息量, 连贯性, 可读性, 长度, 冗余度.

优点: 直接对摘要内容进行评价, 独立于应用环境.

缺点: 人工评价的主观性, 不准确性, 代价太大.

外部评价方法: 间接评价, 将摘要应用于某一个特殊任务中, 根据摘要完成这项特殊任务的效果来评价文本摘要的表现. - 标准: 检索的准确度, 分类的准确度.

优点: 较少主观性, 易与多个摘要系统进行对比.

缺点: 每次测评只是针对一个特定任务, 有一定的局限性, 不利于系统性能的全面改进.

主流算法¶
主流算法主要由BLEU算法, ROUGE算法. - BLEU算法: Bilingual evaluation understudy(双语评估替换研究) - BLEU分数值范围是[0.0, 1.0], 0.0最差, 1.0最佳.
- BLEU评估思想: 机器生成的文本摘要结果越接近专业人工给出的摘要结果, 就认为越好.
- BLEU的计算结果与句子语序无关.
- BLEU主要计算精确度(Precision).
- n-gram精确率的计算: 机器摘要中的n-gram单元在人工摘要中出现的次数.
- 为了弥补算法的缺陷, 引入多条人工摘要对比, 引入惩罚因子.

ROUGE算法: Recall-Oriented Understudy for Gisting Evaluation - ROUGE-N评价: 主要统计n-gram上的召回率, 比较人工摘要和模型摘要分别计算n-gram的结果.

ROUGE-L评价: L指最长公共子序列(Longest Common Subsequence, LCS), 比较人工摘要和模型摘要的最长公共子序列.

一般在实践中, 会将beta设置为很大的数值, 因此第一项可以忽略, ROUGE-L分数几乎只考虑"召回率"指标!

算法也有缺陷: 两条机器摘要得到相同的ROUGE分数, 但是语义上的差别非常大.

ROUGE-N算法的优缺点: - 优点: 直观, 简洁, 能反应文本的词序.

缺点: 区分度不高, 且当N > 3时, ROUGE-N值通常很小.

ROUGE-L算法的优缺点: - 优点: 不要求词的连续匹配, 只要求按词的出现顺序匹配即可, 能够像n-gram一样反应句子级别的词序. 自动匹配最长公共子序列, 不需要预定义n-gram的长度超参数.

缺点: 只计算一个最长子序列, 最终的值忽略了其他备选的最长子序列及较短子序列的特征和影响.

5.2 ROUGE评估算法实现
ROUGE算法代码实现¶
学习目标¶
掌握ROUGE算法的代码实现.

掌握利用ROUGE评估对模型效果进行测试.

ROUGE的代码实现¶
在真实的工业界实践中, 都是直接调用rouge工具包实现ROUGE算法的相关评测工作.

在项目代码中构建ROUGE评估类: - 代码文件路径: /home/ec2-user/text_summary/pgn/src/rouge_eval.py

导入工具包

import os
import sys
from rouge import Rouge
import jieba

设定项目的root路径, 方便相关代码文件的导入

root_path = os.path.dirname(os.path.dirname(os.path.abspath(file)))
sys.path.append(root_path)

导入项目的相关代码文件

from src.predict import Predict
from utils.func_utils import timer
from utils import config

构建ROUGE评估类

class RougeEval():
def init(self, path):
self.path = path
self.scores = None
self.rouge = Rouge()
self.sources = []
self.hypos = []
self.refs = []
self.process()

# 预处理待评估的数据集
def process(self):print('Reading from ', self.path)with open(self.path, 'r') as test:for line in test:source, ref = line.strip().split('<SEP>')ref = ref.replace('。', '.')self.sources.append(source)self.refs.append(ref)print('self.refs[]包含的样本数: ', len(self.refs))print(f'Test set contains {len(self.sources)} samples.')# 生成预测集合
@timer('building hypotheses')
def build_hypos(self, predict):print('Building hypotheses.')count = 0for source in self.sources:count += 1if count % 1000 == 0:print('count=', count)self.hypos.append(predict.predict(source.split()))def get_average(self):assert len(self.hypos) > 0, '需要首先构建hypotheses'print('Calculating average rouge scores.')return self.rouge.get_scores(self.hypos, self.refs, avg=True)

调用:

真实的测试机是val_data_path: dev.txt

print('实例化Rouge对象......')
rouge_eval = RougeEval(config.val_data_path)
print('实例化Predict对象......')
predict = Predict()

利用模型对article进行预测

print('利用模型对article进行预测, 并通过Rouge对象进行评估......')
rouge_eval.build_hypos(predict)

将预测结果和标签abstract进行ROUGE规则计算

print('开始用Rouge规则进行评估......')
result = rouge_eval.get_average()
print('rouge1: ', result['rouge-1'])
print('rouge2: ', result['rouge-2'])
print('rougeL: ', result['rouge-l'])

最后将计算评估结果写入文件中

print('将评估结果写入结果文件中......')
with open('../eval_result/rouge_result.txt', 'a') as f:
for r, metrics in result.items():
f.write(r + '\n')
for metric, value in metrics.items():
f.write(metric + ': ' + str(value * 100) + '\n')
ROUGE评估模型表现¶
对PGN的baseline-2模型进行评估¶
代码文件路径: - 代码1: /home/ec2-user/text_summary/pgn/src/rouge_eval.py

代码2: /home/ec2-user/text_summary/pgn/src/predict.py

调用:

1: 关键的第一步是在rouge_eval.py文件中, 设置好RougeEval(config.val_data_path)中待评估的文件配置路径,
也就是config.py中val_data_path的设置调整成当前待评估的数据

2: 关键的第二步是在predict.py文件中, 设置好当前要评估的模型, 使用self.model.load_state_dict(torch.load(config.model_save_path)),
将config.py中的model_save_path指定成当前待评估的模型存储路径.

3: 执行评估代码即可
cd /home/ec2-user/text_summary/pgn/src/
python rouge_eval.py
输出结果:

实例化Rouge对象......
Reading from /home/ec2-user/text_summary/pgn/data/dev.txt
self.refs[]包含的样本数: 3000
Test set contains 3000 samples.
实例化Predict对象......
Reading dataset /home/ec2-user/text_summary/pgn/data/train.txt... 70000 pairs.
10.03636384010315 secs used for initalize predicter
利用模型对article进行预测, 并通过Rouge对象进行评估......
Building hypotheses.
0.10421490669250488 secs used for doing prediction
0.03433394432067871 secs used for doing prediction
0.08382606506347656 secs used for doing prediction
0.013453960418701172 secs used for doing prediction
0.09066295623779297 secs used for doing prediction
0.029076337814331055 secs used for doing prediction
0.028420209884643555 secs used for doing prediction
0.02485823631286621 secs used for doing prediction
0.03336787223815918 secs used for doing prediction
0.024119138717651367 secs used for doing prediction
0.03458094596862793 secs used for doing prediction
0.08823156356811523 secs used for doing prediction
0.009444475173950195 secs used for doing prediction
0.08700251579284668 secs used for doing prediction
0.09494662284851074 secs used for doing prediction
0.013390302658081055 secs used for doing prediction
0.08516693115234375 secs used for doing prediction
0.08620691299438477 secs used for doing prediction
0.020738840103149414 secs used for doing prediction
0.0464634895324707 secs used for doing prediction
0.08194613456726074 secs used for doing prediction
0.08463144302368164 secs used for doing prediction
0.09369850158691406 secs used for doing prediction
0.08302879333496094 secs used for doing prediction

......
......
......

0.013429403305053711 secs used for doing prediction
0.01087808609008789 secs used for doing prediction
0.011685848236083984 secs used for doing prediction
0.0828557014465332 secs used for doing prediction
0.08689641952514648 secs used for doing prediction
0.08526015281677246 secs used for doing prediction
0.014253854751586914 secs used for doing prediction
count= 3000
0.013748884201049805 secs used for doing prediction
181.9742922782898 secs used for building hypotheses
开始用Rouge规则进行评估......
Calculating average rouge scores.
rouge1: {'f': 0.224840567492723, 'p': 0.2248892685096682, 'r': 0.361521073852865}
rouge2: {'f': 0.076855293213853, 'p': 0.07633592816230374, 'r': 0.1299218003843617}
rougeL: {'f': 0.2979190839597577, 'p': 0.35022548049923935, 'r': 0.31245311823639826}
将评估结果写入结果文件中......
评估结果文件路径: /home/ec2-user/text_summary/pgn/eval_result/rouge_result.txt

rouge-1
f: 22.4840567492723
p: 22.48892685096682
r: 36.152107385286506
rouge-2
f: 7.6855293213852995
p: 7.633592816230374
r: 12.992180038436171
rouge-l
f: 29.79190839597577
p: 35.022548049923934
r: 31.245311823639828
小节总结¶
小节总结: 在5.2小节中实现了评估核心类RougeEval()的代码编写, 可以广泛的应用在相关任务中.

最终关于baseline-2的评估结果: - 最高分数出现在ROUGE-L中, 说明最长子序列更容易匹配上.

ROUGE-2明显比ROUGE-1分数下降很多, 说明对于当前pgn模型的自动摘要, 连续词的匹配能力还比较弱.

6.1 coverage机制的优化
对baseline-2模型的优化¶
学习目标¶
理解PGN + attention模型的缺陷和增加coverage机制的原因.

掌握增加coverage机制的代码实现和模型训练.

增加coverage的原因和数学原理¶
增加coverage的原因¶
自习观察baseline-2模型的预测结果, 有两点值得注意: - 1: 相比于baseline-1模型, 摘要生成的效果已经有了很大的提升.

2: 但是也会出现一些短语的简单无效重复.

在6.1小节中, 针对于短语的简单无效重复问题, 提出了解决方案: 引入coverage机制.
avatar

结论: 从原始论文中可以清楚的看到, 引入coverage机制后, 确实可以大大缓解重复问题.

coverage的数学原理¶
所谓coverage机制, 就是覆盖机制, 模型采用一种方式可以跟踪过去的时间步对哪些单词投放了较多的注意力, 这样在后续时间步的预测时, 我们就更多的去关注累积注意力少的部分, 而不去较多的关注累积注意力多的部分.

论文中在实现coverage机制时, 采用了如下几步: - 第一步: 统计注意力分布的累加和.

第二步: 将coverage张量作为注意力机制的一路输入.

第三步: 引入覆盖损失.

第一步: 统计注意力分布的累加和. - 定义覆盖向量c_t: 是所有先前解码器时间步的注意力分布的总和.
avatar

第二步: 将coverage张量作为注意力机制的一路输入.
avatar

第三步: 引入覆盖损失. - 定义覆盖损失covloss: 采用第一步中的累加和, 还有当前时间步的注意力值的较小值.

作用: 用以惩罚将注意力过多的重复分配到同一位置.
avatar

综合上面三步, 可以得到引入coverage机制后, 整个模型训练的损失函数为:
avatar

PGN + coverage实现baseline-3模型¶
PGN + coverage模型的构建也要分别编写几个子层类, 结构和PGN很像, 但需要添加coverage的代码: - 第一步: 编码器类Encoder.

第二步: 注意力层类Attention.

第三步: 解码器类Decoder.

第四步: 降维加和类ReduceState.

第五步: 完整的PGN网络类.

以上五个步骤的全部代码都在model.py中.

代码文件路径: /home/ec2-user/text_summary/pgn/src/model.py

第一步: 编码器类Encoder¶
编码器类Encoder的创建:

构建编码器类

class Encoder(nn.Module):
def init(self, vocab_size, embed_size, hidden_size, rnn_drop=0):
super(Encoder, self).init()
# 词嵌入层采用跟随模型一起训练的模式
self.embedding = nn.Embedding(vocab_size, embed_size)
self.hidden_size = hidden_size
# 编码器的主体采用单层, 双向LSTM结构
self.lstm = nn.LSTM(embed_size, hidden_size, bidirectional=True, dropout=rnn_drop, batch_first=True)

def forward(self, x):embedded = self.embedding(x)output, hidden = self.lstm(embedded)return output, hidden

注意: 此处代码和第四章4.3小节中的Encoder完全一样.

第二步: 注意力层类Attention¶
注意力层类Attention的创建:

构建注意力类

class Attention(nn.Module):
def init(self, hidden_units):
super(Attention, self).init()
# 定义前向传播层, 对应论文中的公式1中的Wh, Ws
self.Wh = nn.Linear(2 * hidden_units, 2 * hidden_units, bias=False)
self.Ws = nn.Linear(2 * hidden_units, 2 * hidden_units)

    # ---------------------------------------------------------------# 下面一行代码是baseline-3模型增加coverage机制的新增代码# 定义全连接层wc, 对应论文中的coverage处理self.wc = nn.Linear(1, 2 * hidden_units, bias=False)# ---------------------------------------------------------------# 定义全连接层, 对应论文中的公式1中最外层的vself.v = nn.Linear(2 * hidden_units, 1, bias=False)# 相比于baseline-2模型, 此处forward函数新增最后一个参数coverage_vector
def forward(self, decoder_states, encoder_output, x_padding_masks, coverage_vector):h_dec, c_dec = decoder_states# 将两个张量在最后一个维度拼接, 得到deocder state St: (1, batch_size, 2*hidden_units)s_t = torch.cat([h_dec, c_dec], dim=2)# 将batch_size置于第一个维度上: (batch_size, 1, 2*hidden_units)s_t = s_t.transpose(0, 1)# 按照hi的维度扩展St的维度: (batch_size, seq_length, 2*hidden_units)s_t = s_t.expand_as(encoder_output).contiguous()# 根据论文中的公式1来计算et, 总共有三步# 第一步: 分别经历各自的全连接层矩阵乘法# Wh * h_i: (batch_size, seq_length, 2*hidden_units)encoder_features = self.Wh(encoder_output.contiguous())# Ws * s_t: (batch_size, seq_length, 2*hidden_units)decoder_features = self.Ws(s_t)# 第二步: 两部分执行加和运算# (batch_size, seq_length, 2*hidden_units)attn_inputs = encoder_features + decoder_features# -----------------------------------------------------------------# 下面新增的3行代码是baseline-3为服务于coverage机制而新增的.if config.coverage:coverage_features = self.wc(coverage_vector.unsqueeze(2))attn_inputs = attn_inputs + coverage_features# -----------------------------------------------------------------# 第三步: 执行tanh运算和一个全连接层的运算# (batch_size, seq_length, 1)score = self.v(torch.tanh(attn_inputs))# 得到score后, 执行论文中的公式2# (batch_size, seq_length)attention_weights = F.softmax(score, dim=1).squeeze(2)# 添加一步执行padding mask的运算, 将编码器端无效的PAD字符全部遮掩掉attention_weights = attention_weights * x_padding_masks# 最整个注意力层执行一次正则化操作normalization_factor = attention_weights.sum(1, keepdim=True)attention_weights = attention_weights / normalization_factor# 执行论文中的公式3,将上一步得到的attention distributon应用在encoder hidden states上,得到context_vector# (batch_size, 1, 2*hidden_units)context_vector = torch.bmm(attention_weights.unsqueeze(1), encoder_output)# (batch_size, 2*hidden_units)context_vector = context_vector.squeeze(1)# ----------------------------------------------------------------# 下面新增的2行代码是baseline-3模型为服务于coverage机制而新增的.# 按照论文中的公式10更新coverage vectorif config.coverage:coverage_vector = coverage_vector + attention_weights# ----------------------------------------------------------------# 在baseline-2中我们返回2个张量; 在baseline-3中我们新增返回coverage vector张量.return context_vector, attention_weights, coverage_vector

注意: 此处代码和第四章4.3小节中的Attention不一样, 新增了关于coverage的处理部分.

第三步: 解码器类Decoder¶
解码器类Decoder的创建:

class Decoder(nn.Module):
def init(self, vocab_size, embed_size, hidden_size, enc_hidden_size=None):
super(Decoder, self).init()
# 解码器端也采用跟随模型一起训练的方式, 得到词嵌入层
self.embedding = nn.Embedding(vocab_size, embed_size)
# self.DEVICE = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
self.vocab_size = vocab_size
self.hidden_size = hidden_size

    # 解码器的主体结构采用单向LSTM, 区别于编码器端的双向LSTMself.lstm = nn.LSTM(embed_size, hidden_size, batch_first=True)# 因为要将decoder hidden state和context vector进行拼接, 因此需要3倍的hidden_size维度设置self.W1 = nn.Linear(self.hidden_size * 3, self.hidden_size)self.W2 = nn.Linear(self.hidden_size, vocab_size)if config.pointer:# 因为要根据论文中的公式8进行运算, 所谓输入维度上匹配的是4 * hidden_size + embed_sizeself.w_gen = nn.Linear(self.hidden_size * 4 + embed_size, 1)def forward(self, x_t, decoder_states, context_vector):# 首先计算Decoder的前向传播输出张量decoder_emb = self.embedding(x_t)decoder_output, decoder_states = self.lstm(decoder_emb, decoder_states)# 接下来就是论文中的公式4的计算.# 将context vector和decoder state进行拼接, (batch_size, 3*hidden_units)decoder_output = decoder_output.view(-1, config.hidden_size)concat_vector = torch.cat([decoder_output, context_vector], dim=-1)# 经历两个全连接层V和V1后,再进行softmax运算, 得到vocabulary distribution# (batch_size, hidden_units)FF1_out = self.W1(concat_vector)# (batch_size, vocab_size)FF2_out = self.W2(FF1_out)# (batch_size, vocab_size)p_vocab = F.softmax(FF2_out, dim=1)# 构造decoder state s_t.h_dec, c_dec = decoder_states# (1, batch_size, 2*hidden_units)s_t = torch.cat([h_dec, c_dec], dim=2)# p_gen是通过context vector h_t, decoder state s_t, decoder input x_t, 三个部分共同计算出来的.# 下面的部分是计算论文中的公式8.p_gen = Noneif config.pointer:# 这里面采用了直接拼接3部分输入张量, 然后经历一个共同的全连接层w_gen, 和原始论文的计算不同.# 这也给了大家提示, 可以提高模型的复杂度, 完全模拟原始论文中的3个全连接层来实现代码.x_gen = torch.cat([context_vector, s_t.squeeze(0), decoder_emb.squeeze(1)], dim=-1)p_gen = torch.sigmoid(self.w_gen(x_gen))return p_vocab, decoder_states, p_gen

注意: 此处代码和第四章4.3小节中的Decoder完全一样.

第四步: 降维加和类ReduceState¶
降维加和类ReduceState的创建:

构造加和state的类, 方便模型运算

class ReduceState(nn.Module):
def init(self):
super(ReduceState, self).init()

def forward(self, hidden):h, c = hiddenh_reduced = torch.sum(h, dim=0, keepdim=True)c_reduced = torch.sum(c, dim=0, keepdim=True)return (h_reduced, c_reduced)

注意: 此处代码和第四章4.3小节中的ReduceState完全一样.

第五步: 完整的PGN网络类¶
PGN类的创建:

导入系统工具包

import os
import sys

设置项目的root路径, 方便后续相关代码文件的导入

root_path = os.path.dirname(os.path.dirname(os.path.abspath(file)))
sys.path.append(root_path)

导入若干工具包

import torch
import torch.nn as nn
import torch.nn.functional as F

导入项目中的相关代码文件

from utils import config
from utils.func_utils import timer, replace_oovs
from utils.vocab import Vocab

构建PGN类

class PGN(nn.Module):
def init(self, v):
super(PGN, self).init()
# 初始化字典对象
self.v = v
self.DEVICE = config.DEVICE

    # 依次初始化4个类对象self.attention = Attention(config.hidden_size)self.encoder = Encoder(len(v), config.embed_size, config.hidden_size)self.decoder = Decoder(len(v), config.embed_size, config.hidden_size)self.reduce_state = ReduceState()# 计算最终分布的函数
def get_final_distribution(self, x, p_gen, p_vocab, attention_weights, max_oov):if not config.pointer:return p_vocabbatch_size = x.size()[0]# 进行p_gen概率值的裁剪, 具体取值范围可以调参p_gen = torch.clamp(p_gen, 0.001, 0.999)# 接下来两行代码是论文中公式9的计算.p_vocab_weighted = p_gen * p_vocab# (batch_size, seq_len)attention_weighted = (1 - p_gen) * attention_weights# 得到扩展后的单词概率分布(extended-vocab probability distribution)# extended_size = len(self.v) + max_oovsextension = torch.zeros((batch_size, max_oov)).float().to(self.DEVICE)# (batch_size, extended_vocab_size)p_vocab_extended = torch.cat([p_vocab_weighted, extension], dim=1)# 根据论文中的公式9, 累加注意力值attention_weighted到对应的单词位置xfinal_distribution = p_vocab_extended.scatter_add_(dim=1, index=x, src=attention_weighted)return final_distributiondef forward(self, x, x_len, y, len_oovs, batch, num_batches, teacher_forcing):x_copy = replace_oovs(x, self.v)x_padding_masks = torch.ne(x, 0).byte().float()# 第一步: 进行Encoder的编码计算encoder_output, encoder_states = self.encoder(x_copy)decoder_states = self.reduce_state(encoder_states)# ------------------------------------------------------------------# 下面新增的一行代码是baseline-3模型处理coverage机制新增的.# 用全零张量初始化coverage vector.coverage_vector = torch.zeros(x.size()).to(self.DEVICE)# 初始化每一步的损失值step_losses = []# 第二步: 循环解码, 每一个时间步都经历注意力的计算, 解码器层的计算.# 初始化解码器的输入, 是ground truth中的第一列, 即真实摘要的第一个字符x_t = y[:, 0]for t in range(y.shape[1] - 1):# 如果使用Teacher_forcing, 则每一个时间步用真实标签来指导训练if teacher_forcing:x_t = y[:, t]x_t = replace_oovs(x_t, self.v)y_t = y[:, t + 1]# ------------------------------------------------------------------------------------# 通过注意力层计算context vector, 这里新增了coverage_vector张量的处理context_vector, attention_weights, coverage_vector = self.attention(decoder_states,encoder_output,x_padding_masks,coverage_vector)# ------------------------------------------------------------------------------------# 通过解码器层计算得到vocab distribution和hidden statesp_vocab, decoder_states, p_gen = self.decoder(x_t.unsqueeze(1), decoder_states, context_vector)# 得到最终的概率分布final_dist = self.get_final_distribution(x,p_gen,p_vocab,attention_weights,torch.max(len_oovs))# 第t个时间步的预测结果, 将作为第t + 1个时间步的输入(如果采用Teacher-forcing则不同).x_t = torch.argmax(final_dist, dim=1).to(self.DEVICE)# 根据模型对target tokens的预测, 来获取到预测的概率if not config.pointer:y_t = replace_oovs(y_t, self.v)target_probs = torch.gather(final_dist, 1, y_t.unsqueeze(1))target_probs = target_probs.squeeze(1)# 将解码器端的PAD用padding mask遮掩掉, 防止计算loss时的干扰mask = torch.ne(y_t, 0).byte()# 为防止计算log(0)而做的数学上的平滑处理loss = -torch.log(target_probs + config.eps)# ------------------------------------------------------------------------# 下面新增的4行代码是baseline-3模型为服务coverage机制而新增的.# 新增关于coverage loss的处理逻辑代码.if config.coverage:# 按照论文中的公式12, 计算covloss.ct_min = torch.min(attention_weights, coverage_vector)cov_loss = torch.sum(ct_min, dim=1)# 按照论文中的公式13, 计算加入coverage机制后整个模型的损失值.loss = loss + config.LAMBDA * cov_loss# ------------------------------------------------------------------------# 先遮掩, 再添加损失值mask = mask.float()loss = loss * maskstep_losses.append(loss)# 第三步: 计算一个批次样本的损失值, 为反向传播做准备.sample_losses = torch.sum(torch.stack(step_losses, 1), 1)# 统计非PAD的字符个数, 作为当前批次序列的有效长度seq_len_mask = torch.ne(y, 0).byte().float()batch_seq_len = torch.sum(seq_len_mask, dim=1)# 计算批次样本的平均损失值batch_loss = torch.mean(sample_losses / batch_seq_len)return batch_loss

调用:

if name == 'main':
v = Vocab()
model = PGN(v)
print(model)
输出结果:

PGN(
(attention): Attention(
(Wh): Linear(in_features=1024, out_features=1024, bias=False)
(Ws): Linear(in_features=1024, out_features=1024, bias=True)
(wc): Linear(in_features=1, out_features=1024, bias=False)
(v): Linear(in_features=1024, out_features=1, bias=False)
)
(encoder): Encoder(
(embedding): Embedding(4, 512)
(lstm): LSTM(512, 512, batch_first=True, bidirectional=True)
)
(decoder): Decoder(
(embedding): Embedding(4, 512)
(lstm): LSTM(512, 512, batch_first=True)
(W1): Linear(in_features=1536, out_features=512, bias=True)
(W2): Linear(in_features=512, out_features=4, bias=True)
(w_gen): Linear(in_features=2560, out_features=1, bias=True)
)
(reduce_state): ReduceState()
)
PGN + attention + coverage模型结论: 相比于第四章4.3小节的baseline-2模型, 这里面只是在attention模块中新增了一个wc全连接层.

baseline-3模型训练与预测¶
baseline-3模型训练¶
首先修改config.py文件, 将coverage=True加入配置文件中. - 代码文件路径: /home/ec2-user/text_summary/pgn/utils/config.py

在config.py文件中修改如下一行配置代码

coverage=True
调用:

cd /home/ec2-user/text_summary/pgn/src/

python train.py
训练时查看GPU情况: nvidia-smi

+-----------------------------------------------------------------------------+
| NVIDIA-SMI 450.51.05 Driver Version: 450.51.05 CUDA Version: 11.0 |
|-------------------------------+----------------------+----------------------+
| GPU Name Persistence-M| Bus-Id Disp.A | Volatile Uncorr. ECC |
| Fan Temp Perf Pwr:Usage/Cap| Memory-Usage | GPU-Util Compute M. |
| | | MIG M. |
|=++==============|
| 0 Tesla T4 On | 00000000:00:08.0 Off | 0 |
| N/A 69C P0 69W / 70W | 14566MiB / 15109MiB | 99% Default |
| | | N/A |
+-------------------------------+----------------------+----------------------+

+-----------------------------------------------------------------------------+
| Processes: |
| GPU GI CI PID Type Process name GPU Memory |
| ID ID Usage |
|=============================================================================|
| 0 N/A N/A 29256 C python 14563MiB |
+-----------------------------------------------------------------------------+
输出结果:

DEVICE: cuda
Reading dataset /home/ec2-user/text_summary/pgn/data/train.txt... 70000 pairs.
Reading dataset /home/ec2-user/text_summary/pgn/data/dev.txt...
[[0%| | 0/10 [00:00<?, ?it/s]
[[0%| | 0/21 [00:00<?, ?it/s]
[[0%| | 0/2188 [00:00<?, ?it/s]
[[Epoch 0: 0%| | 0/21 [00:01<?, ?it/s]
[[Epoch 0: 0%| | 0/21 [00:01<?, ?it/s, Batch=0, Loss=6.87]
[[Epoch 0: 5%|▍ | 1/21 [00:01<00:29, 1.47s/it, Batch=0, Loss=6.87]
[[0%| | 1/2188 [00:01<53:37, 1.47s/it]
[[0%| | 2/2188 [00:03<1:05:02, 1.79s/it]
[[0%| | 3/2188 [00:05<1:00:36, 1.66s/it]
[[0%| | 4/2188 [00:07<1:00:46, 1.67s/it]
[[0%| | 5/2188 [00:07<50:25, 1.39s/it]

......
......
......

[[99%|█████████▉| 397/402 [03:58<00:02, 1.85it/s]
[[99%|█████████▉| 398/402 [03:58<00:02, 1.98it/s]
[[99%|█████████▉| 399/402 [03:59<00:01, 2.22it/s]
[[100%|█████████▉| 400/402 [03:59<00:00, 2.50it/s]
[[100%|█████████▉| 401/402 [03:59<00:00, 2.41it/s]
[[100%|██████████| 402/402 [04:00<00:00, 2.28it/s]
[[100%|██████████| 402/402 [04:00<00:00, 1.67it/s]

[[0%| | 0/21 [00:00<?, ?it/s]
[[0%| | 0/2188 [00:00<?, ?it/s]
[[Epoch 1: 0%| | 0/21 [00:01<?, ?it/s]
[[Epoch 1: 0%| | 0/21 [00:01<?, ?it/s, Batch=0, Loss=3.77]
[[Epoch 1: 5%|▍ | 1/21 [00:01<00:20, 1.02s/it, Batch=0, Loss=3.77]
[[0%| | 1/2188 [00:01<37:01, 1.02s/it]
[[0%| | 2/2188 [00:02<41:05, 1.13s/it]
[[0%| | 3/2188 [00:04<48:23, 1.33s/it]
[[0%| | 4/2188 [00:05<47:54, 1.32s/it]

......
......
......

[[99%|█████████▉| 398/402 [04:01<00:02, 1.49it/s]
[[99%|█████████▉| 399/402 [04:02<00:02, 1.48it/s]
[[100%|█████████▉| 400/402 [04:02<00:01, 1.61it/s]
[[100%|█████████▉| 401/402 [04:03<00:00, 1.74it/s]
[[100%|██████████| 402/402 [04:03<00:00, 1.71it/s]
[[100%|██████████| 402/402 [04:03<00:00, 1.65it/s]

Epoch 9: 100%|██████████| 10/10 [11:04:48<00:00, 3988.90s/it, Loss=1.95]
12870 pairs.
loading data......
initializing optimizer......
model_name = pgn_model, pointer = True, coverage = True, fine_tune = False, scheduled_sampling = False, weight_tying = False,source = train
teacher_forcing = True
validating
training loss:4.402697711801616 validation loss:4.268789264693189
model_name = pgn_model, pointer = True, coverage = True, fine_tune = False, scheduled_sampling = False, weight_tying = False,source = train
teacher_forcing = True
validating
training loss:3.748820104372349 validation loss:4.182700956638772
model_name = pgn_model, pointer = True, coverage = True, fine_tune = False, scheduled_sampling = False, weight_tying = False,source = train
teacher_forcing = True
validating
training loss:3.3340630204411705 validation loss:4.273871925932851
model_name = pgn_model, pointer = True, coverage = True, fine_tune = False, scheduled_sampling = False, weight_tying = False,source = train
teacher_forcing = True
validating
training loss:2.9564538238471343 validation loss:4.434603503094384
model_name = pgn_model, pointer = True, coverage = True, fine_tune = False, scheduled_sampling = False, weight_tying = False,source = train
teacher_forcing = True
validating
training loss:2.651415746870163 validation loss:4.654036068797705
model_name = pgn_model, pointer = True, coverage = True, fine_tune = False, scheduled_sampling = False, weight_tying = False,source = train
teacher_forcing = True
validating
training loss:2.425050822919204 validation loss:4.86548933579554
model_name = pgn_model, pointer = True, coverage = True, fine_tune = False, scheduled_sampling = False, weight_tying = False,source = train
teacher_forcing = True
validating
training loss:2.2524327974650715 validation loss:5.090988543496203
model_name = pgn_model, pointer = True, coverage = True, fine_tune = False, scheduled_sampling = False, weight_tying = False,source = train
teacher_forcing = True
validating
training loss:2.1244054655706206 validation loss:5.302600638783393
model_name = pgn_model, pointer = True, coverage = True, fine_tune = False, scheduled_sampling = False, weight_tying = False,source = train
teacher_forcing = True
validating
training loss:2.0239765569976305 validation loss:5.519360942033986
model_name = pgn_model, pointer = True, coverage = True, fine_tune = False, scheduled_sampling = False, weight_tying = False,source = train
teacher_forcing = True
validating
training loss:1.9480807396567936 validation loss:5.688335746081908
查看保存的模型:

cd /home/ec2-user/text_summary/pgn/saved_model/

ll
查看输出结果:

-rw-rw-r-- 1 ec2-user ec2-user 8403804 3月 9 11:03 model_attention.pt
-rw-rw-r-- 1 ec2-user ec2-user 93584247 3月 9 11:03 model_decoder.pt
-rw-rw-r-- 1 ec2-user ec2-user 57781249 3月 9 11:03 model_encoder.pt
-rw-rw-r-- 1 ec2-user ec2-user 751 3月 9 11:03 model_reduce_state.pt
-rw-rw-r-- 1 ec2-user ec2-user 159765238 3月 9 11:03 pgn_model.pt
baseline-3模型训练结论: 虽然整个训练过程用了10个epoch, 每个epoch在GPU上耗时约1个小时, 但是我们最终保存下来的模型是第2个epoch的训练结果. 因为第2个epoch的模型在验证集上损失值最低, 后面进入了过拟合的状态, 因此再后续的模型训练完全可以在config.py文件中设置epoch=3即可.

baseline-3模型预测¶
对于baseline-3模型的预测, 需要进行如下两步: - 第一步: 修改predict.py代码文件.

第二步: 执行预测程序.

第一步: 修改predict.py代码文件. - 代码文件路径: /home/ec2-user/text_summary/pgn/src/predict.py

导入工具包

import random
import os
import sys
import torch
import jieba

设定项目的root路径, 方便后续相关代码文件的导入

root_path = os.path.dirname(os.path.dirname(os.path.abspath(file)))
sys.path.append(root_path)

导入项目相关的代码文件

from utils import config
from src.model import PGN
from utils.dataset import PairDataset
from utils.func_utils import source2ids, outputids2words, timer, add2heap, replace_oovs

构建预测类Predict

class Predict():
@timer(module='initalize predicter')
def init(self):
self.DEVICE = config.DEVICE

    # 调用工具函数PairDataset读取训练集数据并构造PGN数据集dataset = PairDataset(config.train_data_path,max_enc_len=config.max_enc_len,max_dec_len=config.max_dec_len,truncate_enc=config.truncate_enc,truncate_dec=config.truncate_dec)# 基于数据集dataset构建词典self.vocab = dataset.build_vocab(embed_file=config.embed_file)# 实例化PGN网络类对象self.model = PGN(self.vocab)self.stop_word = list(set([self.vocab[x.strip()] for x in open(config.stop_word_file).readlines()]))# 加载已经训练好的模型并移动到GPU上.self.model.load_state_dict(torch.load(config.model_save_path))self.model.to(self.DEVICE)# 编写贪心解码策略的函数
def greedy_search(self, x, max_sum_len, len_oovs, x_padding_masks):encoder_output, encoder_states = self.model.encoder(replace_oovs(x, self.vocab))# 用encoder的hidden state初始化decoder的hidden statedecoder_states = self.model.reduce_state(encoder_states)# 利用SOS作为解码器的初始化输入字符x_t = torch.ones(1) * self.vocab.SOSx_t = x_t.to(self.DEVICE, dtype=torch.int64)summary = [self.vocab.SOS]coverage_vector = torch.zeros((1, x.shape[1])).to(self.DEVICE)# 循环解码, 最多解码max_sum_len步while int(x_t.item()) != (self.vocab.EOS) and len(summary) < max_sum_len:# 解码的每一个时间步, 都要先计算注意力分布, 得到context_vectorcontext_vector, attention_weights = self.model.attention(decoder_states,encoder_output,x_padding_masks,coverage_vector)# 基于context_vector, 利用解码器得到单词分布p_vocab和p_genp_vocab, decoder_states, p_gen = self.model.decoder(x_t.unsqueeze(1),decoder_states,context_vector)# 计算得到最终的全局分布final_distfinal_dist = self.model.get_final_distribution(x, p_gen, p_vocab,attention_weights,torch.max(len_oovs))# 以贪心解码策略预测字符x_t = torch.argmax(final_dist, dim=1).to(self.DEVICE)decoder_word_idx = x_t.item()# 将预测的字符添加进结果摘要中summary.append(decoder_word_idx)x_t = replace_oovs(x_t, self.vocab)return summary@timer(module='doing prediction')
def predict(self, text, tokenize=True):if isinstance(text, str) and tokenize:text = list(jieba.cut(text))# 将原始文本映射成数字化张量x, oov = source2ids(text, self.vocab)x = torch.tensor(x).to(self.DEVICE)# 获取OOV的长度和padding mask张量len_oovs = torch.tensor([len(oov)]).to(self.DEVICE)x_padding_masks = torch.ne(x, 0).byte().float()# 利用贪心解码函数得到摘要结果.summary = self.greedy_search(x.unsqueeze(0),max_sum_len=config.max_dec_steps,len_oovs=len_oovs,x_padding_masks=x_padding_masks)# 将得到的摘要数字化张量转换成自然语言文本summary = outputids2words(summary, oov, self.vocab)# 删除掉特殊字符<SOS>和<EOS>return summary.replace('<SOS>', '').replace('<EOS>', '').strip()

第二步: 调用.

if name == "main":
print('实例化Predict对象, 构建dataset和vocab......')
pred = Predict()
print('vocab_size: ', len(pred.vocab))
# Randomly pick a sample in test set to predict.
with open(config.val_data_path, 'r') as test:
picked = random.choice(list(test))
source, ref = picked.strip().split('')

print('source: ', source, '\n')
print('******************************************')
print('ref: ', ref, '\n')
print('******************************************')
greedy_prediction = pred.predict(source.split(),  beam_search=False)
print('greedy: ', greedy_prediction, '\n')

执行程序:

cd /home/ec2-user/text_summary/pgn/src/

python predict.py
输出结果:

实例化Predict对象, 构建dataset和vocab......
Reading dataset /home/ec2-user/text_summary/pgn/data/train.txt... 70000 pairs.
9.530707359313965 secs used for initalize predicter
vocab_size: 20004
source: 2015 年 途观 旗舰版 , 现 6 万公里 , 近期 感觉 发动机 加油 门时 前机 盖内 突突 声音 , 停车 红灯 时 发动机 声音 特别 大 , 平均 油耗 以前 2 个油 。 是不是 车出 问题 ? , 这种 情况 主要 看 一下 发动机 本身 抖动 没有 , 抖动 火花塞 点火 不好 导致


ref: 检查一下 火花塞 才 行 , 点火 不好 会


0.013345718383789062 secs used for doing prediction
greedy: 检查一下 火花塞
baseline-3模型测试结论: 随机抽取一个样本进行测试, 发现baseline-3模型效果已经非常棒了. 既可以表达source document中的核心要点, 又可以去除掉短句重复问题, 而这个正是coverage机制的功劳!!!

利用ROUGE评估baseline-3模型¶
对PGN的baseline-3模型进行评估¶
为了做模型的对比测试, 我们在测试baseline-2模型同样的测试集数据上, 来测试baseline-3模型的表现.

ROUGE评估代码文件路径: /home/ec2-user/text_summary/pgn/src/rouge_eval.py

导入工具包

import os
import sys
from rouge import Rouge
import jieba

设定项目的root路径, 方便相关代码文件的导入

root_path = os.path.dirname(os.path.dirname(os.path.abspath(file)))
sys.path.append(root_path)

导入项目的相关代码文件

from src.predict import Predict
from utils.func_utils import timer
from utils import config

构建ROUGE评估类

class RougeEval():
def init(self, path):
self.path = path
self.scores = None
self.rouge = Rouge()
self.sources = []
self.hypos = []
self.refs = []
self.process()

# 预处理评估数据集
def process(self):print('Reading from ', self.path)with open(self.path, 'r') as test:for line in test:source, ref = line.strip().split('<SEP>')ref = ref.replace('。', '.')self.sources.append(source)self.refs.append(ref)print('self.refs[]包含的样本数: ', len(self.refs))print(f'Test set contains {len(self.sources)} samples.')# 构建预测集合
@timer('building hypotheses')
def build_hypos(self, predict):print('Building hypotheses.')count = 0for source in self.sources:count += 1if count % 1000 == 0:print('count=', count)self.hypos.append(predict.predict(source.split()))def get_average(self):assert len(self.hypos) > 0, '需要首先构建hypotheses'print('Calculating average rouge scores.')return self.rouge.get_scores(self.hypos, self.refs, avg=True)

调用:

真实的测试机是val_data_path: dev.txt

print('实例化Rouge对象......')
rouge_eval = RougeEval(config.val_data_path)
print('实例化Predict对象......')
predict = Predict()

利用模型对article进行预测

print('利用模型对article进行预测, 并通过Rouge对象进行评估......')
rouge_eval.build_hypos(predict)

将预测结果和标签abstract进行ROUGE规则计算

print('开始用Rouge规则进行评估......')
result = rouge_eval.get_average()
print('rouge1: ', result['rouge-1'])
print('rouge2: ', result['rouge-2'])
print('rougeL: ', result['rouge-l'])

最后将计算评估结果写入文件中

print('将评估结果写入结果文件中......')
with open('../eval_result/rouge_result.txt', 'a') as f:
for r, metrics in result.items():
f.write(r + '\n')
for metric, value in metrics.items():
f.write(metric + ': ' + str(value * 100) + '\n')
执行:

1: 关键的第一步是在rouge_eval.py文件中, 设置好RougeEval(config.val_data_path)中待评估的文件配置路径,
注意: 在config.py中val_data_path待评估的数据一定要和baseline-2模型的测试数据是同一份数据

2: 关键的第二步是在predict.py文件中, 设置好当前要评估的模型, 使用self.model.load_state_dict(torch.load(config.model_save_path)),
将config.py中的model_save_path指定成baseline-3模型的存储路径.

3: 执行评估代码即可
cd /home/ec2-user/text_summary/pgn/src/
python rouge_eval.py
输出结果:

实例化Rouge对象......
Reading from /home/ec2-user/text_summary/pgn/data/dev.txt
self.refs[]包含的样本数: 3000
Test set contains 3000 samples.
实例化Predict对象......
Reading dataset /home/ec2-user/text_summary/pgn/data/train.txt... 70000 pairs.
10.267724990844727 secs used for initalize predicter
利用模型对article进行预测, 并通过Rouge对象进行评估......
Building hypotheses.
0.04752159118652344 secs used for doing prediction
0.0552525520324707 secs used for doing prediction
0.03582024574279785 secs used for doing prediction
0.01597142219543457 secs used for doing prediction
0.03732419013977051 secs used for doing prediction
0.016273975372314453 secs used for doing prediction
0.013509750366210938 secs used for doing prediction
0.030243396759033203 secs used for doing prediction
0.0444340705871582 secs used for doing prediction
0.017905235290527344 secs used for doing prediction
0.023414134979248047 secs used for doing prediction
0.016438007354736328 secs used for doing prediction
0.01435995101928711 secs used for doing prediction
0.03378701210021973 secs used for doing prediction
0.025617361068725586 secs used for doing prediction
0.013029336929321289 secs used for doing prediction
0.09791374206542969 secs used for doing prediction
0.039574623107910156 secs used for doing prediction

......
......
......

0.03331756591796875 secs used for doing prediction
0.01589798927307129 secs used for doing prediction
0.05126142501831055 secs used for doing prediction
0.013388395309448242 secs used for doing prediction
0.015698671340942383 secs used for doing prediction
0.01641082763671875 secs used for doing prediction
0.019998550415039062 secs used for doing prediction
0.10716414451599121 secs used for doing prediction
0.09088850021362305 secs used for doing prediction
0.007317543029785156 secs used for doing prediction
0.017053604125976562 secs used for doing prediction
159.63976788520813 secs used for building hypotheses
开始用Rouge规则进行评估......
Calculating average rouge scores.
rouge1: {'f': 0.23891369972642879, 'p': 0.2788479944741074, 'r': 0.3274651102592097}
rouge2: {'f': 0.08145260844937684, 'p': 0.09507772545428753, 'r': 0.11403972188579534}
rougeL: {'f': 0.28112397619052887, 'p': 0.3551625333014313, 'r': 0.28567520633785176}
将评估结果写入结果文件中......
查看结果文件:

cd /home/ec2-user/text_summary/pgn/eval_result/

cat rouge_result.txt
输出结果:

rouge-1
f: 23.891369972642877
p: 27.884799447410742
r: 32.746511025920974
rouge-2
f: 8.145260844937683
p: 9.507772545428752
r: 11.403972188579534
rouge-l
f: 28.112397619052885
p: 35.51625333014313
r: 28.567520633785175
ROUGE评估结果对比¶
将baseline-2模型和baseline-3模型在同样的测试集上的表现展示在一起:

左侧展示的是baseline-2模型的表现, 右侧展示的是baseline-3模型的表现

baseline-2: PGN + attention # baseline-3: PGN + attention + coverage

rouge-1 rouge-1
f: 22.4840567492723 f: 23.891369972642877
p: 22.48892685096682 p: 27.884799447410742
r: 36.152107385286506 r: 32.746511025920974
rouge-2 rouge-2
f: 7.6855293213852995 f: 8.145260844937683
p: 7.633592816230374 p: 9.507772545428752
r: 12.992180038436171 r: 11.403972188579534
rouge-l rouge-l
f: 29.79190839597577 f: 28.112397619052885
p: 35.022548049923934 p: 35.51625333014313
r: 31.245311823639828 r: 28.567520633785175
ROUGE评估结论: baseline-3模型在ROUGE-N的关键指标上效果更好, 尤其是针对于精确率的提升; 但是baseline-3模型在ROUGE-L的关键指标上都有一些下降, 说明在目前的数据集上, 最长公共子串的表现不理想, 后续需要继续优化改进.

小节总结¶
小节总结: 在6.1小节中, 基于baseline-2模型做出优化, 增加重要的coverage机制. 旨在消除生成序列的重复性问题, 得到良好效果.

6.2 PGN + beam-search的优化模型
对baseline-3模型的优化¶
学习目标¶
理解为什么要采用Beam-search的原因.

掌握Beam-search算法的概念和实现.

掌握利用Beam-search来优化文本摘要模型.

Beam-search算法¶
Beam-search算法的概念¶
我们已经有了4个模型, 分别是TextRank, seq2seq, PGN + attention, 和PGN + attention + coverage. 这些优化的方法可以归结为两类: - 第一类: 模型架构的优化.

第二类: 模型训练算法的优化.

那么除了上述的两大类, 思考一个问题: 我们是否可以从模型的预测算法入手呢?

当然可以! 回顾一下我们在baseline-1到baseline-3的全部解码过程, 采用的都是贪心算法! 只要在这个算法上进行优化, 就可以提升模型预测的能力!

Greedy Decode的缺陷: 每一个时间步的解码, 都是选取当前神经网络输出层中"最大值"作为解码结果. 这种方式很显然并不是最优解, 只是简单易用, 算法效率高. 知道了贪心解码的缺陷所在, 就可以针对性的提出解决方案了. - 第一种: 考虑所有的解码可能, 从中挑选最优路径.

第二种: 考虑折中的解码可能, 从中挑选最优路径.

第一种方案时间复杂度太高, 是指数式的复杂度, 显然无法实际应用.

第二种方案可行性高, 比如采用每个时间步考虑TOP2, 或TOP3, 然后再在这个基础上搜索下一个时间步的解码方案.

Beam-search的概念: 在当前级别的状态下计算所有可能性, 并按照递增顺序对它们进行排序, 但只保留一定数量的可能结果(依据beam_size决定这个数量), 接着根据这些可能的结果进行扩展, 迭代以上动作直到搜索结束并返回最高概率的解.

Beam-search算法示例: 假设词表大小为3, 包含单词为[A, B, C], 设置beam_size等于2. - 第一步: 生成第1个词的时候, 对P(A), P(B), P(C)进行排序, 并选取概率最大的两个, 假设为单词A, C.

第二步: 生成第2个词的时候, 将当前序列A, C分别和词表中的所有词进行组合, 得到新的6个组合序列AA, AB, AC, CA, CB, CC, 并选取概率最大的两个, 假设为AA, CC.

第三步: 继续在第二步AA, AC的基础上重复第二步的过程, 直到遇见结束符为止, 最终输出2个得分最高的序列就是我们在beam_size=2的条件下得到的解码结果.

有了上面文字描述的示例, 我们再通过图解更清晰, 形象的理解一下Beam-search算法: - 从开始符出发, 每次解码后选取TOP2概率的结果保留下来, 第一步后留下he, I.

第二步从he出发, 保留TOP2得到hit, struck; 从I出发, 保留TOP2得到was, got.

那么目前已经有4个结果序列了, 依然选取TOP2的序列作为第三步的基础序列, 明显最大概率的两个是-1.6和-1.7对应的序列, 也就是 he hit, 和
I was.
avatar

接下来就是迭代进行前面的过程, 计算-选取TOP2-保留基础序列结果-再计算, 最终下图中绿色的序列就是采用Beam-search算法下的解码结果.
avatar

常用解码算法的分析¶
在预测阶段, 对于生成式任务, 我们可以理解为解码阶段, 目前学习了三种: - Greedy Decode(贪心解码)

Viterbi Decode(维特比解码)

Beam-search Decode(集束解码)

Greedy Decode(贪心解码): - 最普遍的解码方式, 效率最高, 简单易懂

无法保证结果最佳, 更无法保证得到最优解.

时间复杂度O(T * N)

Viterbi Decode(维特比解码): - 尤其对于离线算法应用较广, 效率较低, 理解上有难度.

Viterbi属于动态规划算法, 保证得到最优解.

时间复杂度O(T * N * N)

Beam-search Decode(集束解码): - 属于贪心解码的优化算法, 效率较高, 较易理解.

无法保证得到最优解, 但是一个相对较好的局部最优解在工程时间上可以接收.

时间复杂度O(T * K * N), beam_size=K.

Beam-search实现baseline-4模型¶
要实现Beam-search策略的baseline-4模型, 需要以下三个步骤: - 第一步: 添加Beam-search的配置信息.

第二步: 工具函数中实现Beam类.

第三步: 预测类Predict中添加Beam-search代码逻辑.

第一步: 添加Beam-search的配置信息¶
这些配置信息需要追加进config.py配置文件中: - 代码文件地址: /home/ec2-user/text_summary/pgn/utils/config.py

Beam search相关配置信息

beam_size = 3
alpha = 0.2
beta = 0.2
gamma = 2000
第二步: 工具函数中实现Beam类¶
Beam类的代码追加进工具函数func_utils.py代码文件中: - 代码文件地址: /home/ec2-user/text_summary/pgn/utils/func_utils.py

构建Beam-search的基础类

class Beam(object):
def init(self, tokens, log_probs, decoder_states, coverage_vector):
# Beam类所需的4个参数
# tokens: 已经搜索到的字符序列
# log_probs: 已经搜索的字符序列的得分序列
# decoder_states: Decoder解码器端的隐藏层状态张量
# coverage_vector: 引入coverage机制后计算得到的coverage张量
self.tokens = tokens
self.log_probs = log_probs
self.decoder_states = decoder_states
self.coverage_vector = coverage_vector

# 非常重要的扩展函数, 当前搜索序列向前进一步, 添加当前搜索的字符token和分数log_probs
def extend(self, token, log_prob, decoder_states, coverage_vector):return Beam(tokens=self.tokens + [token],log_probs=self.log_probs + [log_prob],decoder_states=decoder_states,coverage_vector=coverage_vector)# 计算当前序列得分的函数
def seq_score(self):# 得到当前序列的长度, 用来计算正则化参数len_Y = len(self.tokens)# 序列长度的正则化, 自定义公式ln = (5 + len_Y)**config.alpha / (5 + 1)**config.alpha# coverage张量的正则化计算, 固定公式计算即可cn = config.beta * torch.sum(torch.log(config.eps + torch.where(self.coverage_vector < 1.0,self.coverage_vector,torch.ones((1, self.coverage_vector.shape[1])).to(torch.device(config.DEVICE)))))# 直接利用上面的正则化参数, 计算当前序列的分数即可score = sum(self.log_probs) / ln + cnreturn score# 比较序列分数的小于<关系
def __lt__(self, other):return self.seq_score() < other.seq_score()# 比较序列分数的小于等于<=关系
def __le__(self, other):return self.seq_score() <= other.seq_score()

还要添加一个函数, 也在func_utils.py文件中添加:

本函数的作用是维护一个小顶堆, 拥有k个节点的二叉树结构, 最小值始终保持在堆顶!

def add2heap(heap, item, k):
# 如果当前堆的元素个数小于k, 则添加新节元素item为一个新的节点, 同时维护小顶堆的规则.
if len(heap) < k:
heapq.heappush(heap, item)
# 如果当前堆的元素个数不小于k, 则添加新节元素item为一个新的节点, 同时按照小顶堆的规则删除一个不符合的节点.
else:
heapq.heappushpop(heap, item)
第三步: 预测类Predict中添加Beam-search代码逻辑¶
Beam-search相关的解码逻辑放在predict.py代码文件中: - 代码文件路径: /home/ec2-user/text_summary/pgn/src/predict.py

在编写类Predict()时, 最重要的是添加两个新的类内函数来支持beam-search的操作: - def best_k(self): 利用解码器产生出一个vocab distribution, 来预测下一个token.

def beam_search(self): 支持beam-search解码策略的主逻辑函数.

导入相关系统工具包

import random
import os
import sys
import torch
import jieba

设置项目的root路径, 方便后续相关代码文件的导入

root_path = os.path.dirname(os.path.dirname(os.path.abspath(file)))
sys.path.append(root_path)

导入项目的相关代码文件

from utils import config
from src.model import PGN
from utils.dataset import PairDataset
from utils.func_utils import source2ids, outputids2words, Beam, timer, add2heap, replace_oovs

构建核心预测类Predict

class Predict():
@timer(module='initalize predicter')
def init(self):
self.DEVICE = config.DEVICE

    # 产生数据对, 为接下来的迭代器做数据准备, 注意这里面用的是训练集数据dataset = PairDataset(config.train_data_path,max_enc_len=config.max_enc_len,max_dec_len=config.max_dec_len,truncate_enc=config.truncate_enc,truncate_dec=config.truncate_dec)# 生成词汇表self.vocab = dataset.build_vocab(embed_file=config.embed_file)# 实例化PGN模型类, 这里面的模型是基于baseline-3的模型.self.model = PGN(self.vocab)self.stop_word = list(set([self.vocab[x.strip()] for x in open(config.stop_word_file).readlines()]))# 将已经训练好的模型作为初始化参数load进来, 并放到GPU上.self.model.load_state_dict(torch.load(config.model_save_path))self.model.to(self.DEVICE)def greedy_search(self, x, max_sum_len, len_oovs, x_padding_masks):encoder_output, encoder_states = self.model.encoder(replace_oovs(x, self.vocab))# 用encoder的hidden state初始化decoder的hidden statedecoder_states = self.model.reduce_state(encoder_states)# 利用SOS作为解码器的初始化输入字符x_t = torch.ones(1) * self.vocab.SOSx_t = x_t.to(self.DEVICE, dtype=torch.int64)summary = [self.vocab.SOS]coverage_vector = torch.zeros((1, x.shape[1])).to(self.DEVICE)# 循环解码, 最多解码max_sum_len步while int(x_t.item()) != (self.vocab.EOS) and len(summary) < max_sum_len:context_vector, attention_weights = self.model.attention(decoder_states,encoder_output,x_padding_masks,coverage_vector)p_vocab, decoder_states, p_gen = self.model.decoder(x_t.unsqueeze(1),decoder_states,context_vector)final_dist = self.model.get_final_distribution(x, p_gen, p_vocab,attention_weights,torch.max(len_oovs))# 以贪心解码策略预测字符x_t = torch.argmax(final_dist, dim=1).to(self.DEVICE)decoder_word_idx = x_t.item()# 将预测的字符添加进结果摘要中summary.append(decoder_word_idx)x_t = replace_oovs(x_t, self.vocab)return summary# -----------------------------------------------------------------------------------------
# 下面的函数best_k()和beam_search()是6.2小节新增的, 用于支持Beam-search解码的新函数.# 利用解码器产生出一个vocab distribution, 来预测下一个token.
def best_k(self, beam, k, encoder_output, x_padding_masks, x, len_oovs):# beam: 代表Beam类的一个实例化对象.# k: 代表Beam-search中的重要参数beam_size=k.# encoder_output: 编码器的输出张量.# x_padding_masks: 输入序列的padding mask, 用于遮掩那些无效的PAD位置字符# x: 编码器的输入张量.# len_oovs: OOV列表的长度.# 当前时间步t的对应解析字符token, 将作为Decoder端的输入, 产生最终的vocab distribution.x_t = torch.tensor(beam.tokens[-1]).reshape(1, 1)x_t = x_t.to(self.DEVICE)# 通过注意力层attention, 得到context_vectorcontext_vector, attention_weights, coverage_vector = self.model.attention(beam.decoder_states,encoder_output,x_padding_masks,beam.coverage_vector)# 函数replace_oovs()将OOV单词替换成新的id值, 来避免解码器出现index-out-of-bound errorp_vocab, decoder_states, p_gen = self.model.decoder(replace_oovs(x_t, self.vocab),beam.decoder_states,context_vector)# 调用PGN网络中的函数, 得到最终的单词分布(包含OOV)final_dist = self.model.get_final_distribution(x, p_gen, p_vocab,attention_weights,torch.max(len_oovs))# 计算序列的log_probs分数log_probs = torch.log(final_dist.squeeze())# 如果当前Beam序列只有1个token, 要将一些无效字符删除掉, 以免影响序列的计算.# 至于这个无效字符的列表都包含什么, 也是利用bad case的分析, 结合数据观察得到的, 属于调优的一部分.if len(beam.tokens) == 1:forbidden_ids = [self.vocab[u"这"],self.vocab[u"此"],self.vocab[u"采用"],self.vocab[u","],self.vocab[u"。"]]log_probs[forbidden_ids] = -float('inf')# 对于EOS token的一个罚分处理.# 具体做法参考了https://opennmt.net/OpenNMT/translation/beam_search/.log_probs[self.vocab.EOS] *= config.gamma * x.size()[1] / len(beam.tokens)log_probs[self.vocab.UNK] = -float('inf')# 从log_probs中获取top_k分数的tokens, 这也正好符合beam-search的逻辑.topk_probs, topk_idx = torch.topk(log_probs, k)# 非常关键的一行代码: 利用top_k的单词, 来扩展beam-search搜索序列, 等效于将top_k单词追加到候选序列的末尾.best_k = [beam.extend(x, log_probs[x], decoder_states, coverage_vector) for x in topk_idx.tolist()]# 返回追加后的结果列表return best_k# 支持beam-search解码策略的主逻辑函数.
def beam_search(self, x, max_sum_len, beam_size, len_oovs, x_padding_masks):# x: 编码器的输入张量, 即article(source document)# max_sum_len: 本质上就是最大解码长度max_dec_len# beam_size: 采用beam-search策略下的搜索宽度k# len_oovs: OOV列表的长度# x_padding_masks: 针对编码器的掩码张量, 把无效的PAD字符遮掩掉.# 第一步: 通过Encoder计算得到编码器的输出张量.encoder_output, encoder_states = self.model.encoder(replace_oovs(x, self.vocab))# 全零张量初始化coverage vectorcoverage_vector = torch.zeros((1, x.shape[1])).to(self.DEVICE)# 对encoder_states进行加和降维处理, 赋值给decoder_states.decoder_states = self.model.reduce_state(encoder_states)# 初始化hypothesis, 第一个token给SOS, 分数给0.init_beam = Beam([self.vocab.SOS], [0], decoder_states, coverage_vector)# beam_size本质上就是搜索宽度kk = beam_size# 初始化curr作为当前候选集, completed作为最终的hypothesis列表curr, completed = [init_beam], []# 通过for循环连续解码max_sum_len步, 每一步应用beam-search策略产生预测token.for _ in range(max_sum_len):# 初始化当前时间步的topk列表为空, 后续将beam-search的解码结果存储在topk中.topk = []for beam in curr:# 如果产生了一个EOS token, 则将beam对象追加进最终的hypothesis列表, 并将k值减1, 然后继续搜索.if beam.tokens[-1] == self.vocab.EOS:completed.append(beam)k -= 1continue# 遍历最好的k个候选集序列.for can in self.best_k(beam, k, encoder_output, x_padding_masks, x, torch.max(len_oovs)):# 利用小顶堆来维护一个top_k的candidates.# 小顶堆的值以当前序列的得分为准, 顺便也把候选集的id和候选集本身存储起来.add2heap(topk, (can.seq_score(), id(can), can), k)# 当前候选集是堆元素的index=2的值can.curr = [items[2] for items in topk]# 候选集数量已经达到搜索宽度的时候, 停止搜索.if len(completed) == beam_size:break# 将最后产生的候选集追加进completed中.completed += curr# 按照得分进行降序排列, 取分数最高的作为当前解码结果序列.result = sorted(completed, key=lambda x: x.seq_score(), reverse=True)[0].tokensreturn result# 上面的两个函数best_k()和beam_search()是6.2小节为了支持Beam-search解码的新函数
# --------------------------------------------------------------------------------------@timer(module='doing prediction')
def predict(self, text, tokenize=True, beam_search=True):# 很重要的一个参数是将beam_search设置为Trueif isinstance(text, str) and tokenize:text = list(jieba.cut(text))# 做模型所需的若干张量的初始化操作x, oov = source2ids(text, self.vocab)x = torch.tensor(x).to(self.DEVICE)len_oovs = torch.tensor([len(oov)]).to(self.DEVICE)x_padding_masks = torch.ne(x, 0).byte().float()# ------------------------------------------------------------------------# 下面是6.2小节的新增代码部分, 采用beam search策略进行解码if beam_search:summary = self.beam_search(x.unsqueeze(0),max_sum_len=config.max_dec_steps,beam_size=config.beam_size,len_oovs=len_oovs,x_padding_masks=x_padding_masks)# ------------------------------------------------------------------------# 采用贪心策略进行解码else:summary = self.greedy_search(x.unsqueeze(0),max_sum_len=config.max_dec_steps,len_oovs=len_oovs,x_padding_masks=x_padding_masks)# 将数字化ids张量转换为自然语言文本summary = outputids2words(summary, oov, self.vocab)# 在模型摘要结果中删除掉<SOS>, <EOS>字符.return summary.replace('<SOS>', '').replace('<EOS>', '').strip()

调用:

if name == "main":
print('实例化Predict对象, 构建dataset和vocab......')
pred = Predict()
print('vocab_size: ', len(pred.vocab))
# Randomly pick a sample in test set to predict.
with open(config.val_data_path, 'r') as test:
picked = random.choice(list(test))
source, ref = picked.strip().split('')

print('source: ', source, '\n')
print('******************************************')
print('ref: ', ref, '\n')
print('******************************************')
greedy_prediction = pred.predict(source.split(),  beam_search=False)
print('greedy: ', greedy_prediction, '\n')
print('******************************************')
beam_prediction = pred.predict(source.split(),  beam_search=True)
print('beam: ', beam_prediction, '\n')

输出结果1:

实例化Predict对象, 构建dataset和vocab......
Reading dataset /home/ec2-user/ec2-user/zhudejun/text_summary/text_summary/pgn/data/train.txt... 69999 pairs.
9.523097276687622 secs used for initalize predicter
vocab_size: 20004
source: 行驶 过程 中 不 小心 启动 键 一下 , 车子 熄火 , 变速箱 发动机 影响 大 , 没有 影响 , 放心 谢谢 ? ? 不 客气


ref: 没有 影响


0.011927127838134766 secs used for doing prediction
greedy: 没有 影响


0.34371519088745117 secs used for doing prediction
beam: 不会 影响 , 放心 , 不会 影响 , 放心 , 不会 影响 , 放心 行驶
输出结果2:

实例化Predict对象, 构建dataset和vocab......
Reading dataset /home/ec2-user/ec2-user/zhudejun/text_summary/text_summary/pgn/data/train.txt... 69999 pairs.
9.546478748321533 secs used for initalize predicter
vocab_size: 20004
source: 2016 款 9.5 代 本田雅阁 智尊版 2.4 排量 , 2017 年 5 月份 提 车 。 现在 5 200 公里 , 首保加 4S店 全 合成 机油 。 马上 进行 二保 , 听说 全 合成 机油 100 00 公里 再 更换 , 当前 5 200 公里 , 想 请问 大师 , 以上 情况 , 需要 更换 机油 机油 格 ? ! 全 合成 机油 主要 提炼 过程 矿物油 好 , 不 建议 使用 1 万公里 , 发动机 没有 太大 危害 , 后期 运行 中 , 积碳 油泥 始终 会 产生 。 建议 用全 合成 机油 时 , 750 0 公里 更换 保养 一次 保养 间隔 公里 数 时间 计算 , 现在 5 200 公里 , 下次 保养 里程 数 750 0 , 12700 保养 。 时间 间隔 半年 我首保 公里 读数 582 公里 , 全 合成 机油 , 8082 公里 再 更换 机油 机油 格 ? , 全 合成 机油 , 不能 太久 , 使用 太久 , 全 合成 机油 性能 下降 以后 , 油泥 积碳 产生 普通 油 5000 公里 更换 , 产生 油泥 , 发动机 没有 好处 好 , 明白 。 二保 暂时 不 更换 机油 机油 格 , 4S 可能 会 说 车主 自行 脱保 。 至少 公里 数 保养 , 不会 , 首保 师傅 清洗 节气门 , 不 愿意 , 是否 每次 保养 都 清洗 节气门 ? 2 3 万公里 清洗 一次 4S 师傅 清洗 节气门 , 都 不 愿意 , 问题 , 说 车主 驾驶 习惯 不良 造成 积碳 , 油耗 过高 问题 清洗 节气门 收费 , 知道 不用 每次 保养 都 清洗 好 好 谢谢 李师傅 不 客气


ref: 用全 合成 机油 , 换油 周期 最多 750 0 公里 间隔 。


0.05549979209899902 secs used for doing prediction
greedy: 全 合成 机油 一年 一万公里 更换 一次 , 全 合成 机油 一年 一万公里 更换 一次 , 全 合成 机油


0.24428892135620117 secs used for doing prediction
beam: 这款 车 全 合成 机油 一年 750 0 公里 一年 保养 一次 , 后期 维护 保养 周期 750 0 公里
模型预测结论: 对于很短的人工摘要, 有些时候greedy decode会得到更好的结果; 对于稍长的人工摘要, 还是beam search的摘要更合理, 语义更丰富准确, 也减少了重复性.

利用ROUGE评估baseline-4模型¶
对PGN的baseline-4模型进行评估¶
在对baseline-4模型进行评估前, 先明确两个最重要的参数设置. - max_dec_steps: 最大解码长度

beam_size: 采用beam-search策略时的搜索宽度

代码文件路径: /home/ec2-user/text_summary/pgn/utils/config.py

最大解码长度设置为30, 这个超参数对结果摘要也有较大影响.

max_dec_steps=30

beam-search的搜索宽度, 对解码的效率, 效果影响很大, 建议在2, 3, 4中选择, 不要更大.

beam_size=3
评估代码没有任何变化, 完全照搬baseline-3的源代码即可: - 代码文件路径: /home/ec2-user/text_summary/pgn/src/rouge_eval.py

import os
import sys
from rouge import Rouge
import jieba

root_path = os.path.dirname(os.path.dirname(os.path.abspath(file)))
sys.path.append(root_path)

from src.predict import Predict
from utils.func_utils import timer
from utils import config

class RougeEval():
def init(self, path):
self.path = path
self.scores = None
self.rouge = Rouge()
self.sources = []
self.hypos = []
self.refs = []
self.process()

def process(self):print('Reading from ', self.path)with open(self.path, 'r') as test:for line in test:source, ref = line.strip().split('<SEP>')ref = ref.replace('。', '.')self.sources.append(source)self.refs.append(ref)print('self.refs[]包含的样本数: ', len(self.refs))print(f'Test set contains {len(self.sources)} samples.')@timer('building hypotheses')
def build_hypos(self, predict):# Generate hypos for the dataset.print('Building hypotheses.')count = 0for source in self.sources:count += 1if count % 1000 == 0:print('count=', count)self.hypos.append(predict.predict(source.split()))def get_average(self):assert len(self.hypos) > 0, 'Build hypotheses first!'print('Calculating average rouge scores.')return self.rouge.get_scores(self.hypos, self.refs, avg=True)def one_sample(self, hypo, ref):return self.rouge.get_scores(hypo, ref)[0]

调用:

真实的测试机是val_data_path: dev.txt

print('实例化Rouge对象......')
rouge_eval = RougeEval(config.val_data_path)
print('实例化Predict对象......')
predict = Predict()

利用模型对article进行预测

print('利用模型对article进行预测, 并通过Rouge对象进行评估......')
rouge_eval.build_hypos(predict)

将预测结果和标签abstract进行ROUGE规则计算

print('开始用Rouge规则进行评估......')
result = rouge_eval.get_average()
print('rouge1: ', result['rouge-1'])
print('rouge2: ', result['rouge-2'])
print('rougeL: ', result['rouge-l'])

最后将计算评估结果写入文件中

print('将评估结果写入结果文件中......')
with open('../eval_result/rouge_result.txt', 'a') as f:
for r, metrics in result.items():
f.write(r + '\n')
for metric, value in metrics.items():
f.write(metric + ': ' + str(value * 100) + '\n')
执行:

cd /home/ec2-user/text_summary/pgn/src/

python rouge_eval.py
输出结果:

实例化Rouge对象......
Reading from /home/ec2-user/ec2-user/zhudejun/text_summary/text_summary/pgn/data/dev.txt
self.refs[]包含的样本数: 3000
Test set contains 3000 samples.
实例化Predict对象......
Reading dataset /home/ec2-user/ec2-user/zhudejun/text_summary/text_summary/pgn/data/train.txt... 69999 pairs.
9.540595769882202 secs used for initalize predicter
利用模型对article进行预测, 并通过Rouge对象进行评估......
Building hypotheses.
0.3612496852874756 secs used for doing prediction
0.3378407955169678 secs used for doing prediction
0.33907222747802734 secs used for doing prediction
0.33692336082458496 secs used for doing prediction
0.34388184547424316 secs used for doing prediction
0.3351857662200928 secs used for doing prediction
0.3377809524536133 secs used for doing prediction
0.35638427734375 secs used for doing prediction
0.35387086868286133 secs used for doing prediction
0.3497180938720703 secs used for doing prediction
0.33884239196777344 secs used for doing prediction
0.33420300483703613 secs used for doing prediction
0.34486865997314453 secs used for doing prediction
0.33138418197631836 secs used for doing prediction
0.3341488838195801 secs used for doing prediction
0.3463015556335449 secs used for doing prediction
0.3333241939544678 secs used for doing prediction
0.338392972946167 secs used for doing prediction
0.35277867317199707 secs used for doing prediction
0.35438013076782227 secs used for doing prediction

......
......
......

0.3353769779205322 secs used for doing prediction
0.34035539627075195 secs used for doing prediction
0.38248276710510254 secs used for doing prediction
0.33664369583129883 secs used for doing prediction
0.3500978946685791 secs used for doing prediction
0.3417830467224121 secs used for doing prediction
0.3423631191253662 secs used for doing prediction
0.350400447845459 secs used for doing prediction
0.3503289222717285 secs used for doing prediction
0.3484373092651367 secs used for doing prediction
0.33926868438720703 secs used for doing prediction
0.33957529067993164 secs used for doing prediction
0.3400390148162842 secs used for doing prediction
1017.7808394432068 secs used for building hypotheses
开始用Rouge规则进行评估......
Calculating average rouge scores.
rouge1: {'f': 0.2237876211830499, 'p': 0.18118834397125683, 'r': 0.3841843120305152}
rouge2: {'f': 0.07272050441375325, 'p': 0.05774983504844641, 'r': 0.1346595763700352}
rougeL: {'f': 0.27544194926579235, 'p': 0.2864224165624092, 'r': 0.3228687273073168}
将评估结果写入结果文件中......
查看结果文件:

cd /home/ec2-user/text_summary/pgn/eval_result/

cat rouge_result.txt
输出结果:

rouge-1
f: 22.37876211830499
p: 18.11883439712568
r: 38.41843120305152
rouge-2
f: 7.272050441375325
p: 5.774983504844641
r: 13.46595763700352
rouge-l
f: 27.544194926579234
p: 28.64224165624092
r: 32.28687273073168
ROUGE评估结果对比¶
将baseline-2, baseline-3, baseline-4模型在同样的测试集上的表现展示在一起:

左侧是baseline-2模型的表现, 中间是baseline-3模型的表现, 右侧是baseline-4模型的表现

----------------------------------------------------------------------------------

baseline-2 baseline-3 baseline-4

PGN PGN + coverage PGN + coverage + beam-search

----------------------------------------------------------------------------------

rouge-1 rouge-1 rouge-1
f: 22.4840567492723 f: 23.891369972642877 f: 22.37876211830499
p: 22.48892685096682 p: 27.884799447410742 p: 18.11883439712568
r: 36.152107385286506 r: 32.746511025920974 r: 38.41843120305152

----------------------------------------------------------------------------------

rouge-2 rouge-2 rouge-2
f: 7.6855293213852995 f: 8.145260844937683 f: 7.272050441375325
p: 7.633592816230374 p: 9.507772545428752 p: 5.774983504844641
r: 12.992180038436171 r: 11.403972188579534 r: 13.46595763700352

----------------------------------------------------------------------------------

rouge-l rouge-l rouge-l
f: 29.79190839597577 f: 28.112397619052885 f: 27.544194926579234
p: 35.022548049923934 p: 35.51625333014313 p: 28.64224165624092
r: 31.245311823639828 r: 28.567520633785175 r: 32.28687273073168
测试结果对比分析: 对比这3个模型的评估结果, 可以看出来采用beam-search解码的三个指标, 精确率p都有较大的下滑, 同时召回率r都有较大的提升. 同学们可以仔细思考一下这里面的原因是什么? 然后以此为出发点, 再次踏上模型优化的征途~

小节总结¶
Beam-search算法: - Beam-search的概念: 既不采用最大值贪心策略, 也不采用全局搜索策略, 而是折中一个k值, 将时间复杂度降低至O(T * K * N).

区别于Viterbi算法的动态规划全局搜索, Beam-search在时间效率上还是更高的. 但同时Beam-search无法保证得到全局最优解, 只是在较高的算法效率下得到一个较优解, 更适应工程上的需求.

Beam-search策略的代码实现: - 配置文件的添加: 在config.py中添加关于beam-search的部分, 最重要是beam_size.

预测类Predict的修改: 增加类内函数用以支持beam-search的解码.

Beam类的构建: 在工具类函数func_utils.py中添加Beam类的代码.

ROUGE评估baseline-4模型: - 在精确率p指标上有较大的下滑.

在召回率r指标上有较大的提升.

分析: 精确率p下滑恰恰说明了采用beam-search策略生成的摘要更丰富, 不拘泥于原文表意; 同时召回率r提升恰恰说明了采用beam-search策略生成的摘要包含了更多关键的语义信息, 不遗漏重点内容, 搜索范围更广.

6.3 数据增强的优化
从数据角度优化模型¶
学习目标¶
理解几种重要的数据优化策略.

掌握相应数据优化策略的代码实现.

数据优化策略简介¶
人工智能从本质上就是"数据" + "算法" + "算力"的合成体. 算法基本全世界公开化, 算力在国内的大环境下也不是太大的难题, 很多项目最终能否上线, 以及能否在竞争中杀出重围的关键就落在"数据"上.

曾经有一家德国的小型AI创业公司, 在若干NLP任务上取得了比谷歌更棒的效果, 究其原因就在于研究团队将70%以上的精力放在优化数据上, 这家德国公司在数据标注上做到了非常精深的地步, 从而让模型"吃饱吃好", 学到更"优质的特征".

少样本问题: 除了从源头上优化标注数据, 在NLP领域经常面临的一个困扰就是"少样本问题", 尤其在最值钱的金融, 医疗, 法律等垂直领域, 更是缺乏高质量的标注语料. 因此数据优化的很大一部分内容即是"数据增强技术". 目前工业界主要应用三种方法: - 单词替换法

回译数据法

半监督学习法

单词替换法¶
TF-IDF算法介绍¶
首先思考一个问题: 如何在一篇很长的文章中, 完全不加以人工干预的提取到它的关键词?
avatar

TF-IDF算法介绍: 一个很清晰的思路是要找到标准来衡量什么特点的单词是关键词? - TF算法: 词频(Term Frequency)

IDF算法: 逆文档频率(Inverse Document Frequency)

TF算法: 词频(Term Frequency) - 如果一个单词很重要, 那么最直观的想法是这个单词在整篇文章中出现的次数比较多, 这也就是词频的来源.

TF = 某个词在文章中出现的次数/该文章的总词数

一个词在文章中出现次数越多, TF值越大.

IDF算法: 逆文档频率(Inverse Document Frequency) - 同时要考虑到一个现实情况, 那就是"的", "是", "了"这样的单词出现次数一定特别多, 但是对于文章来说肯定不是关键词, 原因就是这些词"太普遍了"! 我们不仅仅要考察哪些词出现次数多, 同时还要考虑这些词在总体文档中是否普遍出现.

IDF = log(语料库的文档总数/(包含该单词的文档数 + 1))

一个词越常见, 那么分母就越大, log函数内值越接近1, IDF值越小.

TF-IDF算法: - TFIDF = TF * IDF

这里用到了乘法效应, 单词出现次数越大, 同时越不普遍, 则TF和IDF都大, 则TFIDF也越大.

如果单词出现次数很多, 但是属于很常见的词, 则TF大同时IDF小, 乘积得到一个中和效应.

利用gensim实现TF-IDF算法.

利用gensim为工具包实现TF-IDF, 需要依次执行下面的几个步骤: - 第一步: 完成语句的分词.

第二步: 构造映射字典.

第三步: 训练得到TF-IDF模型.

第四步: 加载模型完成文本向量化.

代码文件路径: /home/ec2-user/text_summary/pgn/optim/demo.py

第一步: 完成语句的分词.

导入gensim中的字典和模型工具包

from gensim.corpora import Dictionary
from gensim import models

模拟一段输入文本数据

corpus = ['this is the first document',
'this is the second second document',
'and the third one',
'is this the first document'
]

手动分词并构造列表

word_list = []
for i in range(len(corpus)):
word_list.append(corpus[i].split(' '))

print(word_list)
调用:

cd /home/ec2-user/text_summary/pgn/optim/

python demo.py
输出结果:

[['this', 'is', 'the', 'first', 'document'],
['this', 'is', 'the', 'second', 'second', 'document'],
['and', 'the', 'third', 'one'],
['is', 'this', 'the', 'first', 'document']]
第二步: 构造映射字典.

导入gensim中的字典和模型工具包

from gensim.corpora import Dictionary
from gensim import models

模拟一段输入文本数据

corpus = ['this is the first document',
'this is the second second document',
'and the third one',
'is this the first document'
]

手动分词并构造列表

word_list = []
for i in range(len(corpus)):
word_list.append(corpus[i].split(' '))

print(word_list)
print('*******************************')

将分词后的列表结构送入字典类中, 得到实例化对象.

本质作用是给语料库中的每个词(不重复的词)赋值一个整数id

dictionary = Dictionary(word_list)

调用.doc2bow()方法即可得到文本的字典映射.

结果元组中的第一个元素是单词在词典中对应的id, 第二个元素是单词在文档中出现的次数.

new_corpus = [dictionary.doc2bow(text) for text in word_list]

print(new_corpus)
print('*******************************')

将单词到数字的映射字典打印出来.

print(dictionary.token2id)
调用:

cd /home/ec2-user/text_summary/pgn/optim/

python demo.py
输出结果:

[['this', 'is', 'the', 'first', 'document'], ['this', 'is', 'the', 'second', 'second', 'document'], ['and', 'the', 'third', 'one'], ['is', 'this', 'the', 'first', 'document']]


[[(0, 1), (1, 1), (2, 1), (3, 1), (4, 1)], [(0, 1), (2, 1), (3, 1), (4, 1), (5, 2)], [(3, 1), (6, 1), (7, 1), (8, 1)], [(0, 1), (1, 1), (2, 1), (3, 1), (4, 1)]]


{'document': 0, 'first': 1, 'is': 2, 'the': 3, 'this': 4, 'second': 5, 'and': 6, 'one': 7, 'third': 8}
第三步: 训练得到TF-IDF模型.

导入gensim中的字典和模型工具包

from gensim.corpora import Dictionary
from gensim import models

模拟一段输入文本数据

corpus = ['this is the first document',
'this is the second second document',
'and the third one',
'is this the first document'
]

手动分词并构造列表

word_list = []
for i in range(len(corpus)):
word_list.append(corpus[i].split(' '))

将分词后的列表结构送入字典类中, 得到实例化对象.

dictionary = Dictionary(word_list)

调用.doc2bow()方法即可得到文本的字典映射.

结果元组中的第一个元素是单词在词典中对应的id, 第二个元素是单词在文档中出现的次数.

new_corpus = [dictionary.doc2bow(text) for text in word_list]

将单词到数字的映射字典打印出来.

print(dictionary.token2id)

将由字典Dictionary生成的数字化文档作为参数, 传入模型类中训练TF-IDF模型

tfidf = models.TfidfModel(new_corpus)

保存训练好的模型并打印

tfidf.save('demo.tfidf')
print(tfidf)
调用:

cd /home/ec2-user/text_summary/pgn/optim/

python demo.py
输出结果:

{'document': 0, 'first': 1, 'is': 2, 'the': 3, 'this': 4, 'second': 5, 'and': 6, 'one': 7, 'third': 8}


TfidfModel(num_docs=4, num_nnz=19)
第四步: 加载模型完成文本向量化.

导入gensim中的字典和模型工具包

from gensim.corpora import Dictionary
from gensim import models

模拟一段输入文本数据

corpus = ['this is the first document',
'this is the second second document',
'and the third one',
'is this the first document'
]

手动分词并构造列表

word_list = []
for i in range(len(corpus)):
word_list.append(corpus[i].split(' '))

将分词后的列表结构送入字典类中, 得到实例化对象.

dictionary = Dictionary(word_list)

调用.doc2bow()方法即可得到文本的字典映射.

结果元组中的第一个元素是单词在词典中对应的id, 第二个元素是单词在文档中出现的次数.

new_corpus = [dictionary.doc2bow(text) for text in word_list]

将第三步保存的模型加载进内存

model = models.TfidfModel.load('demo.tfidf')

将原始文本利用训练好的TF-IDF模型进行数字化映射.

tfidf_vec = []
for i in range(len(corpus)):
s = corpus[i]
s_bow = dictionary.doc2bow(s.lower().split())
s_tfidf = model[s_bow]
tfidf_vec.append(s_tfidf)

print(tfidf_vec)
输出结果:

[[(0, 0.33699829595119235), (1, 0.8119707171924228), (2, 0.33699829595119235), (4, 0.33699829595119235)],
[(0, 0.10212329019650272), (2, 0.10212329019650272), (4, 0.10212329019650272), (5, 0.9842319344536239)],
[(6, 0.5773502691896258), (7, 0.5773502691896258), (8, 0.5773502691896258)],
[(0, 0.33699829595119235), (1, 0.8119707171924228), (2, 0.33699829595119235), (4, 0.33699829595119235)]]
思考: 上面最后得到的映射文档有何特点? 有没有什么问题?

疑问点: 生成的向量在维度上并不和原始文本单词数匹配! 而我们想得到每一个词的TF-IDF值, 这是什么原因呢? 我们通过一个测试代码来一探究竟:

导入gensim中的字典和模型工具包

from gensim.corpora import Dictionary
from gensim import models

模拟一段输入文本数据

corpus = ['this is the first document',
'this is the second second document',
'and the third one',
'is this the first document'
]

手动分词并构造列表

word_list = []
for i in range(len(corpus)):
word_list.append(corpus[i].split(' '))

将分词后的列表结构送入字典类中, 得到实例化对象.

dictionary = Dictionary(word_list)

将保存的模型加载进内存

model = models.TfidfModel.load('demo.tfidf')

随便给一行文本进行测试, 看看输出的张量有何特点.

s = 'the i first second name'
s_bow = dictionary.doc2bow(s.lower().split())
s_tfidf = model[s_bow]
print(s_tfidf)
调用:

cd /home/ec2-user/text_summary/pgn/optim/

python demo.py
输出结果:

从输出结果看, 只输出了first, second两个单词对应的id和TF-IDF的值

[(1, 0.4472135954999579), (5, 0.8944271909999159)]
分析结果:

1: gensim有自动去除停用词的功能, 比如the
2: gensim会自动去除单个字母, 比如i
3: gensim会去除没有在训练集中的词, 比如name
结论: 综上得到最重要的一条结论, gensim并不能计算每个单词的TF-IDF值!

纯Python代码实现TF-IDF算法: - 代码文件路径: /home/ec2-user/text_summary/pgn/optim/demo1.py

from collections import Counter
import math

corpus = ['this is the first document',
'this is the second second document',
'and the third one',
'is this the first document'
]

word_list = []
for i in range(len(corpus)):
word_list.append(corpus[i].split(' '))

print(word_list)
print('**********************************')
countlist = []
for i in range(len(word_list)):
count = Counter(word_list[i])
countlist.append(count)

print(countlist)
print('**********************************')

word可以通过count得到, count可以通过countlist得到

count[word]可以得到每个单词的词频, sum(count.values())得到整个句子的单词总数

def tf(word, count):
return count[word] / sum(count.values())

统计的是含有该单词的句子数

def n_containing(word, count_list):
return sum(1 for count in count_list if word in count)

len(count_list)是指句子的总数

n_containing(word, count_list)是指含有该单词的句子的总数, 加1是为了防止分母为0

def idf(word, count_list):
return math.log(len(count_list) / (1 + n_containing(word, count_list)))

将tf和idf相乘

def tfidf(word, count, count_list):
return tf(word, count) * idf(word, count_list)

循环遍历countlist, 并计算每个词的TF-IDF值.

for i, count in enumerate(countlist):
print("Top words in document {}".format(i + 1))
scores = {word: tfidf(word, count, countlist) for word in count}
sorted_words = sorted(scores.items(), key=lambda x: x[1], reverse=True)
for word, score in sorted_words[:]:
print("\tWord: {}, TF-IDF: {}".format(word, round(score, 5)))
调用:

cd /home/ec2-user/text_summary/pgn/optim/

python demo1.py
输出结果:

[['this', 'is', 'the', 'first', 'document'],
['this', 'is', 'the', 'second', 'second', 'document'],
['and', 'the', 'third', 'one'],
['is', 'this', 'the', 'first', 'document']]


[Counter({'this': 1, 'is': 1, 'the': 1, 'first': 1, 'document': 1}),
Counter({'second': 2, 'this': 1, 'is': 1, 'the': 1, 'document': 1}),
Counter({'and': 1, 'the': 1, 'third': 1, 'one': 1}),
Counter({'is': 1, 'this': 1, 'the': 1, 'first': 1, 'document': 1})]


Top words in document 1
Word: first, TF-IDF: 0.05754
Word: this, TF-IDF: 0.0
Word: is, TF-IDF: 0.0
Word: document, TF-IDF: 0.0
Word: the, TF-IDF: -0.04463
Top words in document 2
Word: second, TF-IDF: 0.23105
Word: this, TF-IDF: 0.0
Word: is, TF-IDF: 0.0
Word: document, TF-IDF: 0.0
Word: the, TF-IDF: -0.03719
Top words in document 3
Word: and, TF-IDF: 0.17329
Word: third, TF-IDF: 0.17329
Word: one, TF-IDF: 0.17329
Word: the, TF-IDF: -0.05579
Top words in document 4
Word: first, TF-IDF: 0.05754
Word: is, TF-IDF: 0.0
Word: this, TF-IDF: 0.0
Word: document, TF-IDF: 0.0
Word: the, TF-IDF: -0.04463
训练TF-IDF模型¶
训练TF-IDF模型: 为了提高编码和训练效率, 我们采用gensim来辅助训练TF-IDF模型. - 目的: 利用训练好的模型帮助我们自动提取关键词, 以在单词替换时使用.

代码文件路径: /home/ec2-user/text_summary/pgn/optim/train_tfidf.py

导入工具包并设置项目的root路径, 方便后续相关代码文件的导入

import os
import sys
root_path = os.path.dirname(os.path.dirname(os.path.abspath(file)))
sys.path.append(root_path)

导入项目配置信息

from utils.config import *

导入gensim相关工具包

from gensim.corpora import Dictionary
from gensim import models
from gensim.models import word2vec

指定训练集数据路径, 这里采用70000条完整训练集数据进行TF-IDF模型的训练.

data_path = root_path + '/data/train.txt'

word_list = []
n = 0
with open(data_path, 'r', encoding='utf-8') as f:
for line in f.readlines():
article, abstract = line.strip('\n').split('')
text = article + ' ' + abstract
word_list.append(text.split(' '))
n += 1
if n % 10000 == 0:
print('n=', n)

print('n=', n)
print('word_list=', len(word_list))
print('***********************************')

print('开始创建数字化字典......')
dictionary = Dictionary(word_list)
new_corpus = [dictionary.doc2bow(text) for text in word_list]

saved_path = root_path + '/tf_idf/'
print('开始训练TF-IDF模型......')
tfidf = models.TfidfModel(new_corpus)

tfidf.save(saved_path + 'text_summary_baseline-5.tfidf')
print('保存TF-IDF模型完毕, 文件为text_summary_baseline-5.tfidf')

dictionary.save(saved_path + 'text_summary_baseline-5.dict')
print('保存TF-IDF字典完毕, 文件为text_summary_baseline-5.dict')
调用:

cd /home/ec2-user/text_summary/pgn/optim/train_tfidf.py

python train_tfidf.py
输出结果:

n= 10000
n= 20000
n= 30000
n= 40000
n= 50000
n= 60000
n= 70000
n= 70000
word_list= 70000


开始创建数字化字典......
开始训练TF-IDF模型......
保存TF-IDF模型完毕, 文件为text_summary_baseline-5.tfidf
保存TF-IDF字典完毕, 文件为text_summary_baseline-5.dict
查看保存的模型:

cd /home/ec2-user/text_summary/pgn/tf_idf

ll
输出结果:

-rw-rw-r-- 1 ec2-user ec2-user 3231294 3月 14 07:42 text_summary_baseline-5.dict
-rw-rw-r-- 1 ec2-user ec2-user 6219208 3月 14 07:42 text_summary_baseline-5.tfidf
构建替换单词的类代码¶
辅助工具函数: - 代码文件路径: /home/ec2-user/text_summary/pgn/optim/data_utils.py

import os
import sys

root_path = os.path.dirname(os.path.dirname(os.path.abspath(file)))
sys.path.append(root_path)

def read_samples(filename):
samples = []
with open(filename, 'r', encoding='utf-8') as f:
for line in f:
samples.append(line.strip('\n'))
return samples

def write_samples(samples, file_path, opt='w'):
with open(file_path, opt, encoding='utf-8') as f:
for line in samples:
f.write(line + '\n')

def isChinese(word):
for ch in word:
if '\u4e00' <= ch <= '\u9fff':
return True

return False

单词替换的核心类代码: - 代码文件路径: /home/ec2-user/text_summary/pgn/optim/word_replace.py

from gensim.models import KeyedVectors, TfidfModel
from gensim.corpora import Dictionary
from data_utils import read_samples, isChinese, write_samples
from gensim import matutils
from gensim.models.word2vec import Word2Vec
from itertools import islice
import numpy as np

import os
import sys
root_path = os.path.dirname(os.path.dirname(os.path.abspath(file)))
sys.path.append(root_path)

class EmbedReplace():
def init(self, sample_path, wv_path):
self.samples = read_samples(sample_path)
self.refs = [sample.split('')[1].split(' ') for sample in self.samples]
self.word2vector = Word2Vec.load(wv_path)

    self.tfidf_model = TfidfModel.load(root_path + '/tf_idf/text_summary_baseline-5.tfidf')self.dict = Dictionary.load(root_path + '/tf_idf/text_summary_baseline-5.dict')self.corpus = [self.dict.doc2bow(doc) for doc in self.refs]self.vocab_size = len(self.dict.token2id)def extract_keywords(self, dic, tfidf, threshold=0.2, topk=5):tfidf = sorted(tfidf, key=lambda x: x[1], reverse=True)return list(islice([dic[w] for w, score in tfidf if score > threshold], topk))def replace(self, token_list, doc):keywords = self.extract_keywords(self.dict, self.tfidf_model[doc])num = int(len(token_list) * 0.3)new_tokens = token_list.copy()while num == int(len(token_list) * 0.3):indexes = np.random.choice(len(token_list), num)for index in indexes:token = token_list[index]if isChinese(token) and token not in keywords and token in self.word2vector.wv:new_tokens[index] = self.word2vector.wv.most_similar(positive=token, negative=None, topn=1)[0][0]num -= 1return ' '.join(new_tokens)def generate_samples(self, write_path):replaced = []count = 0for sample, token_list, doc in zip(self.samples, self.refs, self.corpus):count += 1if count % 2000 == 0:print('count=', count)write_samples(replaced, write_path, 'a')replaced = []replaced.append(sample.split('<SEP>')[0] + '<SEP>' + self.replace(token_list, doc))

调用:

if name == 'main':
sample_path = root_path + '/data/train.txt'
wv_path = root_path + '/wv/word2vec.model'
replacer = EmbedReplace(sample_path, wv_path)
replacer.generate_samples(root_path + '/data/optim_word_replaced.txt')
输出结果:

count= 2000
count= 4000
count= 6000
count= 8000
count= 10000
count= 12000
count= 14000
count= 16000
count= 18000
count= 20000
count= 22000
count= 24000
count= 26000
count= 28000
count= 30000
count= 32000
count= 34000
count= 36000
count= 38000
count= 40000
count= 42000
count= 44000
count= 46000
count= 48000
count= 50000
count= 52000
count= 54000
count= 56000
count= 58000
count= 60000
count= 62000
count= 64000
count= 66000
count= 68000
count= 70000
对比训练集数据:

首先查看原始训练集数据

cd /home/ec2-user/text_summary/pgn/data/
vim train.txt

然后查看替换后新生成的数据

cd /home/ec2-user/text_summary/pgn/data/
vim optim_word_replaced.txt
对比结果(原始训练集): /home/ec2-user/text_summary/pgn/data/train.txt

丰田 花冠 行驶 十万 公里 皮带 要换 正 时 皮带 , 这种 车型 最 10 万公里 , 最好 更换 一次 。 皮带 时间 长 会 >出现 老化 , 出现 断裂 , 会 损坏 发动机 , 造成 车辆 抛锚 。 发电机 皮带 都 需要 检查一下 , 最 6 8 万公里 。需要 , 检查 , 更换 新件 。现在 公里 数 , 最好 检查一下 皮带 , 应该 老化 现象 , 皮带 。 寿命 8 10 万公里 。 建议 最好 更换 一下 。

大师 好 ! 新 时代 全顺 , 中 门板 手 钱 ? 右门 玻璃 升降 开关 钱 ? 拉手 价格 60 元 , 开关 60 元 , 拿货 价格拉手 升降 开关 价格 120 左右 , 进货价格

昌河Q35 音响 拆装 发个 中控 看看 圈起来 面板 翘出来 不行 如图所示 没有 螺丝 新车 紧翘 断 不 出来 绿色 圈 先 下来 上边 螺丝 没有 拆完 内部 卡扣 固定面板 内部 卡子 固定 , 没有 固定 螺丝

长安 35 朝阳 轮胎 不要 里面 钢圈 。 钢圈 钱 外面 换 钱 意思 只 更换 新 轮胎 。 只 更换 单独 轮胎 , 价格 350 >块钱 左右 。 只换 轮胎 皮皮 轮毂 350 , 轮胎 品牌 轮胎 , 轮胎 型号 比较 大 , 说 价格 都 比较 贵 , 最 便宜 >。 网上 购买 , 200 , 地区 不 , 价格 略有 浮动 。 额额 谢谢 回答 ? ? 晓得 。 不 客气 ! 祝您 用车 愉快 !单独 购买 轮胎 , 车胎 型号 比较 大 卖 轮胎 地方 , 去 修理厂 费用 350 左右 , 网购 , 更换 比较 便宜 一点。
对比结果(新替换的训练集): /home/ec2-user/text_summary/pgn/data/optim_word_replaced.txt

丰田 花冠 行驶 十万 公里 皮带 要换 正 时 皮带 , 这种 车型 最 10 万公里 , 最好 更换 一次 。 皮带 时间 长 会 >出现 老化 , 出现 断裂 , 会 损坏 发动机 , 造成 车辆 抛锚 。 发电机 皮带 都 需要 检查一下 , 最 6 8 万公里 。需要 , 检查 , 更换 新件 。现在 公里 数 , 最好 检查一下 皮带 , 应该 老化 现象 , 皮带 。 寿命 8 10 万公里 。 建议您 最好 更换 清醒 。

大师 好 ! 新 时代 全顺 , 中 门板 手 钱 ? 右门 玻璃 升降 开关 钱 ? 拉手 价格 60 元 , 开关 60 元 , 拿货 价格拉手 升降 开关 价格 120 左右 , 进货价格

昌河Q35 音响 拆装 发个 中控 看看 圈起来 面板 翘出来 不行 如图所示 没有 螺丝 新车 紧翘 断 不 出来 绿色 圈 先 下来 上边 螺丝 没有 拆完 内部 卡扣 固定面板 内部 卡子 固定 , 没 固定 螺丝

长安 35 朝阳 轮胎 不要 里面 钢圈 。 钢圈 钱 外面 换 钱 意思 只 更换 新 轮胎 。 只 更换 单独 轮胎 , 价格 350 >块钱 左右 。 只换 轮胎 皮皮 轮毂 350 , 轮胎 品牌 轮胎 , 轮胎 型号 比较 大 , 说 价格 都 比较 贵 , 最 便宜 >。 网上 购买 , 200 , 地区 不 , 价格 略有 浮动 。 额额 谢谢 回答 ? ? 晓得 。 不 客气 ! 祝您 用车 愉快 !单独 购买 轮胎 , 车胎 型号 比较 大才 卖 轮胎 地方 , 去 修理厂 费用 350 左右 , 网购 , 换 公认 贵 一点 >。
结论: 通过单词替换后, 有的样本摘要完全和之前一样, 有的样本摘要发生了几个单词的变化. 而且有的变化合理, 有的变化目测并不合理, 后续需要对代码流程继续调优. 这里对同学们最重要的是获取一个新的优化思路, 同时掌握新的代码实践方法, 未来面对新的项目反复迭代试验就好. 同时思考一个问题: 面对单词替换的bad case分析, 后续你如何调优?

训练baseline-5模型¶
有了增强后的数据集, 就可以在baseline-4模型的基础上优化训练baseline-5模型了: - 注意: 整个训练相关的代码完全和baseline-4模型一模一样, 仅有train.py调用中稍作修改.

代码文件路径: /home/ec2-user/text_summary/pgn/src/train.py

if name == "main":
# Prepare dataset for training.
DEVICE = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
print('DEVICE: ', DEVICE)

# -------------------------------------------------------------------------------------
# baseline-5模型只在下面这一块对baseline-4稍作修改.
# 将构建数据集的路径改为config.train_data_optim_1_path, 即融合了经过单词替换后的训练集
# 相比于baseline-4训练集的7万条样本, 在baseline-5中被扩充成了14万条样本.
dataset = PairDataset(config.train_data_optim_1_path,max_enc_len=config.max_enc_len,max_dec_len=config.max_dec_len,truncate_enc=config.truncate_enc,truncate_dec=config.truncate_dec)
# -------------------------------------------------------------------------------------val_dataset = PairDataset(config.val_data_path,max_enc_len=config.max_enc_len,max_dec_len=config.max_dec_len,truncate_enc=config.truncate_enc,truncate_dec=config.truncate_dec)vocab = dataset.build_vocab(embed_file=config.embed_file)train(dataset, val_dataset, vocab, start_epoch=0)

调用:

因为模型训练要持续10多个小时, 直接放到后台, 以非中断模式训练.

nohup python train.py &
输出结果:

DEVICE: cuda
Reading dataset /home/ec2-user/text_summary/pgn/data/train_word_optim.txt... 140000 pairs.
Reading dataset /home/ec2-user/text_summary/pgn/data/dev.txt...
[[0%| | 0/5 [00:00<?, ?it/s]
[[0%| | 0/43 [00:00<?, ?it/s]
[[0%| | 0/4375 [00:00<?, ?it/s]
[[Epoch 0: 0%| | 0/43 [00:02<?, ?it/s]
[[Epoch 0: 0%| | 0/43 [00:02<?, ?it/s, Batch=0, Loss=6.66]
[[Epoch 0: 2%|▏ | 1/43 [00:02<01:46, 2.54s/it, Batch=0, Loss=6.66]
[[0%| | 1/4375 [00:02<3:05:02, 2.54s/it]
[[0%| | 2/4375 [00:04<2:46:21, 2.28s/it]
[[0%| | 3/4375 [00:06<2:49:43, 2.33s/it]
[[0%| | 4/4375 [00:08<2:34:36, 2.12s/it]
[[0%| | 5/4375 [00:09<2:13:39, 1.84s/it]

......
......
......

[[91%|█████████▏| 85/93 [00:55<00:05, 1.49it/s]
[[92%|█████████▏| 86/93 [00:55<00:04, 1.60it/s]
[[94%|█████████▎| 87/93 [00:56<00:03, 1.67it/s]
[[95%|█████████▍| 88/93 [00:56<00:02, 1.68it/s]
[[96%|█████████▌| 89/93 [00:57<00:02, 1.57it/s]
[[97%|█████████▋| 90/93 [00:58<00:01, 1.60it/s]
[[98%|█████████▊| 91/93 [00:58<00:01, 1.62it/s]
[[99%|█████████▉| 92/93 [00:59<00:00, 1.83it/s]
[[100%|██████████| 93/93 [00:59<00:00, 1.77it/s]
[[100%|██████████| 93/93 [00:59<00:00, 1.55it/s]
[[0%| | 0/43 [00:00<?, ?it/s]
[[0%| | 0/4375 [00:00<?, ?it/s]
[[Epoch 1: 0%| | 0/43 [00:01<?, ?it/s]
[[Epoch 1: 0%| | 0/43 [00:01<?, ?it/s, Batch=0, Loss=3.68]
[[Epoch 1: 2%|▏ | 1/43 [00:01<00:54, 1.29s/it, Batch=0, Loss=3.68]
[[0%| | 1/4375 [00:01<1:33:58, 1.29s/it]
[[0%| | 2/4375 [00:02<1:36:24, 1.32s/it]

......
......
......

[[98%|█████████▊| 91/93 [00:57<00:01, 1.57it/s]
[[99%|█████████▉| 92/93 [00:58<00:00, 1.73it/s]
[[100%|██████████| 93/93 [00:59<00:00, 1.48it/s]
[[100%|██████████| 93/93 [00:59<00:00, 1.57it/s]
Epoch 4: 100%|██████████| 5/5 [10:29:52<00:00, 7558.48s/it, Loss=2.24]
2999 pairs.
loading data......
initializing optimizer......
model_name = pgn_model, pointer = True, coverage = True, fine_tune = False, scheduled_sampling = False, weight_tying = False,source = train
teacher_forcing = True
validating
training loss:4.2041042657034735 validation loss:4.174842016671294
model_name = pgn_model, pointer = True, coverage = True, fine_tune = False, scheduled_sampling = False, weight_tying = False,source = train
teacher_forcing = True
validating
training loss:3.2948868374415805 validation loss:4.355987264264014
model_name = pgn_model, pointer = True, coverage = True, fine_tune = False, scheduled_sampling = False, weight_tying = False,source = train
teacher_forcing = True
validating
training loss:2.752900101198469 validation loss:4.662438987403788
model_name = pgn_model, pointer = True, coverage = True, fine_tune = False, scheduled_sampling = False, weight_tying = False,source = train
teacher_forcing = True
validating
training loss:2.4371365354537966 validation loss:4.97526418009112
model_name = pgn_model, pointer = True, coverage = True, fine_tune = False, scheduled_sampling = False, weight_tying = False,source = train
teacher_forcing = True
validating
training loss:2.2446208192007884 validation loss:5.230078948441372
评估baseline-5模型¶
首先展示一下预测代码predict.py的运行结果, 唯一需要在类Predict的初始化函数中稍作修改: - 代码文件路径: /home/ec2-user/text_summary/pgn/src/predict.py

class Predict():
@timer(module='initalize predicter')
def init(self):
self.DEVICE = config.DEVICE

    # ----------------------------------------------------------------------# 在baseline-5模型评估中, 只需修改第一行的训练集路径即可.# 改成单词替换后的14万条的train_data_optim_1_path数据集.dataset = PairDataset(config.train_data_optim_1_path,max_enc_len=config.max_enc_len,max_dec_len=config.max_dec_len,truncate_enc=config.truncate_enc,truncate_dec=config.truncate_dec)# ---------------------------------------------------------------------self.vocab = dataset.build_vocab(embed_file=config.embed_file)self.model = PGN(self.vocab)self.stop_word = list(set([self.vocab[x.strip()] for x in open(config.stop_word_file).readlines()]))self.model.load_state_dict(torch.load(config.model_save_path))self.model.to(self.DEVICE)

调用:

if name == "main":
print('实例化Predict对象, 构建dataset和vocab......')
pred = Predict()
print('vocab_size: ', len(pred.vocab))
# Randomly pick a sample in test set to predict.
with open(config.val_data_path, 'r') as test:
picked = random.choice(list(test))
source, ref = picked.strip().split('')

print('source: ', source, '\n')
print('******************************************')
print('ref: ', ref, '\n')
print('******************************************')
greedy_prediction = pred.predict(source.split(),  beam_search=False)
print('greedy: ', greedy_prediction, '\n')
print('******************************************')
beam_prediction = pred.predict(source.split(),  beam_search=True)
print('beam: ', beam_prediction, '\n')

输出结果:

实例化Predict对象, 构建dataset和vocab......
Reading dataset /home/ec2-user/text_summary/pgn/data/train_word_optim.txt... 140000 pairs.
16.072141647338867 secs used for initalize predicter
vocab_size: 20004
source: 怠速时 转速表 轻微 摆动 , 车身 轻微 抖动 , 感觉 动力 没有 以前 强 , 火花塞 空气 格 汽油 格 万多公里 前换 2017 年 7 月 , 一家 加油站 加 油 。 请问 原因 造成 ? , 检查 节气门 积碳 多不多 , 看 一下 进气管 , 燃烧室 积碳 , 吊瓶 清洗 一下 常理 来说 , 三万 公里 不 应该 有积 碳 影响 明显 ? 加油站 加油 中石化 95 ?


ref: 节气门 清洗 , 燃油 添加剂 拉 高速 清洗 缸 内积 碳


0.03769087791442871 secs used for doing prediction
greedy: 清洗 一下 喷油嘴 , 再 跑跑 试试 。 进气管 , 燃烧室 积碳 , 吊瓶 清洗


0.3405790328979492 secs used for doing prediction
beam: 清洗 一下 喷油嘴 , 再 跑跑 试试 。 吊瓶 清洗 一下 。 , 再 跑跑 试试 。 看 一下 有没有 漏油 地方 。 吊瓶 清洗 积碳 。
测试完毕后, 直接在和之前版本相同的测试集上进行评估:

类Predict的代码只需要稍作修改: - 代码文件地址: /home/ec2-user/text_summary/pgn/src/predict.py

类Predict的内部函数, 评估函数会调用predict.py中的类进行处理.

# 采用贪心解码策略时将beam_search=False; 采用Beam-search解码策略时将beam_search=True
@timer(module='doing prediction')
def predict(self, text, tokenize=True, beam_search=False):if isinstance(text, str) and tokenize:text = list(jieba.cut(text))x, oov = source2ids(text, self.vocab)x = torch.tensor(x).to(self.DEVICE)len_oovs = torch.tensor([len(oov)]).to(self.DEVICE)x_padding_masks = torch.ne(x, 0).byte().float()if beam_search:summary = self.beam_search(x.unsqueeze(0),max_sum_len=config.max_dec_steps,beam_width=config.beam_size,len_oovs=len_oovs,x_padding_masks=x_padding_masks)else:summary = self.greedy_search(x.unsqueeze(0),max_sum_len=config.max_dec_steps,len_oovs=len_oovs,x_padding_masks=x_padding_masks)summary = outputids2words(summary, oov, self.vocab)

评估主函数rouge_eval完全不需要修改 - 代码文件路径: /home/ec2-user/text_summary/pgn/src/rouge_eval.py

调用(贪心解码):

首先将上面代码文件中的参数beam_search设置为False

cd /home/ec2-user/text_summary/pgn/src/

python rouge_eval.py
输出结果(贪心解码):

实例化Rouge对象......
Reading from /home/ec2-user/text_summary/pgn/data/dev.txt
self.refs[]包含的样本数: 3000
Test set contains 3000 samples.
实例化Predict对象......
Reading dataset /home/ec2-user/text_summary/pgn/data/train_word_optim.txt... 140000 pairs.
16.086021661758423 secs used for initalize predicter
利用模型对article进行预测, 并通过Rouge对象进行评估......
Building hypotheses.
0.035405635833740234 secs used for doing prediction
0.05810713768005371 secs used for doing prediction
0.051779747009277344 secs used for doing prediction
0.05352973937988281 secs used for doing prediction
0.061347246170043945 secs used for doing prediction
0.047524452209472656 secs used for doing prediction
0.014091968536376953 secs used for doing prediction
0.021171092987060547 secs used for doing prediction

......
......
......

0.02424907684326172 secs used for doing prediction
0.0540165901184082 secs used for doing prediction
0.0701298713684082 secs used for doing prediction
0.020273208618164062 secs used for doing prediction
0.021963834762573242 secs used for doing prediction
0.0258636474609375 secs used for doing prediction
0.021762609481811523 secs used for doing prediction
0.01562952995300293 secs used for doing prediction
0.012730836868286133 secs used for doing prediction
0.015019893646240234 secs used for doing prediction
0.013356447219848633 secs used for doing prediction
0.05282020568847656 secs used for doing prediction
0.014577150344848633 secs used for doing prediction
120.08142113685608 secs used for building hypotheses
开始用Rouge规则进行评估......
Calculating average rouge scores.
rouge1: {'f': 0.24856651392979245, 'p': 0.2798775863861886, 'r': 0.31694090956505816}
rouge2: {'f': 0.07876241371737112, 'p': 0.08768004109197018, 'r': 0.10274573842579217}
rougeL: {'f': 0.2734870775154685, 'p': 0.34682583339947426, 'r': 0.27245196203151145}
将评估结果写入结果文件中......
调用(Beam search解码):

首先要将predict.py文件中参数设置为beam_search=True

cd /home/ec2-user/text_summary/pgn/src/

python rouge_eval.py
输出结果(Beam search解码):

实例化Rouge对象......
Reading from /home/ec2-user/text_summary/pgn/data/dev.txt
self.refs[]包含的样本数: 3000
Test set contains 3000 samples.
实例化Predict对象......
Reading dataset /home/ec2-user/text_summary/pgn/data/train_word_optim.txt... 140000 pairs.
16.08588480949402 secs used for initalize predicter
利用模型对article进行预测, 并通过Rouge对象进行评估......
Building hypotheses.
0.36016082763671875 secs used for doing prediction
0.3373758792877197 secs used for doing prediction
0.3348877429962158 secs used for doing prediction
0.33213067054748535 secs used for doing prediction
0.3412024974822998 secs used for doing prediction
0.3547537326812744 secs used for doing prediction
0.337308406829834 secs used for doing prediction
0.3540971279144287 secs used for doing prediction
0.33278846740722656 secs used for doing prediction
0.331463098526001 secs used for doing prediction
0.3486611843109131 secs used for doing prediction

......
......
......

0.3724033832550049 secs used for doing prediction
0.34821534156799316 secs used for doing prediction
0.34289073944091797 secs used for doing prediction
0.32703256607055664 secs used for doing prediction
0.32860231399536133 secs used for doing prediction
0.3314642906188965 secs used for doing prediction
0.32858800888061523 secs used for doing prediction
0.33380556106567383 secs used for doing prediction
0.3321969509124756 secs used for doing prediction
0.3724205493927002 secs used for doing prediction
0.32953882217407227 secs used for doing prediction
1024.8375618457794 secs used for building hypotheses
开始用Rouge规则进行评估......
Calculating average rouge scores.
rouge1: {'f': 0.21761298996967154, 'p': 0.17647193879827075, 'r': 0.37561127997163163}
rouge2: {'f': 0.06866650115912178, 'p': 0.05509031711696027, 'r': 0.12487954175828382}
rougeL: {'f': 0.27141952833427696, 'p': 0.28759791074006696, 'r': 0.31386396066086725}
将评估结果写入结果文件中......
对比几个版本的模型关键指标数据:

baseline-2 baseline-3 baseline-4 baseline-5 baseline-5

PGN PGN + coverage PGN + coverage 数据增强优化 数据增强优化

                                                  + beam-search       (贪心解码)              (Beam search)

-------------------------------------------------------------------------------------------------------------------------

rouge-1 | rouge-1 | rouge-1 | rouge-1 | rouge-1
f: 22.4840567492723 | f: 23.891369972642877 | f: 22.37876211830499 | f: 24.856651392979245 | f: 21.761298996967156
p: 22.48892685096682 | p: 27.884799447410742 | p: 18.11883439712568 | p: 27.98775863861886 | p: 17.647193879827075
r: 36.152107385286506 | r: 32.746511025920974 | r: 38.41843120305152 | r: 31.694090956505818 | r: 37.56112799716316

-------------------------------------------------------------------------------------------------------------------------

rouge-2 | rouge-2 | rouge-2 | rouge-2 | rouge-2
f: 7.6855293213852995 | f: 8.145260844937683 | f: 7.272050441375325 | f: 7.8762413717371125 | f: 6.8666501159121776
p: 7.633592816230374 | p: 9.507772545428752 | p: 5.774983504844641 | p: 8.768004109197019 | p: 5.509031711696027
r: 12.992180038436171 | r: 11.403972188579534 | r: 13.46595763700352 | r: 10.274573842579217 | r: 12.487954175828381

------------------------------------------------------------------------------------------------------------------------

rouge-l | rouge-l | rouge-l | rouge-l | rouge-l
f: 29.79190839597577 | f: 28.112397619052885 | f: 27.544194926579234 | f: 27.348707751546854 | f: 27.141952833427695
p: 35.022548049923934 | p: 35.51625333014313 | p: 28.64224165624092 | p: 34.68258333994743 | p: 28.759791074006696
r: 31.245311823639828 | r: 28.567520633785175 | r: 32.28687273073168 | r: 27.245196203151146 | r: 31.386396066086725
结论: 采用单词替换的数据增强后, 模型在精确率上表现较好, 但是召回率上有些许下降. 总体效果一般.

回译数据法¶
LLM翻译接口¶
利用LLM提供的接口实现回译数据法: 当样本数量不足时, NLP领域内最容易想到也是最容易实现的就是回译数据增强法. 通过不同语言之间的翻译, 使得训练文本的表达方式产生差异, 可以丰富语料语义, 增强模型的效果.

测试代码: - 代码文件路径: /home/ec2-user/text_summary/pgn/optim/back_translate.py

import jieba
import random
import time
import os
from openai import OpenAI

client = OpenAI(api_key="sk-XXXXXXXXXXXXXXXXXXXXX", base_url="https://api.deepseek.com")

def translate_from_llm(text, source, target):
response = client.chat.completions.create(model="deepseek-chat",
messages=[
{
"role": "system",
"content": f"你是一个语言翻译专家, 请将文本中的语言从{source}翻译成{target}."
},
{
"role": "user",
"content": f"{text}"
},
],
stream=False
)

return response.choices[0].message.content

def back_translate(text, source_lang, target_lang):
text1 = translate_from_llm(text, source_lang, target_lang)
time.sleep(1)
text2 = translate_from_llm(text1, target_lang, source_lang)
time.sleep(1)
print('text: ', text)
print('text1: ', text1)
print('text2: ', text2)

return text2

def translate(sample_file, result_file, source_lang, target_lang):
if not os.path.exists(result_file):
with open(result_file, 'w', encoding='utf-8') as f:
f.write("这是回译数据法的结果文件.")
print(f"文件{result_file}已创建.")
else:
print(f"文件{result_file}已存在.")

translated = []
count = 0
with open(sample_file, 'r', encoding='utf-8') as file:for line in file:line = line.strip('\n').strip()count += 1source, ref = line.split('<SEP>')source = ''.join(source.split(' '))ref = ''.join(ref.split(' '))source_back = back_translate(source.strip(), source_lang, target_lang)ref_back = back_translate(ref.strip(), source_lang, target_lang)source_back = ' '.join(list(jieba.cut(source_back)))ref_back = ' '.join(list(jieba.cut(ref_back)))translated.append(source_back + ' <SEP> ' + ref_back)if count % 10 == 0:print('count=', count)with open(result_file, 'a', encoding='utf-8') as f:for content in translated:f.write(content + '\n')translated = []

调用:

if name == 'main':
sample_file = '../data/sample.txt'
result_file = '../data/translated.txt'
translate(sample_file, result_file, u'中文', u'韩语')
输出结果:

长安 35 朝阳 轮胎 不 包括 内 轮辋 。 轮辋 费用 是 指 在 外部 更换 的 费用 , 只 更换 新 >轮胎 。 单独 更换 轮胎 , 价格 大约 在 350 元 左右 。 只 更换 轮胎 表面 是 350 元 , 因为
轮胎 品牌 和 型号 较大 , 所以 价格 偏高 。 这 是 最 便宜 的 价格 。 在 网上 购买 是 200 元 , 根据 地区 价格 可能 会 有所 浮动 。 是 的 , 谢谢 ! 祝您 用车 愉快 ! 单独 购买 轮胎 , 并 在 销售 大 尺寸 轮胎 的 地方 购买 , 如果 在 维修 店 更换 , 费用 大约 为
350 左右 。 如果 在线 购买 并 更换 , 会 更 便宜 一些 。

地理 远景 外部 看到 的 滚轮 上 的 大 螺栓 意味着 顺时针 旋转 可以 松开 , 而 逆时针 旋转 则 更 难 拧紧 。 如果 用 扳手 也 无法 松开 导致 螺栓 断裂 , 这 意味着 在 松开 轮胎 螺栓 时 使用 了 错误 的 方法 。 螺栓 轮胎 螺栓 , 顺时针 方向 拧 出

丰田 卡罗 拉 在 行驶 10 万公里 时 需要 更换 正 时 皮带 。 这 款 车型 建议 每 10 万公里 至
少 更换 一次 。 皮带 老化 后 可能 会 损坏 , 这 可能 会 对 发动机 造成 损害 , 甚至 导致 车辆 故障 。 还 需要 检查 发电机 皮带 , 建议 每 6 到 8 万公里 进行 一次 检查和 更换 新 >零件 。 根据 当前 的 行驶 里程 , 建议 检查 皮带 。 皮带 可能 出现 老化 现象 , 皮>带 的 寿命 为 81 万公里 。 建议 进行 更换 。
半监督学习法¶
半监督数据增强法¶
当训练样本不足, 或者需要更进一步在更大规模的数据集上优化模型时, 可以采用半监督学习法.

半监督数据增强法: 当我们已经训练出一个文本生成模型后, 可以利用这个模型为原始训练集中的abstract生成新的article, 将这个新生成的article作为新样本的source document, 继续训练模型.

在半监督算法的实践中, 我们选取baseline-4为基准模型(具体选取哪个模型为基准, 可以多次尝试, 或者程序员指定!) - 代码文件路径: /home/ec2-user/text_summary/pgn/optim/semi_supervised.py

import os
import sys

设定项目的root路径, 方便后续相关代码文件的导入

root_path = os.path.dirname(os.path.dirname(os.path.abspath(file)))
sys.path.append(root_path)

导入项目相关的代码文件

from src.predict import Predict
from optim.data_utils import *

半监督数据增强的实现函数

def semi_supervised(samples_path, write_path, beam_search):
pred = Predict()
print('vocab_size: ', len(pred.vocab))
count = 0
semi = []

with open(samples_path, 'r') as f:for line in f:line = line.strip('\n').strip()# 原始训练集数据, 每行样本以<SEP>进行分隔.source, ref = line.split('<SEP>')# 调用预测类Predict对摘要给出生成文本.try:prediction = pred.predict(ref.split(), beam_search=beam_search)except:continue# 将模型生成的文本重新作为source document, 拼接成新的训练样本.semi.append(prediction + '<SEP>' + ref)count += 1if count % 100 == 0:print('count=', count)with open(write_path, 'a', encoding='utf-8') as f:for content in semi:f.write(content + '\n')semi = []

调用:

if name == 'main':
samples_path = root_path + '/data/train2.txt'
write_path_greedy = root_path + '/data/semi_greedy.txt'
write_path_beam = root_path + '/data/semi_beam.txt'

# 贪心策略生成半监督数据
beam_search = Falseif beam_search:write_path = write_path_beam
else:write_path = write_path_greedy
semi_supervised(samples_path, write_path, beam_search)

小节总结¶
小节总结: - 单词替换法: - TF-IDF算法来自动提取关键词.
- 在对训练集进行普遍替换的过程中, 不替换上一步得到的关键词.
- 单词替换后的样本可以表达更丰富的语义, 更丰富的表达形式, 增强模型的表现.

回译数据法: - 采用LLM翻译接口.

不同语言间的翻译可以得到更多的样本, 更丰富的语义, 更丰富的表达形式, 增强模型的表现, 并弥补样本量的不足.

半监督学习法: - 前提是已经有一个表现不错的模型.

利用这个模型将摘要作为输入生成新的文本, 这个文本将作为新样本的输入文本.

但这种方法依赖于已有模型要表现较好, 同时原始训练样本的质量也要较高.

6.4 训练策略的优化
训练策略优化模型¶
学习目标¶
理解几种优化模型的训练策略.

掌握训练策略实现的代码.
avatar

Scheduled sampling优化策略¶
Scheduled sampling方法和实现¶
Scheduled sampling方法介绍: 在生成式模型训练阶段, 常采用Teacher-forcing的策略辅助模型更快的收敛. 但是一直使用Teacher-forcing的策略, 会造成训练和预测的输入样本分布不一致, 也就是著名的"Exposure bias".

解决方法: 对于"Exposure bias", 很好的一个解决策略就是采用"Scheduled sampling", 即在训练阶段, 将ground truth和decoder predict混合起来使用, 作为下一个时间步的decoder input. 具体做法是每个时间步以一个p值概率进行Teacher forcing, 以(1 - p)值概率不进行Teacher forcing. 同时p值的大小随着batch或者epoch衰减.

策略有效性分析: 采用"Scheduled sampling", 在刚开始训练的阶段, 模型所具有的知识很少, 需要采用Teacher forcing的方式, 使用ground truth加速模型的学习和收敛. 到了训练后期, 模型已经掌握了很多数据分布的特征和数据本身的特征, 这个时候将decoder input替换成decoder predict, 保持和预测阶段一致, 来解决"Exposure bias"的问题.

实现相关的核心代码逻辑: - 代码文件路径: /home/ec2-user/text_summary/pgn/utils/func_utils.py

随着训练迭代步数的增长, 计算是否使用Teacher-forcing的概率大小

class ScheduledSampler():
def init(self, phases):
self.phases = phases
# 通过超参数phases来提前计算出每一个epoch是否采用Teacher forcing的阈值概率
self.scheduled_probs = [i / (self.phases - 1) for i in range(self.phases)]

def teacher_forcing(self, phase):# 生成随机数sampling_prob = random.random()# 每一轮训练时, 通过随机数和阈值概率比较, 来决定是否采用Teacher forcingif sampling_prob >= self.scheduled_probs[phase]:return Trueelse:return False

调用:

if name == 'main':
ss = ScheduledSampler(10)
print('scheduled_probs: ', ss.scheduled_probs)
print('phases: ', ss.phases)
print('teacher_forcing: ', ss.teacher_forcing(5))
print('teacher_forcing: ', ss.teacher_forcing(8))
输出结果:

scheduled_probs: [0.0, 0.1111111111111111, 0.2222222222222222, 0.3333333333333333, 0.4444444444444444, 0.5555555555555556, 0.6666666666666666, 0.7777777777777778, 0.8888888888888888, 1.0]
phases: 10
teacher_forcing: True
teacher_forcing: False
训练baseline-7模型¶
Scheduled sampling代码实现. - 第一步: 修改配置文件config.py

第二步: 修改训练代码train.py

第一步: 修改配置文件config.py - 代码文件路径: /home/ec2-user/text_summary/pgn/utils/config.py

对这一行做出修改, 设置为True

scheduled_sampling = True
第二步: 修改训练代码train.py - 代码文件路径: /home/ec2-user/text_summary/pgn/src/train.py

导入系统工具包

import pickle
import os
import sys

设置项目的root路径, 方便后续相关代码文件的导入

root_path = os.path.dirname(os.path.dirname(os.path.abspath(file)))
sys.path.append(root_path)

导入项目中用到的工具包

import numpy as np
from torch import optim
from torch.utils.data import DataLoader
import torch
from torch.nn.utils import clip_grad_norm_
from tqdm import tqdm
from tensorboardX import SummaryWriter

导入项目中自定义的代码文件, 类, 函数等

from src.model import PGN
from utils import config
from src.evaluate import evaluate
from utils.dataset import PairDataset, collate_fn, SampleDataset
from utils.func_utils import ScheduledSampler, config_info

编写训练模型的主逻辑函数.

def train(dataset, val_dataset, v, start_epoch=0):
DEVICE = config.DEVICE

# 实例化PGN类对象并移动到GPU上(CPU).
model = PGN(v)
model.to(DEVICE)print("loading data......")
train_data = SampleDataset(dataset.pairs, v)
val_data = SampleDataset(val_dataset.pairs, v)print("initializing optimizer......")# 定义模型训练的优化器.
optimizer = optim.Adam(model.parameters(), lr=config.learning_rate)# 定义训练集的数据迭代器(这里用到了自定义的collate_fn以服务于PGN特殊的数据结构).
train_dataloader = DataLoader(dataset=train_data,batch_size=config.batch_size,shuffle=True,collate_fn=collate_fn)# 验证集上的损失值初始化为一个大整数.
val_losses = 10000000.0# SummaryWriter: 为服务于TensorboardX写日志的可视化工具.
writer = SummaryWriter(config.log_path)num_epochs =  len(range(start_epoch, config.epochs))# ---------------------------------------------------------------------------------------
# 下面的代码是6.4小节添加的关于训练策略Scheduled sampling的优化代码.
# 工具函数ScheduledSampler()根据当前模型训练epoch的推进, 来决定Teacher-forcing的策略选择
scheduled_sampler = ScheduledSampler(num_epochs)
if config.scheduled_sampling:print('scheduled_sampling mode.')
# ---------------------------------------------------------------------------------------with tqdm(total=config.epochs) as epoch_progress:for epoch in range(start_epoch, config.epochs):# 每一个epoch之前打印模型训练的相关配置信息.print(config_info(config))# 初始化每一个batch损失值的存放列表batch_losses = []num_batches = len(train_dataloader)# ----------------------------------------------------------------------------# 下面的代码是6.4小节添加的关于训练策略Scheduled sampling的优化代码.# 调用工具函数决定是否使用Teacher-forcing策略.if config.scheduled_sampling:teacher_forcing = scheduled_sampler.teacher_forcing(epoch - start_epoch)else:teacher_forcing = Trueprint('teacher_forcing = {}'.format(teacher_forcing))# ----------------------------------------------------------------------------with tqdm(total=num_batches//100) as batch_progress:for batch, data in enumerate(tqdm(train_dataloader)):x, y, x_len, y_len, oov, len_oovs = dataassert not np.any(np.isnan(x.numpy()))# 如果配置有GPU, 则加速训练if config.is_cuda:x = x.to(DEVICE)y = y.to(DEVICE)x_len = x_len.to(DEVICE)len_oovs = len_oovs.to(DEVICE)# 设置模型进入训练模式(参数参与反向传播和更新)model.train()# "老三样"中的第一步: 梯度清零optimizer.zero_grad()# 调用模型进行训练并返回损失值loss = model(x, x_len, y,len_oovs, batch=batch,num_batches=num_batches,teacher_forcing=teacher_forcing)batch_losses.append(loss.item())# "老三样"中的第二步: 反向传播loss.backward()# 为防止梯度爆炸(gradient explosion)而进行梯度裁剪.clip_grad_norm_(model.encoder.parameters(), config.max_grad_norm)clip_grad_norm_(model.decoder.parameters(), config.max_grad_norm)clip_grad_norm_(model.attention.parameters(), config.max_grad_norm)# "老三样"中的第三步: 参数更新optimizer.step()# 每隔100个batch记录一下损失值信息.if (batch % 100) == 0:batch_progress.set_description(f'Epoch {epoch}')batch_progress.set_postfix(Batch=batch, Loss=loss.item())batch_progress.update()# 向tensorboard中写入损失值信息.writer.add_scalar(f'Average loss for epoch {epoch}',np.mean(batch_losses),global_step=batch)# 将一个轮次中所有batch的平均损失值作为这个epoch的损失值.epoch_loss = np.mean(batch_losses)epoch_progress.set_description(f'Epoch {epoch}')epoch_progress.set_postfix(Loss=epoch_loss)epoch_progress.update()# 结束每一个epoch训练后, 直接在验证集上跑一下模型效果avg_val_loss = evaluate(model, val_data, epoch)print('training loss:{}'.format(epoch_loss), 'validation loss:{}'.format(avg_val_loss))# 更新更小的验证集损失值evaluating loss.if (avg_val_loss < val_losses):torch.save(model.encoder, config.encoder_save_name)torch.save(model.decoder, config.decoder_save_name)torch.save(model.attention, config.attention_save_name)torch.save(model.reduce_state, config.reduce_state_save_name)torch.save(model.state_dict(), config.model_save_path)val_losses = avg_val_loss# 将更小的损失值写入文件中with open(config.losses_path, 'wb') as f:pickle.dump(val_losses, f)writer.close()

调用:

if name == "main":
# Prepare dataset for training.
DEVICE = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
print('DEVICE: ', DEVICE)

# 将构建数据集的路径改为config.train_data_path, 具体采用单词替换后的训练集
dataset = PairDataset(config.train_data_path,max_enc_len=config.max_enc_len,max_dec_len=config.max_dec_len,truncate_enc=config.truncate_enc,truncate_dec=config.truncate_dec)
val_dataset = PairDataset(config.val_data_path,max_enc_len=config.max_enc_len,max_dec_len=config.max_dec_len,truncate_enc=config.truncate_enc,truncate_dec=config.truncate_dec)vocab = dataset.build_vocab(embed_file=config.embed_file)train(dataset, val_dataset, vocab, start_epoch=0)

输出结果:

DEVICE: cuda
Reading dataset /home/ec2-user/text_summary/pgn/data/train.txt... 140000 pairs.
Reading dataset /home/ec2-user/text_summary/pgn/data/dev.txt...
[[0%| | 0/4375 [00:00<?, ?it/s]
[[Epoch 0: 0%| | 0/43 [00:01<?, ?it/s]
[[Epoch 0: 0%| | 0/43 [00:01<?, ?it/s, Batch=0, Loss=7.2]
[[Epoch 0: 2%|▏ | 1/43 [00:01<01:05, 1.55s/it, Batch=0, Loss=7.2]
[[0%| | 1/4375 [00:01<1:53:09, 1.55s/it]
[[0%| | 2/4375 [00:02<1:47:52, 1.48s/it]
[[0%| | 3/4375 [00:04<1:45:31, 1.45s/it]
[[0%| | 4/4375 [00:05<1:42:08, 1.40s/it]
[[0%| | 5/4375 [00:06<1:39:37, 1.37s/it]
[[0%| | 6/4375 [00:08<1:51:42, 1.53s/it]

......
......
......

[[99%|█████████▊| 396/402 [03:58<00:03, 1.95it/s]
[[99%|█████████▉| 397/402 [03:59<00:02, 1.73it/s]
[[99%|█████████▉| 398/402 [04:00<00:02, 1.44it/s]
[[99%|█████████▉| 399/402 [04:01<00:01, 1.54it/s]
[[100%|█████████▉| 400/402 [04:01<00:01, 1.66it/s]
[[100%|█████████▉| 401/402 [04:02<00:00, 1.60it/s]
[[100%|██████████| 402/402 [04:03<00:00, 1.27it/s]
[[100%|██████████| 402/402 [04:03<00:00, 1.65it/s]
Epoch 9: 100%|██████████| 10/10 [21:27:18<00:00, 7723.90s/it, Loss=3.7]
12870 pairs.
loading data......
initializing optimizer......
scheduled_sampling mode.
model_name = pgn_model, pointer = True, coverage = True, fine_tune = False, scheduled_sampling = True, weight_tying = False,source = train
teacher_forcing = True
validating
training loss:4.552305130713327 validation loss:4.524957228655839
model_name = pgn_model, pointer = True, coverage = True, fine_tune = False, scheduled_sampling = True, weight_tying = False,source = train
teacher_forcing = True
validating
training loss:3.7868157992226736 validation loss:4.627957730744016
model_name = pgn_model, pointer = True, coverage = True, fine_tune = False, scheduled_sampling = True, weight_tying = False,source = train
teacher_forcing = True
validating
training loss:3.3115914395468575 validation loss:4.873124810000557
model_name = pgn_model, pointer = True, coverage = True, fine_tune = False, scheduled_sampling = True, weight_tying = False,source = train
teacher_forcing = True
validating
training loss:2.9778448364257812 validation loss:5.174075812249634
model_name = pgn_model, pointer = True, coverage = True, fine_tune = False, scheduled_sampling = True, weight_tying = False,source = train
teacher_forcing = True
validating
training loss:2.732732899093628 validation loss:5.468189263225195
model_name = pgn_model, pointer = True, coverage = True, fine_tune = False, scheduled_sampling = True, weight_tying = False,source = train
teacher_forcing = True
validating
training loss:2.5524693885530745 validation loss:5.7531704665416505
model_name = pgn_model, pointer = True, coverage = True, fine_tune = False, scheduled_sampling = True, weight_tying = False,source = train
teacher_forcing = True
validating
training loss:2.4137240750721523 validation loss:6.042709622217055
model_name = pgn_model, pointer = True, coverage = True, fine_tune = False, scheduled_sampling = True, weight_tying = False,source = train
teacher_forcing = False
validating
training loss:4.1108463183266775 validation loss:5.622990067325421
model_name = pgn_model, pointer = True, coverage = True, fine_tune = False, scheduled_sampling = True, weight_tying = False,source = train
teacher_forcing = False
validating
training loss:3.85217619972229 validation loss:5.791738596721668
model_name = pgn_model, pointer = True, coverage = True, fine_tune = False, scheduled_sampling = True, weight_tying = False,source = train
teacher_forcing = False
validating
training loss:3.6952303215571813 validation loss:5.919977106265168
结论: 我们发现前7轮epoch的时候teacher_forcing=True, 最后3轮epoch的时候teacher_forcing=False. 并且我们验证集上最小的损失恰恰在第一个epoch就出现了. 结合前面几章的经验, 提示我们在当前数据集和任务上, 每次迭代训练2-3个epoch就足够了.

评估baseline-7模型¶
关于baseline-7模型的评估代码: - 代码文件predict.py只需要对参数beam_search做出设定, 其他全部不变.

代码文件rouge_eval.py全部保持不变.

首先评估贪心解码的模型效果: - 在代码文件predict.py中, 只需要对参数beam_search设置为False即可.

代码文件路径: /home/ec2-user/text_summary/pgn/src/predict.py

---------------------------------------------------------------------

# 只需要将最后一个形参beam_search设置为False, 即可以贪心解码预测.
@timer(module='doing prediction')
def predict(self, text, tokenize=True, beam_search=False):
# ---------------------------------------------------------------------if isinstance(text, str) and tokenize:text = list(jieba.cut(text))x, oov = source2ids(text, self.vocab)x = torch.tensor(x).to(self.DEVICE)len_oovs = torch.tensor([len(oov)]).to(self.DEVICE)x_padding_masks = torch.ne(x, 0).byte().float()if beam_search:summary = self.beam_search(x.unsqueeze(0),max_sum_len=config.max_dec_steps,beam_width=config.beam_size,len_oovs=len_oovs,x_padding_masks=x_padding_masks)else:summary = self.greedy_search(x.unsqueeze(0),max_sum_len=config.max_dec_steps,len_oovs=len_oovs,x_padding_masks=x_padding_masks)summary = outputids2words(summary, oov, self.vocab)return summary.replace('<SOS>', '').replace('<EOS>', '').strip()

调用:

cd /home/ec2-user/text_summary/pgn/src/

python rouge_eval.py
输出结果:

实例化Rouge对象......
Reading from /home/ec2-user/text_summary/pgn/data/dev.txt
self.refs[]包含的样本数: 3000
Test set contains 3000 samples.
实例化Predict对象......
Reading dataset /home/ec2-user/text_summary/pgn/data/train.txt... 140000 pairs.
15.963327169418335 secs used for initalize predicter
利用模型对article进行预测, 并通过Rouge对象进行评估......
Building hypotheses.
0.07332539558410645 secs used for doing prediction
0.055742502212524414 secs used for doing prediction
0.037644386291503906 secs used for doing prediction
0.05304408073425293 secs used for doing prediction
0.030008316040039062 secs used for doing prediction
0.05388140678405762 secs used for doing prediction
0.01584935188293457 secs used for doing prediction
0.06547999382019043 secs used for doing prediction
0.05590486526489258 secs used for doing prediction
0.021546125411987305 secs used for doing prediction
0.04329800605773926 secs used for doing prediction
0.05656576156616211 secs used for doing prediction

......
......
......

0.05123019218444824 secs used for doing prediction
0.009801626205444336 secs used for doing prediction
0.05459141731262207 secs used for doing prediction
0.05174136161804199 secs used for doing prediction
0.05583930015563965 secs used for doing prediction
0.05422520637512207 secs used for doing prediction
0.052309513092041016 secs used for doing prediction
0.01446390151977539 secs used for doing prediction
116.43013048171997 secs used for building hypotheses
开始用Rouge规则进行评估......
Calculating average rouge scores.
rouge1: {'f': 0.20571242815278154, 'p': 0.22165821440593508, 'r': 0.2691005485951501}
rouge2: {'f': 0.05577086927945189, 'p': 0.05833918727814196, 'r': 0.07897281222802614}
rougeL: {'f': 0.23440123410369454, 'p': 0.3021595446736551, 'r': 0.22998070380868565}
将评估结果写入结果文件中......
再评估Beam search解码的效果: - 在代码文件predict.py中, 只需要对参数beam_search设置为True即可.

代码文件路径: /home/ec2-user/text_summary/pgn/src/predict.py

---------------------------------------------------------------------

# 只需要将最后一个形参beam_search设置为True, 即可以Beam search解码预测.
@timer(module='doing prediction')
def predict(self, text, tokenize=True, beam_search=True):
# ---------------------------------------------------------------------if isinstance(text, str) and tokenize:text = list(jieba.cut(text))x, oov = source2ids(text, self.vocab)x = torch.tensor(x).to(self.DEVICE)len_oovs = torch.tensor([len(oov)]).to(self.DEVICE)x_padding_masks = torch.ne(x, 0).byte().float()if beam_search:summary = self.beam_search(x.unsqueeze(0),max_sum_len=config.max_dec_steps,beam_width=config.beam_size,len_oovs=len_oovs,x_padding_masks=x_padding_masks)else:summary = self.greedy_search(x.unsqueeze(0),max_sum_len=config.max_dec_steps,len_oovs=len_oovs,x_padding_masks=x_padding_masks)summary = outputids2words(summary, oov, self.vocab)return summary.replace('<SOS>', '').replace('<EOS>', '').strip()

调用:

cd /home/ec2-user/text_summary/pgn/src/

python rouge_eval.py
输出结果:

实例化Rouge对象......
Reading from /home/ec2-user/ec2-user/zhudejun/text_summary/text_summary/pgn/data/dev.txt
self.refs[]包含的样本数: 3000
Test set contains 3000 samples.
实例化Predict对象......
Reading dataset /home/ec2-user/ec2-user/zhudejun/text_summary/text_summary/pgn/data/train.txt... 139998 pairs.
15.918241024017334 secs used for initalize predicter
利用模型对article进行预测, 并通过Rouge对象进行评估......
Building hypotheses.
0.3589177131652832 secs used for doing prediction
0.3374664783477783 secs used for doing prediction
0.3456692695617676 secs used for doing prediction
0.3331456184387207 secs used for doing prediction
0.34146881103515625 secs used for doing prediction
0.334575891494751 secs used for doing prediction
0.33663010597229004 secs used for doing prediction
0.3577096462249756 secs used for doing prediction
0.3289070129394531 secs used for doing prediction
0.3339529037475586 secs used for doing prediction

......
......
......

0.3524472713470459 secs used for doing prediction
0.35205864906311035 secs used for doing prediction
0.36118149757385254 secs used for doing prediction
0.40877795219421387 secs used for doing prediction
0.3536827564239502 secs used for doing prediction
0.366757869720459 secs used for doing prediction
0.35179853439331055 secs used for doing prediction
0.35098910331726074 secs used for doing prediction
0.35169148445129395 secs used for doing prediction
0.35124707221984863 secs used for doing prediction
0.3597400188446045 secs used for doing prediction
0.3530762195587158 secs used for doing prediction
0.35770344734191895 secs used for doing prediction
0.3506617546081543 secs used for doing prediction
1024.457626581192 secs used for building hypotheses
开始用Rouge规则进行评估......
Calculating average rouge scores.
rouge1: {'f': 0.19552847393917322, 'p': 0.1587446851587022, 'r': 0.33453045142923926}
rouge2: {'f': 0.05518565025066199, 'p': 0.04381349206349152, 'r': 0.10198874959312032}
rougeL: {'f': 0.24967671068636252, 'p': 0.27878950788684004, 'r': 0.2761432124695714}
将评估结果写入结果文件中......
Weight tying优化策略¶
Weight tying方法和实现¶
Weight tying方法介绍: 其实这个策略要解决的问题还是上面提出来的"Exposure bias"问题, 除了用训练后期去除掉Teacher forcing的方法. 我们还可以通过让Encoder和Decoder的词嵌入尽量一致来纠正这种偏差.

解决方法: 对于"Exposure bias"另一个很好的解决策略就是采用"Weight tying", 字面意思就是权重的绑定, 具体做法是将Encoder和Decoder的embedding权重矩阵进行共享.

策略有效性分析: 对embedding权重矩阵进行共享, 这样就使得Encoder和Decoder的输入词向量表达完全相同了, 一定程度上可以缓解"Exposure bias".

训练baseline-8模型¶
Weight tying代码实现. - 第一步: 修改配置文件config.py

第二步: 修改模型代码model.py

第一步: 修改配置文件config.py - 代码文件路径: /home/ec2-user/text_summary/pgn/utils/config.py

对这一行做出修改, 设置为True

weight_tying = True
第二步: 修改模型代码model.py - 代码文件路径: /home/ec2-user/text_summary/pgn/src/model.py

具体来说, 只有Encoder, Decoder, PGN这三个类需要作出修改, 其他保持不变.

第1处修改: 在Encoder中的forward()函数中.

class Encoder(nn.Module):
def init(self, vocab_size, embed_size, hidden_size, rnn_drop=0):
super(Encoder, self).init()
self.embedding = nn.Embedding(vocab_size, embed_size)
self.hidden_size = hidden_size
self.lstm = nn.LSTM(embed_size, hidden_size, bidirectional=True, dropout=rnn_drop, batch_first=True)

def forward(self, x, decoder_embedding):# ---------------------------------------------------------------------------# 下面的代码是6.4小节添加的关于weight tying训练策略的代码. if config.weight_tying:embedded = decoder_embedding(x)else:embedded = self.embedding(x)output, hidden = self.lstm(embedded)# ---------------------------------------------------------------------------return output, hidden

第2处修改: 在Decoder中的forward()函数中.

class Decoder(nn.Module):
def init(self, vocab_size, embed_size, hidden_size, enc_hidden_size=None):
super(Decoder, self).init()
# 解码器端也采用跟随模型一起训练的方式, 得到词嵌入层
self.embedding = nn.Embedding(vocab_size, embed_size)
# self.DEVICE = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
self.vocab_size = vocab_size
self.hidden_size = hidden_size

    # 解码器的主体结构采用单向LSTM, 区别于编码器端的双向LSTMself.lstm = nn.LSTM(embed_size, hidden_size, batch_first=True)# 因为要将decoder hidden state和context vector进行拼接, 因此需要3倍的hidden_size维度设置self.W1 = nn.Linear(self.hidden_size * 3, self.hidden_size)self.W2 = nn.Linear(self.hidden_size, vocab_size)if config.pointer:# 因为要根据论文中的公式8进行运算, 所谓输入维度上匹配的是4 * hidden_size + embed_sizeself.w_gen = nn.Linear(self.hidden_size * 4 + embed_size, 1)def forward(self, x_t, decoder_states, context_vector):# 首先计算Decoder的前向传播输出张量decoder_emb = self.embedding(x_t)decoder_output, decoder_states = self.lstm(decoder_emb, decoder_states)# 接下来就是论文中的公式4的计算.# 将context vector和decoder state进行拼接, (batch_size, 3*hidden_units)decoder_output = decoder_output.view(-1, config.hidden_size)concat_vector = torch.cat([decoder_output, context_vector], dim=-1)# 经历两个全连接层V和V1后,再进行softmax运算, 得到vocabulary distribution# (batch_size, hidden_units)FF1_out = self.W1(concat_vector)# ---------------------------------------------------------------------------# 下面的代码是6.4小节添加的关于weight tying训练策略的代码.if config.weight_tying:FF2_out = torch.mm(FF1_out, torch.t(self.embedding.weight))else:FF2_out = self.W2(FF1_out)# (batch_size, vocab_size)p_vocab = F.softmax(FF2_out, dim=1)# ---------------------------------------------------------------------------# 构造decoder state s_t.h_dec, c_dec = decoder_states# (1, batch_size, 2*hidden_units)s_t = torch.cat([h_dec, c_dec], dim=2)# p_gen是通过context vector h_t, decoder state s_t, decoder input x_t, 三个部分共同计算出来的.# 下面的部分是计算论文中的公式8.p_gen = Noneif config.pointer:# 这里面采用了直接拼接3部分输入张量, 然后经历一个共同的全连接层w_gen, 和原始论文的计算不同.# 这也给了大家提示, 可以提高模型的复杂度, 完全模拟原始论文中的3个全连接层来实现代码.x_gen = torch.cat([context_vector, s_t.squeeze(0), decoder_emb.squeeze(1)], dim=-1)p_gen = torch.sigmoid(self.w_gen(x_gen))return p_vocab, decoder_states, p_gen

第3处修改: 在Decoder中的forward()函数中.

class PGN(nn.Module):
def init(self, v):
super(PGN, self).init()
# 初始化字典对象
self.v = v
self.DEVICE = config.DEVICE

    # 依次初始化4个类对象self.attention = Attention(config.hidden_size)self.encoder = Encoder(len(v), config.embed_size, config.hidden_size)self.decoder = Decoder(len(v), config.embed_size, config.hidden_size)self.reduce_state = ReduceState()# 计算最终分布的函数
def get_final_distribution(self, x, p_gen, p_vocab, attention_weights, max_oov):if not config.pointer:return p_vocabbatch_size = x.size()[0]# 进行p_gen概率值的裁剪, 具体取值范围可以调参p_gen = torch.clamp(p_gen, 0.001, 0.999)# 接下来两行代码是论文中公式9的计算.p_vocab_weighted = p_gen * p_vocab# (batch_size, seq_len)attention_weighted = (1 - p_gen) * attention_weights# 得到扩展后的单词概率分布(extended-vocab probability distribution)# extended_size = len(self.v) + max_oovsextension = torch.zeros((batch_size, max_oov)).float().to(self.DEVICE)# (batch_size, extended_vocab_size)p_vocab_extended = torch.cat([p_vocab_weighted, extension], dim=1)# 根据论文中的公式9, 累加注意力值attention_weighted到对应的单词位置xfinal_distribution = p_vocab_extended.scatter_add_(dim=1, index=x, src=attention_weighted)return final_distributiondef forward(self, x, x_len, y, len_oovs, batch, num_batches, teacher_forcing):x_copy = replace_oovs(x, self.v)x_padding_masks = torch.ne(x, 0).byte().float()# --------------------------------------------------------------------------------# 下面一行代码修改, 加入self.decoder.embedding是整个PGN类中唯一一行需要修改的代码.# 下面一行代码是6.4小节添加的关于weight tying训练策略的代码.# 第一步: 进行Encoder的编码计算encoder_output, encoder_states = self.encoder(x_copy, self.decoder.embedding)# --------------------------------------------------------------------------------decoder_states = self.reduce_state(encoder_states)# 用全零张量初始化coverage vector.coverage_vector = torch.zeros(x.size()).to(self.DEVICE)# 初始化每一步的损失值step_losses = []# 第二步: 循环解码, 每一个时间步都经历注意力的计算, 解码器层的计算.# 初始化解码器的输入, 是ground truth中的第一列, 即真实摘要的第一个字符x_t = y[:, 0]for t in range(y.shape[1] - 1):# 如果使用Teacher_forcing, 则每一个时间步用真实标签来指导训练if teacher_forcing:x_t = y[:, t]x_t = replace_oovs(x_t, self.v)y_t = y[:, t + 1]# 通过注意力层计算context vector.context_vector, attention_weights, coverage_vector = self.attention(decoder_states,encoder_output,x_padding_masks,coverage_vector)# 通过解码器层计算得到vocab distribution和hidden statesp_vocab, decoder_states, p_gen = self.decoder(x_t.unsqueeze(1), decoder_states, context_vector)# 得到最终的概率分布final_dist = self.get_final_distribution(x,p_gen,p_vocab,attention_weights,torch.max(len_oovs))# 第t个时间步的预测结果, 将作为第t + 1个时间步的输入(如果采用Teacher-forcing则不同).x_t = torch.argmax(final_dist, dim=1).to(self.DEVICE)# 根据模型对target tokens的预测, 来获取到预测的概率if not config.pointer:y_t = replace_oovs(y_t, self.v)target_probs = torch.gather(final_dist, 1, y_t.unsqueeze(1))target_probs = target_probs.squeeze(1)# 将解码器端的PAD用padding mask遮掩掉, 防止计算loss时的干扰mask = torch.ne(y_t, 0).byte()# 为防止计算log(0)而做的数学上的平滑处理loss = -torch.log(target_probs + config.eps)# 关于coverage loss的处理逻辑代码.if config.coverage:# 按照论文中的公式12, 计算covloss.ct_min = torch.min(attention_weights, coverage_vector)cov_loss = torch.sum(ct_min, dim=1)# 按照论文中的公式13, 计算加入coverage机制后整个模型的损失值.loss = loss + config.LAMBDA * cov_loss# 先遮掩, 再添加损失值mask = mask.float()loss = loss * maskstep_losses.append(loss)# 第三步: 计算一个批次样本的损失值, 为反向传播做准备.sample_losses = torch.sum(torch.stack(step_losses, 1), 1)# 统计非PAD的字符个数, 作为当前批次序列的有效长度seq_len_mask = torch.ne(y, 0).byte().float()batch_seq_len = torch.sum(seq_len_mask, dim=1)# 计算批次样本的平均损失值batch_loss = torch.mean(sample_losses / batch_seq_len)return batch_loss

训练部分的主要逻辑代码train.py完全和之前保持一致, 无需修改. - 代码文件路径: /home/ec2-user/text_summary/pgn/src/train.py

if name == "main":
# Prepare dataset for training.
DEVICE = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
print('DEVICE: ', DEVICE)

# 将构建数据集的路径改为config.train_data_path, 具体采用单词替换后的14万样本训练集
dataset = PairDataset(config.train_data_path,max_enc_len=config.max_enc_len,max_dec_len=config.max_dec_len,truncate_enc=config.truncate_enc,truncate_dec=config.truncate_dec)
val_dataset = PairDataset(config.val_data_path,max_enc_len=config.max_enc_len,max_dec_len=config.max_dec_len,truncate_enc=config.truncate_enc,truncate_dec=config.truncate_dec)vocab = dataset.build_vocab(embed_file=config.embed_file)train(dataset, val_dataset, vocab, start_epoch=0)

输出结果:

DEVICE: cuda
Reading dataset /home/ec2-user/text_summary/pgn/data/train.txt... 140000 pairs.
Reading dataset /home/ec2-user/text_summary/pgn/data/dev.txt...
[[0%| | 0/4375 [00:00<?, ?it/s]
[[Epoch 0: 0%| | 0/43 [00:01<?, ?it/s]
[[Epoch 0: 0%| | 0/43 [00:01<?, ?it/s, Batch=0, Loss=7.2]
[[Epoch 0: 2%|▏ | 1/43 [00:01<01:05, 1.55s/it, Batch=0, Loss=7.2]
[[0%| | 1/4375 [00:01<1:53:09, 1.55s/it]
[[0%| | 2/4375 [00:02<1:47:52, 1.48s/it]
[[0%| | 3/4375 [00:04<1:45:31, 1.45s/it]
[[0%| | 4/4375 [00:05<1:42:08, 1.40s/it]
[[0%| | 5/4375 [00:06<1:39:37, 1.37s/it]
[[0%| | 6/4375 [00:08<1:51:42, 1.53s/it]

......
......
......

[[99%|█████████▊| 396/402 [03:58<00:03, 1.95it/s]
[[99%|█████████▉| 397/402 [03:59<00:02, 1.73it/s]
[[99%|█████████▉| 398/402 [04:00<00:02, 1.44it/s]
[[99%|█████████▉| 399/402 [04:01<00:01, 1.54it/s]
[[100%|█████████▉| 400/402 [04:01<00:01, 1.66it/s]
[[100%|█████████▉| 401/402 [04:02<00:00, 1.60it/s]
[[100%|██████████| 402/402 [04:03<00:00, 1.27it/s]
[[100%|██████████| 402/402 [04:03<00:00, 1.65it/s]
Epoch 9: 100%|██████████| 10/10 [21:27:18<00:00, 7723.90s/it, Loss=3.7]
12870 pairs.
loading data......
initializing optimizer......
scheduled_sampling mode.
model_name = pgn_model, pointer = True, coverage = True, fine_tune = False, scheduled_sampling = True, weight_tying = True,source = train
teacher_forcing = True
validating
training loss:4.523505130713327 validation loss:4.524958001655839
model_name = pgn_model, pointer = True, coverage = True, fine_tune = False, scheduled_sampling = True, weight_tying = True,source = train
teacher_forcing = True
validating
training loss:3.7671157992226736 validation loss:4.681057730733016
model_name = pgn_model, pointer = True, coverage = True, fine_tune = False, scheduled_sampling = True, weight_tying = True,source = train
teacher_forcing = True
validating
training loss:3.3115914395468575 validation loss:4.873124810000557
model_name = pgn_model, pointer = True, coverage = True, fine_tune = False, scheduled_sampling = True, weight_tying = True,source = train
teacher_forcing = True
validating
training loss:2.8917448364257812 validation loss:5.104775812249634
model_name = pgn_model, pointer = True, coverage = True, fine_tune = False, scheduled_sampling = True, weight_tying = True,source = train
teacher_forcing = True
validating
training loss:2.612732899093628 validation loss:5.468801263225195
model_name = pgn_model, pointer = True, coverage = True, fine_tune = False, scheduled_sampling = True, weight_tying = True,source = train
teacher_forcing = True
validating
training loss:2.5488693885530745 validation loss:5.7531706645416505
model_name = pgn_model, pointer = True, coverage = True, fine_tune = False, scheduled_sampling = True, weight_tying = True,source = train
teacher_forcing = True
validating
training loss:2.4237240751256523 validation loss:6.024709622127055
model_name = pgn_model, pointer = True, coverage = True, fine_tune = False, scheduled_sampling = True, weight_tying = True,source = train
teacher_forcing = False
validating
training loss:4.1108463181256775 validation loss:5.6224401267325421
model_name = pgn_model, pointer = True, coverage = True, fine_tune = False, scheduled_sampling = True, weight_tying = True,source = train
teacher_forcing = False
validating
training loss:3.87217611147229 validation loss:5.691734581721668
model_name = pgn_model, pointer = True, coverage = True, fine_tune = False, scheduled_sampling = True, weight_tying = True,source = train
teacher_forcing = False
validating
training loss:3.6852303215571813 validation loss:5.83677106265168
结论: 我们发现前7轮epoch的时候teacher_forcing=True, 最后3轮epoch的时候teacher_forcing=False. 并且我们验证集上最小的损失恰恰在第一个epoch就出现了. 结合前面几章的经验, 提示我们在当前数据集和任务上, 每次迭代训练2-3个epoch就足够了.

评估baseline-8模型¶
关于baseline-8模型的评估代码: - 代码文件predict.py只需要对参数beam_search做出设定. 同时要对几行代码做出修改.

代码文件rouge_eval.py全部保持不变.

首先评估贪心解码的模型效果: - 在代码文件predict.py中, 首先要对参数beam_search设置为False.

然后要在对应的位置改变embedding的代码.

代码文件路径: /home/ec2-user/text_summary/pgn/src/predict.py

import random
import os
import sys
import torch
import jieba

root_path = os.path.dirname(os.path.dirname(os.path.abspath(file)))
sys.path.append(root_path)

from utils import config
from src.model import PGN
from utils.dataset import PairDataset
from utils.func_utils import source2ids, outputids2words, timer, add2heap, replace_oovs

class Predict():
@timer(module='initalize predicter')
def init(self):
self.DEVICE = config.DEVICE

    dataset = PairDataset(config.train_data_path,max_enc_len=config.max_enc_len,max_dec_len=config.max_dec_len,truncate_enc=config.truncate_enc,truncate_dec=config.truncate_dec)self.vocab = dataset.build_vocab(embed_file=config.embed_file)self.model = PGN(self.vocab)self.stop_word = list(set([self.vocab[x.strip()] for x in open(config.stop_word_file).readlines()]))self.model.load_state_dict(torch.load(config.model_save_path))self.model.to(self.DEVICE)def greedy_search(self, x, max_sum_len, len_oovs, x_padding_masks):# -------------------------------------------------------------------------------------------# 下面一行代码是6.4小节采用weight tying策略时需要修改的, 最后一个参数传入Decoder的词嵌入参数encoder_output, encoder_states = self.model.encoder(replace_oovs(x, self.vocab),self.model.decoder.embedding)# -------------------------------------------------------------------------------------------# 用encoder的hidden state初始化decoder的hidden statedecoder_states = self.model.reduce_state(encoder_states)# 利用SOS作为解码器的初始化输入字符x_t = torch.ones(1) * self.vocab.SOSx_t = x_t.to(self.DEVICE, dtype=torch.int64)summary = [self.vocab.SOS]coverage_vector = torch.zeros((1, x.shape[1])).to(self.DEVICE)# 循环解码, 最多解码max_sum_len步while int(x_t.item()) != (self.vocab.EOS) and len(summary) < max_sum_len:context_vector, attention_weights = self.model.attention(decoder_states,encoder_output,x_padding_masks,coverage_vector)p_vocab, decoder_states, p_gen = self.model.decoder(x_t.unsqueeze(1),decoder_states,context_vector)final_dist = self.model.get_final_distribution(x, p_gen, p_vocab,attention_weights,torch.max(len_oovs))# 以贪心解码策略预测字符x_t = torch.argmax(final_dist, dim=1).to(self.DEVICE)decoder_word_idx = x_t.item()# 将预测的字符添加进结果摘要中summary.append(decoder_word_idx)x_t = replace_oovs(x_t, self.vocab)return summary# ---------------------------------------------------------------------
# 只需要将最后一个形参beam_search设置为False, 即可以贪心解码预测.
@timer(module='doing prediction')
def predict(self, text, tokenize=True, beam_search=False):
# ---------------------------------------------------------------------if isinstance(text, str) and tokenize:text = list(jieba.cut(text))x, oov = source2ids(text, self.vocab)x = torch.tensor(x).to(self.DEVICE)len_oovs = torch.tensor([len(oov)]).to(self.DEVICE)x_padding_masks = torch.ne(x, 0).byte().float()if beam_search:summary = self.beam_search(x.unsqueeze(0),max_sum_len=config.max_dec_steps,beam_width=config.beam_size,len_oovs=len_oovs,x_padding_masks=x_padding_masks)else:summary = self.greedy_search(x.unsqueeze(0),max_sum_len=config.max_dec_steps,len_oovs=len_oovs,x_padding_masks=x_padding_masks)summary = outputids2words(summary, oov, self.vocab)return summary.replace('<SOS>', '').replace('<EOS>', '').strip()

调用:

cd /home/ec2-user/text_summary/pgn/src/

python rouge_eval.py
输出结果:

实例化Rouge对象......
Reading from /home/ec2-user/text_summary/pgn/data/dev.txt
self.refs[]包含的样本数: 3000
Test set contains 3000 samples.
实例化Predict对象......
Reading dataset /home/ec2-user/text_summary/pgn/data/train.txt... 140000 pairs.
15.963327169418335 secs used for initalize predicter
利用模型对article进行预测, 并通过Rouge对象进行评估......
Building hypotheses.
0.07332539558410645 secs used for doing prediction
0.055742502212524414 secs used for doing prediction
0.037644386291503906 secs used for doing prediction
0.05304408073425293 secs used for doing prediction
0.030008316040039062 secs used for doing prediction
0.05388140678405762 secs used for doing prediction
0.01584935188293457 secs used for doing prediction
0.06547999382019043 secs used for doing prediction
0.05590486526489258 secs used for doing prediction
0.021546125411987305 secs used for doing prediction
0.04329800605773926 secs used for doing prediction
0.05656576156616211 secs used for doing prediction

......
......
......

0.05123019218444824 secs used for doing prediction
0.009801626205444336 secs used for doing prediction
0.05459141731262207 secs used for doing prediction
0.05174136161804199 secs used for doing prediction
0.05583930015563965 secs used for doing prediction
0.05422520637512207 secs used for doing prediction
0.052309513092041016 secs used for doing prediction
0.01446390151977539 secs used for doing prediction
116.43013048171997 secs used for building hypotheses
开始用Rouge规则进行评估......
Calculating average rouge scores.
rouge1: {'f': 0.20431242815278154, 'p': 0.21685821440593508, 'r': 0.2639005485159501}
rouge2: {'f': 0.05269086927945189, 'p': 0.05963918727814196, 'r': 0.07997281111602614}
rougeL: {'f': 0.24340123445669454, 'p': 0.3171595446736551, 'r': 0.23198070380849265}
将评估结果写入结果文件中......
再评估Beam search解码的效果: - 在代码文件predict.py中, 第一步对参数beam_search设置为True.

在代码文件predict.py中, 第二步对beam_search()函数做一行代码修改.

代码文件路径: /home/ec2-user/text_summary/pgn/src/predict.py

class Predict():
# 支持beam-search解码策略的主逻辑函数.
def beam_search(self, x, max_sum_len, beam_width, len_oovs, x_padding_masks):
# x: 编码器的输入张量, 即article(source document)
# max_sum_len: 本质上就是最大解码长度max_dec_len
# beam_size: 采用beam-search策略下的搜索宽度k
# len_oovs: OOV列表的长度
# x_padding_masks: 针对编码器的掩码张量, 把无效的PAD字符遮掩掉.

    # --------------------------------------------------------------------------------------------# 下面一行代码是6.4小节采用weight tying策略时唯一需要修改的一行代码, 第二个参数传入Decoder的词嵌入参数.# 第一步: 通过Encoder计算得到编码器的输出张量.encoder_output, encoder_states = self.model.encoder(replace_oovs(x, self.vocab),self.model.decoder.embedding)# ----------------------------------------------------------------------------------------------# 全零张量初始化coverage vectorcoverage_vector = torch.zeros((1, x.shape[1])).to(self.DEVICE)# 对encoder_states进行加和降维处理, 赋值给decoder_states.decoder_states = self.model.reduce_state(encoder_states)# 初始化hypothesis, 第一个token给SOS, 分数给0.init_beam = Beam([self.vocab.SOS], [0], decoder_states, coverage_vector)# beam_size本质上就是搜索宽度kk = beam_size# 初始化curr作为当前候选集, completed作为最终的hypothesis列表curr, completed = [init_beam], []# 通过for循环连续解码max_sum_len步, 每一步应用beam-search策略产生预测token.for _ in range(max_sum_len):# 初始化当前时间步的topk列表为空, 后续将beam-search的解码结果存储在topk中.topk = []for beam in curr:# 如果产生了一个EOS token, 则将beam对象追加进最终的hypothesis列表, 并将k值减1, 然后继续搜索.if beam.tokens[-1] == self.vocab.EOS:completed.append(beam)k -= 1continue# 遍历最好的k个候选集序列.for can in self.best_k(beam, k, encoder_output, x_padding_masks, x, torch.max(len_oovs)):# 利用小顶堆来维护一个top_k的candidates.# 小顶堆的值以当前序列的得分为准, 顺便也把候选集的id和候选集本身存储起来.add2heap(topk, (can.seq_score(), id(can), can), k)# 当前候选集是堆元素的index=2的值can.curr = [items[2] for items in topk]# 候选集数量已经达到搜索宽度的时候, 停止搜索.if len(completed) == beam_size:break# 将最后产生的候选集追加进completed中.completed += curr# 按照得分进行降序排列, 取分数最高的作为当前解码结果序列.result = sorted(completed, key=lambda x: x.seq_score(), reverse=True)[0].tokensreturn result# ---------------------------------------------------------------------
# 只需要将最后一个形参beam_search设置为True, 即可以Beam search解码预测.
@timer(module='doing prediction')
def predict(self, text, tokenize=True, beam_search=True):
# ---------------------------------------------------------------------if isinstance(text, str) and tokenize:text = list(jieba.cut(text))x, oov = source2ids(text, self.vocab)x = torch.tensor(x).to(self.DEVICE)len_oovs = torch.tensor([len(oov)]).to(self.DEVICE)x_padding_masks = torch.ne(x, 0).byte().float()if beam_search:summary = self.beam_search(x.unsqueeze(0),max_sum_len=config.max_dec_steps,beam_width=config.beam_size,len_oovs=len_oovs,x_padding_masks=x_padding_masks)else:summary = self.greedy_search(x.unsqueeze(0),max_sum_len=config.max_dec_steps,len_oovs=len_oovs,x_padding_masks=x_padding_masks)summary = outputids2words(summary, oov, self.vocab)return summary.replace('<SOS>', '').replace('<EOS>', '').strip()

调用:

cd /home/ec2-user/text_summary/pgn/src/

python rouge_eval.py
输出结果:

实例化Rouge对象......
Reading from /home/ec2-user/ec2-user/zhudejun/text_summary/text_summary/pgn/data/dev.txt
self.refs[]包含的样本数: 3000
Test set contains 3000 samples.
实例化Predict对象......
Reading dataset /home/ec2-user/ec2-user/zhudejun/text_summary/text_summary/pgn/data/train.txt... 139998 pairs.
15.918241024017334 secs used for initalize predicter
利用模型对article进行预测, 并通过Rouge对象进行评估......
Building hypotheses.
0.3589177131652832 secs used for doing prediction
0.3374664783477783 secs used for doing prediction
0.3456692695617676 secs used for doing prediction
0.3331456184387207 secs used for doing prediction
0.34146881103515625 secs used for doing prediction
0.334575891494751 secs used for doing prediction
0.33663010597229004 secs used for doing prediction
0.3577096462249756 secs used for doing prediction
0.3289070129394531 secs used for doing prediction
0.3339529037475586 secs used for doing prediction

......
......
......

0.40102291107177734 secs used for doing prediction
0.3524472713470459 secs used for doing prediction
0.35205864906311035 secs used for doing prediction
0.36118149757385254 secs used for doing prediction
0.40877795219421387 secs used for doing prediction
0.3536827564239502 secs used for doing prediction
0.366757869720459 secs used for doing prediction
0.35179853439331055 secs used for doing prediction
0.35098910331726074 secs used for doing prediction
0.35169148445129395 secs used for doing prediction
0.35124707221984863 secs used for doing prediction
0.3597400188446045 secs used for doing prediction
0.3530762195587158 secs used for doing prediction
0.35770344734191895 secs used for doing prediction
0.3506617546081543 secs used for doing prediction
1022.312926581192 secs used for building hypotheses
开始用Rouge规则进行评估......
Calculating average rouge scores.
rouge1: {'f': 0.19442847393917322, 'p': 0.1617446851444022, 'r': 0.34453045149293926}
rouge2: {'f': 0.05718565052066199, 'p': 0.04481349203649152, 'r': 0.11018874395912032}
rougeL: {'f': 0.24767991068636252, 'p': 0.28778950788684004, 'r': 0.2861432124810714}
将评估结果写入结果文件中......
小节总结¶
小节总结:

Scheduled sampling优化策略: - 策略介绍: 在生成式模型训练阶段, 常采用Teacher-forcing的策略辅助模型更快的收敛. 但是一直使用Teacher-forcing的策略, 会造成训练和预测的输入样本分布不一致, 也就是著名的"Exposure bias".

具体方法: 对于"Exposure bias", 很好的一个解决策略就是采用"Scheduled sampling", 即在训练阶段, 将ground truth和decoder predict混合起来使用, 作为下一个时间步的decoder input. 具体做法是每个时间步以一个p值概率进行Teacher forcing, 以(1 - p)值概率不进行Teacher forcing. 同时p值的大小随着batch或者epoch衰减.

有效性分析: 采用"Scheduled sampling", 在刚开始训练的阶段, 模型所具有的知识很少, 需要采用Teacher forcing的方式, 使用ground truth加速模型的学习和收敛. 到了训练后期, 模型已经掌握了很多数据分布的特征和数据本身的特征, 这个时候将decoder input替换成decoder predict, 保持和预测阶段一致, 来解决"Exposure bias"的问题.

Weight tying优化策略: - 策略介绍: 这个策略要解决的还是"Exposure bias"问题, 除了用训练后期去除掉Teacher forcing的方法. 我们还可以通过让Encoder和Decoder的词嵌入尽量一致来纠正这种偏差.

具体方法: "Weight tying"的字面意思就是权重的绑定, 具体做法是将Encoder和Decoder的embedding权重矩阵进行共享.

有效性分析: 对embedding权重矩阵进行共享, 这样就使得Encoder和Decoder的输入词向量表达完全相同了, 一定程度上可以缓解"Exposure bias".

7.1 硬件优化与模型部署
硬件优化与模型部署¶
学习目标¶
掌握对模型的硬件优化.

掌握模型的部署实践.

硬件优化¶
GPU优化¶
avatar

我们在考虑GPU优化时, 会考虑两种情况: - 第一种: GPU训练 + GPU部署.

第二种: GPU训练 + CPU部署.

第一种: GPU训练 + GPU部署.

这种情况就是前面从第三章到第六章所做的工作, 同学们已经很熟悉了, 再次不做复述了.

第二种: GPU训练 + CPU部署.

这种情况的核心操作在于将GPU训练好的参数加载到CPU上执行. - 代码文件路径: /home/ec2-user/text_summary/pgn/server/transform_model.py

import sys
import os
import torch
import time

root_path = os.path.dirname(os.path.dirname(os.path.abspath(file)))
sys.path.append(root_path)

from utils import config
from utils.vocab import Vocab
from utils.dataset import PairDataset
from utils.func_utils import timer
from src.model import PGN

@timer(module='initalize and transform model...')
def transform_GPU_to_CPU(origin_model_path, to_device='cpu'):
# 第一步: 构造数据集字典
dataset = PairDataset(config.train_data_path,
max_enc_len=config.max_enc_len,
max_dec_len=config.max_dec_len,
truncate_enc=config.truncate_enc,
truncate_dec=config.truncate_dec)

# 第二步: 利用字典, 实例化模型对象
vocab = dataset.build_vocab(embed_file=config.embed_file)
model = PGN(vocab)# 判断待加载的模型是否存在
if not os.path.exists(origin_model_path):print('The model file is not exists!')exit(0)model.load_state_dict(torch.load(origin_model_path))
print(model)
DEVICE = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
model.to(DEVICE)
print('-------------------------------------------------------------------')if to_device != 'cpu':print('Transform model to CPU!')exit(0)# 将在GPU上训练好的模型加载到CPU上
model.load_state_dict(torch.load(origin_model_path, map_location=lambda storage, loc:storage))
print(model)
model.to(to_device)

调用:

if name == 'main':
transform_GPU_to_CPU(config.model_save_path, 'cpu')
print('OK...')
输出结果:

Reading dataset /home/ec2-user/ec2-user/zhudejun/text_summary/text_summary/pgn/data/train.txt... 69999 pairs.
PGN(
(attention): Attention(
(Wh): Linear(in_features=1024, out_features=1024, bias=False)
(Ws): Linear(in_features=1024, out_features=1024, bias=True)
(wc): Linear(in_features=1, out_features=1024, bias=False)
(v): Linear(in_features=1024, out_features=1, bias=False)
)
(encoder): Encoder(
(embedding): Embedding(20004, 512)
(lstm): LSTM(512, 512, batch_first=True, bidirectional=True)
)
(decoder): Decoder(
(embedding): Embedding(20004, 512)
(lstm): LSTM(512, 512, batch_first=True)
(W1): Linear(in_features=1536, out_features=512, bias=True)
(W2): Linear(in_features=512, out_features=20004, bias=True)
(w_gen): Linear(in_features=2560, out_features=1, bias=True)
)
(reduce_state): ReduceState()
)

PGN(
(attention): Attention(
(Wh): Linear(in_features=1024, out_features=1024, bias=False)
(Ws): Linear(in_features=1024, out_features=1024, bias=True)
(wc): Linear(in_features=1, out_features=1024, bias=False)
(v): Linear(in_features=1024, out_features=1, bias=False)
)
(encoder): Encoder(
(embedding): Embedding(20004, 512)
(lstm): LSTM(512, 512, batch_first=True, bidirectional=True)
)
(decoder): Decoder(
(embedding): Embedding(20004, 512)
(lstm): LSTM(512, 512, batch_first=True)
(W1): Linear(in_features=1536, out_features=512, bias=True)
(W2): Linear(in_features=512, out_features=20004, bias=True)
(w_gen): Linear(in_features=2560, out_features=1, bias=True)
)
(reduce_state): ReduceState()
)
10.31801986694336 secs used for initalize and transform model...
OK...
将模型转移到CPU上执行预测程序: - 第一步: 实现预测代码predict.py编写.

第二步: 实现rouge_eval.py编写.

第三步: 实现inference.py编写.

第一步: 实现预测代码predict.py编写. - 代码文件路径: /home/ec2-user/text_summary/pgn/server/predict.py

导入相关系统工具包

import random
import os
import sys
import torch
import jieba

设置项目的root路径, 方便后续相关代码文件的导入

root_path = os.path.dirname(os.path.dirname(os.path.abspath(file)))
sys.path.append(root_path)

导入项目的相关代码文件

from utils import config
from src.model import PGN
from utils.dataset import PairDataset
from utils.func_utils import source2ids, outputids2words, Beam, timer, add2heap, replace_oovs

构建核心预测类Predict

class Predict():
@timer(module='initalize predicter')
def init(self):
self.DEVICE = config.DEVICE

    # 产生数据对, 为接下来的迭代器做数据准备, 注意这里面用的是训练集数据dataset = PairDataset(config.train_data_path,max_enc_len=config.max_enc_len,max_dec_len=config.max_dec_len,truncate_enc=config.truncate_enc,truncate_dec=config.truncate_dec)# 生成词汇表self.vocab = dataset.build_vocab(embed_file=config.embed_file)# 实例化PGN模型类, 这里面的模型是基于baseline-3的模型.self.model = PGN(self.vocab)self.stop_word = list(set([self.vocab[x.strip()] for x in open(config.stop_word_file).readlines()]))# --------------------------------------------------------------------------------------# 下面两行代码是将模型加载到CPU上的核心部分, 也是相比于baseline-4模型唯一变动的地方.# 将在GPU上训练好的模型加载到CPU上self.model.load_state_dict(torch.load(config.model_save_path,map_location=lambda storage,loc:storage))self.model.to(self.DEVICE)# --------------------------------------------------------------------------------------def greedy_search(self, x, max_sum_len, len_oovs, x_padding_masks):encoder_output, encoder_states = self.model.encoder(replace_oovs(x, self.vocab), None)# 用encoder的hidden state初始化decoder的hidden statedecoder_states = self.model.reduce_state(encoder_states)# 利用SOS作为解码器的初始化输入字符x_t = torch.ones(1) * self.vocab.SOSx_t = x_t.to(self.DEVICE, dtype=torch.int64)summary = [self.vocab.SOS]coverage_vector = torch.zeros((1, x.shape[1])).to(self.DEVICE)# 循环解码, 最多解码max_sum_len步while int(x_t.item()) != (self.vocab.EOS) and len(summary) < max_sum_len:context_vector, attention_weights = self.model.attention(decoder_states,encoder_output,x_padding_masks,coverage_vector)p_vocab, decoder_states, p_gen = self.model.decoder(x_t.unsqueeze(1),decoder_states,context_vector)final_dist = self.model.get_final_distribution(x, p_gen, p_vocab,attention_weights,torch.max(len_oovs))# 以贪心解码策略预测字符x_t = torch.argmax(final_dist, dim=1).to(self.DEVICE)decoder_word_idx = x_t.item()# 将预测的字符添加进结果摘要中summary.append(decoder_word_idx)x_t = replace_oovs(x_t, self.vocab)return summary# 利用解码器产生出一个vocab distribution, 来预测下一个token.
def best_k(self, beam, k, encoder_output, x_padding_masks, x, len_oovs):# beam: 代表Beam类的一个实例化对象.# k: 代表Beam-search中的重要参数beam_size=k.# encoder_output: 编码器的输出张量.# x_padding_masks: 输入序列的padding mask, 用于遮掩那些无效的PAD位置字符# x: 编码器的输入张量.# len_oovs: OOV列表的长度.# 当前时间步t的对应解析字符token, 将作为Decoder端的输入, 产生最终的vocab distribution.x_t = torch.tensor(beam.tokens[-1]).reshape(1, 1)x_t = x_t.to(self.DEVICE)# 通过注意力层attention, 得到context_vectorcontext_vector, attention_weights, coverage_vector = self.model.attention(beam.decoder_states,encoder_output,x_padding_masks,beam.coverage_vector)# 函数replace_oovs()将OOV单词替换成新的id值, 来避免解码器出现index-out-of-bound errorp_vocab, decoder_states, p_gen = self.model.decoder(replace_oovs(x_t, self.vocab),beam.decoder_states,context_vector)# 调用PGN网络中的函数, 得到最终的单词分布(包含OOV)final_dist = self.model.get_final_distribution(x, p_gen, p_vocab,attention_weights,torch.max(len_oovs))# 计算序列的log_probs分数log_probs = torch.log(final_dist.squeeze())# 如果当前Beam序列只有1个token, 要将一些无效字符删除掉, 以免影响序列的计算.# 至于这个无效字符的列表都包含什么, 也是利用bad case的分析, 结合数据观察得到的, 属于调优的一部分.if len(beam.tokens) == 1:forbidden_ids = [self.vocab[u"这"],self.vocab[u"此"],self.vocab[u"采用"],self.vocab[u","],self.vocab[u"。"]]log_probs[forbidden_ids] = -float('inf')# 对于EOS token的一个罚分处理.# 具体做法参考了https://opennmt.net/OpenNMT/translation/beam_search/.log_probs[self.vocab.EOS] *= config.gamma * x.size()[1] / len(beam.tokens)log_probs[self.vocab.UNK] = -float('inf')# 从log_probs中获取top_k分数的tokens, 这也正好符合beam-search的逻辑.topk_probs, topk_idx = torch.topk(log_probs, k)# 非常关键的一行代码: 利用top_k的单词, 来扩展beam-search搜索序列, 等效于将top_k单词追加到候选序列的末尾.best_k = [beam.extend(x, log_probs[x], decoder_states, coverage_vector) for x in topk_idx.tolist()]# 返回追加后的结果列表return best_k# 支持beam-search解码策略的主逻辑函数.
def beam_search(self, x, max_sum_len, beam_width, len_oovs, x_padding_masks):# x: 编码器的输入张量, 即article(source document)# max_sum_len: 本质上就是最大解码长度max_dec_len# beam_size: 采用beam-search策略下的搜索宽度k# len_oovs: OOV列表的长度# x_padding_masks: 针对编码器的掩码张量, 把无效的PAD字符遮掩掉.# 第一步: 通过Encoder计算得到编码器的输出张量.encoder_output, encoder_states = self.model.encoder(replace_oovs(x, self.vocab))# 全零张量初始化coverage vectorcoverage_vector = torch.zeros((1, x.shape[1])).to(self.DEVICE)# 对encoder_states进行加和降维处理, 赋值给decoder_states.decoder_states = self.model.reduce_state(encoder_states)# 初始化hypothesis, 第一个token给SOS, 分数给0.init_beam = Beam([self.vocab.SOS], [0], decoder_states, coverage_vector)# beam_size本质上就是搜索宽度kk = beam_size# 初始化curr作为当前候选集, completed作为最终的hypothesis列表curr, completed = [init_beam], []# 通过for循环连续解码max_sum_len步, 每一步应用beam-search策略产生预测token.for _ in range(max_sum_len):# 初始化当前时间步的topk列表为空, 后续将beam-search的解码结果存储在topk中.topk = []for beam in curr:# 如果产生了一个EOS token, 则将beam对象追加进最终的hypothesis列表, 并将k值减1, 然后继续搜索.if beam.tokens[-1] == self.vocab.EOS:completed.append(beam)k -= 1continue# 遍历最好的k个候选集序列.for can in self.best_k(beam, k, encoder_output, x_padding_masks, x, torch.max(len_oovs)):# 利用小顶堆来维护一个top_k的candidates.# 小顶堆的值以当前序列的得分为准, 顺便也把候选集的id和候选集本身存储起来.add2heap(topk, (can.seq_score(), id(can), can), k)# 当前候选集是堆元素的index=2的值can.curr = [items[2] for items in topk]# 候选集数量已经达到搜索宽度的时候, 停止搜索.if len(completed) == beam_size:break# 将最后产生的候选集追加进completed中.completed += curr# 按照得分进行降序排列, 取分数最高的作为当前解码结果序列.result = sorted(completed, key=lambda x: x.seq_score(), reverse=True)[0].tokensreturn result@timer(module='doing prediction')
def predict(self, text, tokenize=True, beam_search=False):# 很重要的一个参数是将beam_search设置为Trueif isinstance(text, str) and tokenize:text = list(jieba.cut(text))# 做模型所需的若干张量的初始化操作x, oov = source2ids(text, self.vocab)x = torch.tensor(x).to(self.DEVICE)len_oovs = torch.tensor([len(oov)]).to(self.DEVICE)x_padding_masks = torch.ne(x, 0).byte().float()# 采用Beam search策略进行解码if beam_search:summary = self.beam_search(x.unsqueeze(0),max_sum_len=config.max_dec_steps,beam_size=config.beam_size,len_oovs=len_oovs,x_padding_masks=x_padding_masks)# 采用贪心策略进行解码else:summary = self.greedy_search(x.unsqueeze(0),max_sum_len=config.max_dec_steps,len_oovs=len_oovs,x_padding_masks=x_padding_masks)# 将数字化ids张量转换为自然语言文本summary = outputids2words(summary, oov, self.vocab)# 在模型摘要结果中删除掉<SOS>, <EOS>字符.return summary.replace('<SOS>', '').replace('<EOS>', '').strip()

第二步: 实现rouge_eval.py编写. - 代码文件路径: /home/ec2-user/text_summary/pgn/server/rouge_eval.py

import os
import sys
from rouge import Rouge
import jieba

root_path = os.path.dirname(os.path.dirname(os.path.abspath(file)))
sys.path.append(root_path)

from utils.func_utils import timer

class RougeEval():
def init(self, path):
self.path = path
self.scores = None
self.rouge = Rouge()
self.sources = []
self.hypos = []
self.refs = []
self.process()

def process(self):print('Reading from ', self.path)with open(self.path, 'r') as test:for line in test:source, ref = line.strip().split('<SEP>')ref = ref.replace('。', '.')self.sources.append(source)self.refs.append(ref)print('self.refs[]包含的样本数: ', len(self.refs))print(f'Test set contains {len(self.sources)} samples.')@timer('building hypotheses')
def build_hypos(self, predict):# Generate hypos for the dataset.print('Building hypotheses.')for source in self.sources:self.hypos.append(predict.predict(source.split()))def get_average(self):assert len(self.hypos) > 0, 'Build hypotheses first!'print('Calculating average rouge scores.')return self.rouge.get_scores(self.hypos, self.refs, avg=True)def one_sample(self, hypo, ref):return self.rouge.get_scores(hypo, ref)[0]

第三步: 实现inference.py编写. - 代码文件路径: /home/ec2-user/text_summary/pgn/server/inference.py

import os
import sys
import torch
import time

root_path = os.path.dirname(os.path.dirname(os.path.abspath(file)))
sys.path.append(root_path)

from utils import config
from utils.vocab import Vocab
from utils.dataset import PairDataset
from utils.func_utils import timer
from src.model import PGN
from server.predict import Predict
from server.rouge_eval import RougeEval

真实的测试机是val_data_path: dev.txt

print('实例化Rouge对象......')
rouge_eval = RougeEval(config.val_data_path)
print('实例化Predict对象......')
predict = Predict()

利用模型对article进行预测

print('利用模型对article进行预测, 并通过Rouge对象进行评估......')
rouge_eval.build_hypos(predict)

将预测结果和标签abstract进行ROUGE规则计算

print('开始用Rouge规则进行评估......')
result = rouge_eval.get_average()

print('rouge1: ', result['rouge-1'])
print('rouge2: ', result['rouge-2'])
print('rougeL: ', result['rouge-l'])

最后将计算评估结果写入文件中

print('将评估结果写入结果文件中......')
with open(root_path + '/eval_result/rouge_result_greedy_cpu.txt', 'a') as f:
for r, metrics in result.items():
f.write(r + '\n')
for metric, value in metrics.items():
f.write(metric + ': ' + str(value * 100) + '\n')
调用:

cd /home/ec2-user/text_summary/pgn/server/

python inference.py
输出结果:

实例化Rouge对象......
Reading from /home/ec2-user/text_summary/pgn/data/dev.txt
self.refs[]包含的样本数: 3000
Test set contains 3000 samples.
实例化Predict对象......
Reading dataset /home/ec2-user/text_summary/pgn/data/train.txt... 70000 pairs.
9.418930053710938 secs used for initalize predicter
利用模型对article进行预测, 并通过Rouge对象进行评估......
Building hypotheses.
0.15330982208251953 secs used for doing prediction
0.18052959442138672 secs used for doing prediction
0.04963088035583496 secs used for doing prediction
0.0351109504699707 secs used for doing prediction
0.11708974838256836 secs used for doing prediction
0.04735374450683594 secs used for doing prediction
0.04634213447570801 secs used for doing prediction
0.1311793327331543 secs used for doing prediction
0.150376558303833 secs used for doing prediction
0.06444954872131348 secs used for doing prediction
0.12553858757019043 secs used for doing prediction
0.07853579521179199 secs used for doing prediction
0.051067352294921875 secs used for doing prediction
0.11544156074523926 secs used for doing prediction
0.11416888236999512 secs used for doing prediction

......
......
......

0.12541508674621582 secs used for doing prediction
0.4589982032775879 secs used for doing prediction
0.033342838287353516 secs used for doing prediction
0.09662842750549316 secs used for doing prediction
0.17136907577514648 secs used for doing prediction
0.18829894065856934 secs used for doing prediction
0.05489516258239746 secs used for doing prediction
0.24480724334716797 secs used for doing prediction
0.04336810111999512 secs used for doing prediction
0.04814314842224121 secs used for doing prediction
0.06292939186096191 secs used for doing prediction
0.057683467864990234 secs used for doing prediction
0.2856731414794922 secs used for doing prediction
0.19181203842163086 secs used for doing prediction
0.023878097534179688 secs used for doing prediction
0.046187400817871094 secs used for doing prediction
427.7368497848511 secs used for building hypotheses
开始用Rouge规则进行评估......
Calculating average rouge scores.
rouge1: {'f': 0.2621586067672694, 'p': 0.30965963799458307, 'r': 0.3154167172567273}
rouge2: {'f': 0.08909632183395856, 'p': 0.10533893122936726, 'r': 0.10886982715218156}
rougeL: {'f': 0.2825995304486134, 'p': 0.36590744862195695, 'r': 0.27657798744118395}
将评估结果写入结果文件中......
这里展示一下批量预测时的服务器状态截图:
avatar

结论1: 可以很清楚的看到两点, 第一点是单条样本的预测时间基本处于[0.05s, 0.5s]之间, 大量样本处于0.2-0.3秒之间. 对比于GPU上的预测, 单条样本的预测时间基本处于[0.01s, 0.08s]之间, 大量样本处于0.01-0.05秒之间.

结论2: 在当前4核16GB的服务器上, 因为采用了CPU做推断, 因此CPU资源被占用了53%. 相比于GPU推断时, CPU资源被占用大约10%.

结论3: 同样的模型在CPU上做推断的时间, 基本上是在GPU上做推断的5-10倍, 因此如果有条件的话尽量安排GPU部署. 实在为了节省预算那也只好在CPU上了.

模型部署¶
GPU环境Flask部署¶
首先进行GPU部署的测试. - 第一步: 编写主服务逻辑代码.

第二步: 启动Flask服务.

第三步: 编写测试代码.

第四步: 执行测试并检验结果.

第一步: 编写主服务逻辑代码. - 代码文件路径: /home/ec2-user/text_summary/pgn/server/app.py

导入必备工具包

import os
import sys
import torch
import time
import jieba

设定项目的root路径, 方便后续相关代码文件的导入

root_path = os.path.dirname(os.path.dirname(os.path.abspath(file)))
sys.path.append(root_path)

服务框架使用Flask, 导入工具包

from flask import Flask
from flask import request
app = Flask(name)

导入发送http请求的requests工具

import requests

导入项目相关的代码文件

from utils import config
from src.model import PGN
from utils.dataset import PairDataset
from utils.func_utils import source2ids, outputids2words, Beam, timer, add2heap, replace_oovs

导入专门针对GPU的预测类Predict

from server.predict import Predict

加载自定义的停用词字典

jieba.load_userdict(config.stop_word_file)

实例化Predict对象, 用于推断摘要, 提供服务请求

predict = Predict()
print('预测类Predict实例化完毕...')

设定文本摘要服务的路由和请求方法

@app.route('/v1/main_server/', methods=["POST"])
def main_server():
# 接收来自请求方发送的服务字段
uid = request.form['uid']
text = request.form['text']

# 对请求文本进行处理
article = jieba.lcut(text)# 调用预测类对象执行摘要提取
res = predict.predict(article)return res

第二步: 启动Flask服务.

cd /home/ec2-user/text_summary/pgn/server/

gunicorn -w 1 -b 0.0.0.0:5000 app:app
输出结果:

[2021-03-18 14:33:50 +0000] [21460] [INFO] Starting gunicorn 20.0.4
[2021-03-18 14:33:50 +0000] [21460] [INFO] Listening at: http://0.0.0.0:5000 (21460)
[2021-03-18 14:33:50 +0000] [21460] [INFO] Using worker: sync
[2021-03-18 14:33:50 +0000] [21463] [INFO] Booting worker with pid: 21463
Building prefix dict from the default dictionary ...
Loading model from cache /tmp/jieba.cache
Loading model cost 0.814 seconds.
Prefix dict has been built successfully.
Reading dataset /home/ec2-user/text_summary/pgn/data/train.txt... 70000 pairs.
9.655232667922974 secs used for initalize predicter
预测类Predict实例化完毕...
第三步: 编写测试代码. - 代码文件路径: /home/ec2-user/text_summary/pgn/server/test.py

import requests
import time

定义请求url和传入的data

url = "http://0.0.0.0:5000/v1/main_server/"
data = {"uid": "AI-6-202104", "text": "丰田花冠行驶十万公里皮带要换正时皮带,这种车型10万公里最好更换一次。皮>带时间长会出现老化出现断裂,会损坏发动机,造成车辆抛锚。发电机皮带都需要检查一下。"}

start_time = time.time()

向服务发送post请求

res = requests.post(url, data=data)

cost_time = time.time() - start_time

打印返回的结果

print('文本摘要是: ', res.text)
print('单条样本耗时: ', cost_time * 1000, 'ms')
第四步: 执行测试并检验结果.

cd /home/ec2-user/text_summary/pgn/server/

python test.py
输出结果:

文本摘要是: 长会 出现 这种 情况 , 需要 更换 。 皮带 时间 长 会 出现 这种 情况 。10 万公里 需要
单条样本耗时: 59.73529815673828 ms
GPU部署结论: 总体来说模型效果不错, 单条样本运行时间在60ms附近, 可以大规模应用于离线分析. 或者并发性不高的在线预测.

CPU环境Flask部署¶
然后进行CPU部署的测试. - 第一步: 编写主服务逻辑代码.

第二步: 启动Flask服务.

第三步: 编写测试代码.

第四步: 执行测试并检验结果.

第一步: 编写主服务逻辑代码. - 代码文件路径: /home/ec2-user/text_summary/pgn/server/app.py

导入必备工具包

import os
import sys
import torch
import time
import jieba

设定项目的root路径, 方便后续相关代码文件的导入

root_path = os.path.dirname(os.path.dirname(os.path.abspath(file)))
sys.path.append(root_path)

服务框架使用Flask, 导入工具包

from flask import Flask
from flask import request
app = Flask(name)

导入发送http请求的requests工具

import requests

导入项目相关的代码文件

from utils import config
from src.model import PGN
from utils.dataset import PairDataset
from utils.func_utils import source2ids, outputids2words, Beam, timer, add2heap, replace_oovs

导入专门针对CPU的预测类Predict

from server.predict_cpu import Predict

加载自定义的停用词字典

jieba.load_userdict(config.stop_word_file)

实例化Predict对象, 用于推断摘要, 提供服务请求

predict = Predict()
print('预测类Predict实例化完毕...')

设定文本摘要服务的路由和请求方法

@app.route('/v1/main_server/', methods=["POST"])
def main_server():
# 接收来自请求方发送的服务字段
uid = request.form['uid']
text = request.form['text']

# 对请求文本进行处理
article = jieba.lcut(text)# 调用预测类对象执行摘要提取
res = predict.predict(article)return res

第二步: 启动Flask服务:

cd /home/ec2-user/text_summary/pgn/server/

gunicorn -w 1 -b 0.0.0.0:5000 app:app
输出结果:

[2021-03-19 08:32:16 +0000] [4293] [INFO] Starting gunicorn 20.0.4
[2021-03-19 08:32:16 +0000] [4293] [INFO] Listening at: http://0.0.0.0:5000 (4293)
[2021-03-19 08:32:16 +0000] [4293] [INFO] Using worker: sync
[2021-03-19 08:32:16 +0000] [4302] [INFO] Booting worker with pid: 4302
Building prefix dict from the default dictionary ...
Loading model from cache /tmp/jieba.cache
Loading model cost 0.746 seconds.
Prefix dict has been built successfully.
Reading dataset /home/ec2-user/text_summary/pgn/data/train.txt... 70000 pairs.
6.9905993938446045 secs used for initalize predicter
预测类Predict实例化完毕...
第三步: 编写测试代码. - 代码文件路径: /home/ec2-user/text_summary/pgn/server/test.py

import requests
import time

定义请求url和传入的data

url = "http://0.0.0.0:5000/v1/main_server/"
data = {"uid": "AI-6-202104", "text": "丰田花冠行驶十万公里皮带要换正时皮带,这种车型10万公里最好更换一次。皮>带时间长会出现老化出现断裂,会损坏发动机,造成车辆抛锚。发电机皮带都需要检查一下。"}

start_time = time.time()

向服务发送post请求

res = requests.post(url, data=data)

cost_time = time.time() - start_time

打印返回的结果

print('文本摘要是: ', res.text)
print('单条样本耗时: ', cost_time * 1000, 'ms')
第四步: 执行测试并检验结果.

cd /home/ec2-user/text_summary/pgn/server/

python test.py
输出结果:

文本摘要是: 长会 出现 这种 情况 , 需要 更换 。 皮带 时间 长 会 出现 这种 情况 。10 万公里 需要
单条样本耗时: 154.26969528198242 ms
CPU部署结论: 因为采用的模型是同一个模型, 这里是baseline-3, 所以CPU部署后的模型输出和GPU完全一样. 唯一不同就是单条样本的耗时大概为150ms级别, 对于更长的文本耗时会更高. 因此更适合离线作业, 对于在线提取只能是分布式部署, 或者低并发量的业务场景.

小节总结¶
小节总结: 本小节主要针对硬件优化和模型部署展开讨论.

硬件优化: - GPU优化: - 训练阶段可以极大的提升效率, 预测阶段可以提高速度.
- GPU训练好的模型, 如果部署在CPU上面, 需要做特殊的转换, 将GPU tensor转换成CPU上的数据格式.

模型部署: - GPU部署: - 编写app.py主逻辑代码时要注意加载GPU版本的模型.
- 启动Flask服务进行测试, GPU部署大概可以在50ms级别完成预测.

CPU部署: - 编写app.py主逻辑代码时要注意加载CPU版本的模型.

启动Flask服务进行测试, CPU部署大概可以在150ms级别完成预测.

7.2 项目总结
文本摘要项目总结¶
学习目标¶
回顾整个项目迭代开发的过程.

展望项目未来的优化方向.

项目的迭代开发¶
先有一个Demo很重要!

模型是在一轮又一轮的迭代优化中变强大的!

模型, 算法, 数据, 硬件, 实验, 调参侠, 炼丹师, 一个都不能少!

项目的未来展望¶

尾声¶
寄语¶
程序猿, 测试猫, 产品狗, 大家都是一个阶级的战友, 携手共进才是上善之策.

学习之路: CV, NLP, 语音, 多模态, RL, 搜广推.

研发之路: 从单模态到多模态, 从应用到算法, 从底层再到应用.

祝福¶
下面有一段神秘的代码, 作为本项目最后的彩蛋:

print('\n'.join([''.join([('Love'[(x-y) % len('Love')]
if ((x0.05)**2+(y0.13)2-1)3-(x0.05)**2(y*0.13)**3 <= 0 else ' ')
for x in range(-30, 30)])
for y in range(15, -15, -1)]))

?

http://www.vanclimg.com/news/157.html

相关文章:

  • 虚拟机之间实现免密登录,SSH密钥认证
  • 新认识了一个既简单又好用的AI修图工具丨PhotoDirector Ultra 2025 v16.6 相片大师
  • LGP4171 [JSTS 2010] 满汉全席 学习笔记
  • 2025年7款效率翻倍项目管理软件工具清单,项目经理生存手册!
  • Java初步了解
  • 微服务学习-01-微服务技术栈导学
  • CVE-2021-25646 Apache Druid 远程代码执行漏洞 (复现)
  • 9N90-ASEMI工业驱动专用9N90
  • 读后感
  • 我的 10 级 Claude Code 速查表让你几分钟内变专家(你现在是第几级?)
  • Docker容器服务端口探测 - Leonardo
  • Docker搭建Hadoop集群
  • 总结与计划 7.28
  • Inventory System Plugin
  • 联邦学习中的持续学习技术
  • CHO细胞抗体表达|重组抗体纯化|高效抗体生产
  • new
  • (阶段二:落地) CMS 模板系统核心数据结构与流程梳理(SceneStack)
  • CAXA3D 实体设计2025最新版本下载安装图文教程,一键快速安装激活
  • 前端开发者的利器:6款最强类EXCEL表格插件对比,轻松实现Excel级交互
  • 软考系统分析师每日学习卡 | [日期:2025-07-28] | [今日主题:操作系统概述]
  • xshell的正则表达式
  • Linux查看PCIe版本及速率
  • 盈鹏飞嵌入式带你玩转T113系列tina5 SDK(7)-使用ADB来传输文件
  • CLion与Beta版:使用Unicode UTF-8提供全球语言支持
  • PowerShell脚本执行打包命令
  • 盈鹏飞嵌入式带你玩转T113系列tina5 SDK(6)-添加心跳灯
  • “轻”是态度,“强”是底气:折叠屏的“成人礼”
  • zip伪加密writeup
  • 25_1 C++函数参数传递方式