概述
语言模型与新闻的内容预测,分为 4 个阶段:
- 从主流新闻网站科技频道抓取新闻内容;
- 数据预处理;
- 实现 n-gram 语言模型;
- 在测试集上检验模型。
完整代码:https://github.com/Jed-Z/ngram-text-prediction
本文地址:https://www.jeddd.com/article/python-ngram-language-prediction.html
数据获取
第一阶段的任务是从新闻网站上获取语料数据。
经过简单的考察,我选择新浪滚动新闻科技频道作为抓取的目标网站,选择该网站的原因有二:一是因为新浪是国内知名的新闻网站;二是由于滚动新闻页面结构简单,方便抓取。
抓取新闻内容的工作主要有两步:
- 从滚动新闻列表获取到新闻详情页的链接;
- 进入到新闻详情页面,抓取新闻正文并保存到文件中。
下面先说明如何抓取一篇新闻的正文内容(第 2 步),然后再说明如何获取到新闻详情页的链接(第 1 步)。
“数据获取”阶段的代码见 get_news_text.ipynb
。
抓取新闻正文
从滚动新闻页面随意点开一个新闻详情页,查看其源代码,得知新闻正文是静态的,因此可以直接用 Python Requests 模块获取页面并配合 BeautifulSoup 模块进行 HTML 内容的解析。
用浏览器 F12 开发者工具查看页面结构,发现新闻标题存在于一个 class 为 main-title
的标签中。(实际抓取时发现,并不是所有页面都是这样的,也可能是存在于 ID 为 artibodyTitle
的标签中。分情况处理即可。)使用同样的方法得知新闻正文在一个 ID 为 artibody
的 div
标签中。
给定一个页面的 URL,首先用 Requests 下载页面,使用正确的编码对网页内容解码,然后用 BeautifulSoup 解析 HTML。
re = requests.get(url) # 下载页面
re.raise_for_status() # 若请求失败则抛出异常
re.encoding = re.apparent_encoding # 检测编码
soup = BeautifulSoup(re.text) # 解析HTML
接下来剔除页面中的 <script>
标签。这部操作主要是为了避免抓取到的正文中混入 JS 脚本。使用 BeautifulSoup 可以很方便地完成这一操作:
for s in soup('script'):
s.extract()
接下来使用 BeautifulSoup 的 select
方法抓取的文章标题、正文即可。方便起见,我们将这部分功能封装在一个函数中。如下:
def getOneArticle(url):
"""
获取一篇新闻的纯文本正文。
Params:
url: 新闻网址。
Returns:
由新闻标题、正文字符串组成的二元组。
"""
re = requests.get(url) # 下载页面
re.raise_for_status() # 若请求失败则抛出异常
re.encoding = re.apparent_encoding # 检测编码
soup = BeautifulSoup(re.text) # 解析HTML
for s in soup('script'):
s.extract() # 丢弃HTML中的JS内容
title = soup.select(".main-title")
if len(title) == 0:
title = soup.select("#artibodyTitle")
title = title[0].text
content = soup.find("div", id="artibody").text.strip()
return title, content
从滚动新闻列表获取详情页链接
滚动新闻列表即科技滚动新闻_新浪网。
通过检查源代码的方式得知,滚动新闻主页是动态生成的,因此无法直接用 Requests 获取到网页内容,需要用到 Selenium 模块进行渲染页面、模拟点击按钮等操作。(关于 Selenium 和 WebDriver 的安装、配置过程等本文不做叙述。)
浏览器打开页面并渲染完成后,通过 XPath 得到新闻列表,并逐一获取到这些新闻的链接,最后用一个 for
循环把这些链接放入一个列表中。将这部分功能封装在函数 getPageLinks()
中。
def getPageLinks():
"""
获取滚动新闻一个页面的所有新闻链接。
Params:
无。
Returns:
新闻链接的列表。
"""
global browser
results = browser.find_elements_by_xpath('//div[@class="d_list_txt"]/ul/li/span/a')
links = []
for result in results:
links.append(result.get_attribute('href'))
return links
得到链接的列表后,对每个链接调用上面我们写好的 getOneArticle()
函数,然后把新闻正文以 UTF-8 编码保存在文件中即可。为了后期模型的训练效果,我这里把正文长度小于 1000 字的新闻都忽略了,确保语料库足够大。
抓取完一整页滚动列表的新闻后,通过模拟点击页面上的“下一页”按钮来翻页。通过观察滚动列表页可以发现,点击翻页按钮实际是执行了一个 JavaScript 函数 newsList.page.next()
。所以实际上不需要模拟点击这个按钮,只需要用 execute_script
方法执行这个函数即可。
此外,为了避免新闻列表中有重复的新闻,我用一个 set 来保存所有抓取过的新闻 URL,并在抓取新闻前检查当前新闻是否已经抓取过了。
这部分完整代码如下:
counter = 0
metadata = [] # (id, 标题)元组的列表
visited = set()
browser = webdriver.Chrome()
browser.implicitly_wait(10)
browser.get(homepage) # 滚动新闻首页
while True:
links = getPageLinks() # 获取滚动新闻一个页面的所有新闻链接
for link in links:
if link not in visited: # 避免重复爬取
visited.add(link)
title, content = getOneArticle(link)
if len(content) >= 1000: # 忽略1000字以下的文章
counter += 1
metadata.append((counter, title)) # 记录id与标题的对应关系
with open("data/" + str(counter) + ".txt", "w", encoding='utf-8') as f:
f.write(content)
print(counter, title, len(content), link)
browser.execute_script("newsList.page.next();return false;") # 翻页
browser.implicitly_wait(10) # 等待页面加载完毕
if counter >= 1000:
break
browser.close()
print("[+] Done.")
上述代码中,counter
变量用于计数,它保存的是成功抓取到文件的新闻数量。我只在抓取完一整页新闻后才检查 counter
是否大于等于 1000,因此实际抓取的新闻数量可能略大于 1000。(实际上共抓取了 1002 条新闻。)
至于新闻标题,我将每个编号的新闻对应的标题保存到了 metadata.csv
文件中。(该操作是可选的,后面并没有用到。)该文件内容如下:
浏览抓取结果
抓取下来的新闻全部存放在 data
目录下,命名为 1.txt
~ 1002.txt
,共 1002 个文件。随机抽取几个手动检查,均符合预期。下面是随机抽取的两个:
数据预处理
尽管前面爬取到的数据已经是纯文本的新闻原文了,但是其中可能还存在一些特殊字符,如标点符号、空格、段首空白字符(缩进制表符 Tab)、音标、外文字符等。从数据的角度看,这些特殊字符属于噪声,需要去除或进行其他处理。
预处理后的新闻文件的每行是新闻原文中的一个句子分词后的结果(以空格分隔),且需要构建词表,此表的每行是用空格隔开的序号以及单词。
“数据预处理”阶段的代码见 preprocessing.ipynb
。
删除空白字符
空白字符属于噪声,首先从新闻全文中去掉这些常见的空白字符:
content = content.replace(u'\t', '') # 去除制表符
content = content.replace(u'\xa0', '') # 去除全角空格
content = content.replace(u'\u3000', '') # 去除不间断空白符
切割成句子
根据要求,一行一个句子,所以要将新闻文本按标点符号(如逗号、句号、换行符等)切割成句子。这里使用正则表达式来完成这一工作:
content = re.split(',|。|;|?|!|:|\n', content)
content = list(filter(None, content)) # 去除空句子
得到的 content
是一个由新闻句子(字符串)为元素的列表。
分词并去除停止词
我们对句子做进一步的特殊字符处理,然后就可以开始分词了。通过随机选取部分新闻在多种分词工具上进行测试,我发现结巴中文分词(jieba)的效果最好,而且分词速度足够快(清华的 THULAC 实在是太慢了…),可以快速地对 1002 个新闻文件执行分词操作。因此我选用结巴中文分词工具对全部新闻文本进行分词。
分词后,去除停止词。中文停止词表可以在这个 GitHub 仓库找到。
with open('stopwords/中文停用词表.txt') as f:
stopwords = f.readlines()
stopwords = set(map(lambda x:x.strip(), stopwords)) # 去除末尾换行符
# 分词、去除停止词
sentence = jieba.cut(sentence) # 分词
word_list = [word.strip() for word in sentence if word.strip() and word not in stopwords] # 去除停止词
sentence = ' '.join(word_list) # 用空格分隔分词结果
预处理的结果保存在 data
目录下的文本文件中,序号与之前的一一对应,共 1002 个文件。
构建词表
在上面分词步骤遍历所有新闻时,还需要记录每个句子的分词结果包含那些词,将这些词记录在一个字典 word_table
中,字典的键是单词,值是该单词出现的频数。
for word in word_list:
word = word.strip()
word_table[word] = word_table.get(word, 0) + 1 # 频数加一
在对 1002 条新闻都进行了以上操作后,词表将变得很大,因为其包含了一千多条新闻中的词。将词表按照“序号-词”的格式保存至 wordtable.txt
文件中。
# 保存词表到文件
with open('wordtable2.txt', 'w', encoding='utf-8') as f:
for i, word in enumerate(word_table):
f.write(str(i) + ' ' + word + '\n')
我的词表中包含 54079 个词,序号 0 ~ 54078。
n-gram 语言模型
模型简介
n-gram 模型是基于马尔科夫假设的简单语言模型,该模型中假设句子中每个词出现的概率取决于它前面的 n-1 个词,即:
$$ p(w_i|w_1,w_2,\dots ,w_{i-1})=p(w_i|w_{i-n+1},\dots ,w_{i-1}) $$
对于一个句子,我们将句子首尾两端增加两个标志:<BOS>
表示句子开头,<EOS>
表示句子结尾。一个有 $m$ 个词的句子表示为:
$$ \text{<BOS>}w_1w_2\cdots w_m\text{<EOS>} $$
句子的概率为每个基元(词)的概率之积:
$$ p(s)=\prod_{i=1}^{m+1} p(w_i|w_{i-n+1},\dots ,w_{i-1}) $$
n-gram 中的 n 是可以指定的超参数,常用的有:n=2 称为 bigram;n=3 称为 trigram。
前面我们爬取并预处理过的新闻文本数据即是训练语料库,词的概率可以由最大似然估计的方法求的。要使用 n-gram 模型进行词预测,可以依次将词表中每个词填入待预测的空位,选出使得句子概率大的词作为预测结果。
下面我们将实现一个 n=3 的 trigram 模型。
构建语料库
前面已经有了 1002 个文本文件,每个文件为一篇新闻的内容,文件中每一行为一个分词后的句子,词与词之间以空格隔开。模型语料的来源就是这 1002 篇新闻。
遍历所有新闻中的所有句子,在每个句子前后添加上 <BOS>
和 <EOS>
,然后从中提取所有的 n-gram 元组(n=3 时就是三元组),保存在 ngrams_list
列表中;同时还要提取由词历史构成的二元组,保存在 prefix_list
列表中。在这两个列表中保存了所有的 3-gram 以及前缀二元组,接着使用 collections 模块中的 Counter 对每个元组出现的次数进行计数,得到 ngrams_counter
和 prefix_counter
。这两个 counter 将用于最大似然估计概率。
ngrams_list = [] # n元组(分子)
prefix_list = [] # n-1元组(分母)
# 遍历所有预处理过的新闻文件
for i, datafile in enumerate(os.listdir(data_path)):
with open(data_path + datafile, encoding='utf-8') as f:
for line in f:
sentence = ['<BOS>'] + line.split() + ['<EOS>'] # 列表,形如:['<BOS>', '显得', '十分', '明亮', '<EOS>']
ngrams = list(zip(*[sentence[i:] for i in range(n)])) # 一个句子中n-gram元组的列表
prefix = list(zip(*[sentence[i:] for i in range(n-1)])) # 历史前缀元组的列表
ngrams_list += ngrams
prefix_list += prefix
ngrams_counter = Counter(ngrams_list)
prefix_counter = Counter(prefix_list)
这样,语料库就构建好了。
进行预测
测试集的形式是挖去一个词的句子,我们要使用 n-gram 模型预测这个挖去的词是什么。先预览一下测试集:
思路是将词表中的所有词作为候选词,依次尝试将每个词填入空,然后计算句子的概率,最终选取使得句子概率最大的那个词。由于每个待测句子都只有一个待预测词,所以实际上无需计算整个句子的概率,只需计算待预测词附近的概率即可——具体来讲就是待预测词及其前后各 n-1 个词,其他距离待预测词较远的单词的概率不受待预测词的改变而改变。
当 n=3 时,句子的概率可以等价于包括待预测词 M 在内的 5 个词的联合概率。概率可用最大似然估计来表示。举个例子,比如有一个由五个单词构成的句子“ABMCD”,该句子的概率为:
$$ p(A,B,M,C,D)=\frac{c(A,B,M)}{c(A,B)}\times \frac{c(B,M,C)}{c(B,M)}\times \frac{c(M,C,D)}{c(M,C)} $$
先实现一个计算句子概率的函数,输入一个由多个词构成的列表,函数内将列表转换为 n-gram,然后计算 n-gram 概率的乘积。为了避免数据匮乏的问题,这里采用了加一法(拉普拉斯平滑)进行数据平滑。
def probability(sentence):
"""
计算一个句子的概率。
Params:
sentence: 由词构成的列表表示的句子。
Returns:
句子的概率。
"""
prob = 1 # 初始化句子概率
ngrams = list(zip(*[sentence[i:] for i in range(n)])) # 将句子处理成n-gram的列表
for ngram in ngrams:
# 累乘每个n-gram的概率,并使用加一法进行数据平滑
prob *= (1 + ngrams_counter[ngram]) / (len(prefix_counter) + prefix_counter[(ngram[0], ngram[1])])
return prob
遍历词表,选出使得句子概率最大的词作为待预测词。为了更方便地检验模型,我增加了一个参数 cand_num
,使得预测结果可以有多个词,概率由大到小排序(cand_num=1
时仅预测一个概率最大的词)。
def predict(pre_sentence, post_sentence, all_words, cand_num=1):
"""
根据历史进行一个词的预测。
Params:
pre_sentence: 待预测词之前部分句子的分词结果构成的列表。
post_sentence: 待预测词之后部分句子的分词结果构成的列表。
all_words: 所有候选词构成的列表。
cand_num: 候选词数,默认为1。
Returns:
一个含有cand_num个元素的列表,表示预测的词,概率由大到小排序;
如果预测失败,返回None。
"""
word_prob = [] # 候选词及其概率构成的元组的列表
for word in all_words:
# 实际上不需要算整个句子的概率,只需要算待预测词附近的概率即可,因为句子其他部分的概率不受待预测词影响
test_sentence = pre_sentence[-(n-1):] + [word] + post_sentence[:(n-1)] # 待预测词及其前后各n-1个词的列表
word_prob.append( (word, probability(test_sentence)) ) # (词, 概率)元组构成的列表
return sorted(word_prob, key=lambda tup: tup[1], reverse=True)[:cand_num] # 按概率降序排序并取前cand_num个
对于测试集中的每个句子 question
,首先将句子按照与预处理相同的方法分词、去除停止词,然后用 predict
函数进行一个句子的预测。
# 加载测试集标签(答案)
with open('testset/answer.txt', encoding='utf-8') as f:
answers = [answer.strip() for answer in f] # 答案构成的列表
prediction_file = open(prediction_path + 'prediction_ngram.txt', 'w', encoding='utf-8') # 存放预测结果
# 开始测试
correct_count = 0 # 预测正确的数量
with open('testset/questions.txt', encoding='utf-8') as f:
questions = f.readlines() # 测试集规模
total_count = len(questions)
for i, question in enumerate(questions):
question = question.strip()
pre_mask = question[:question.index('[MASK]')] # 待预测词的历史
post_mask = question[question.index('[MASK]')+6:] # 待预测词后的剩余部分
pre_sentence = jieba.cut(pre_mask.replace(',', ' ')) # 分词
post_sentence = jieba.cut(post_mask.replace(',', ' ')) # 分词
pre_sentence = [word.strip() for word in pre_sentence if word.strip() and word not in stopwords] # 去除停止词、空串
post_sentence = [word.strip() for word in post_sentence if word.strip() and word not in stopwords] # 去除停止词、空串
predict_cand = predict(pre_sentence, post_sentence, all_words) # 预测一个概率最大的词
prediction_file.write(' '.join([w[0] for w in predict_cand]) + '\n') # 将预测结果写入文件
# 遍历多个预测结果
for j, p in enumerate(predict_cand):
if p[0] == answers[i]:
print(i, '{} [{}] {}'.format(pre_mask, p[0], post_mask))
correct_count += 1
break
prediction_file.close()
最终,3-gram 模型的准确率为 12%。正确预测的句子如下,行首的数字为句子序号,方括号内的为预测正确的词:
0 一直以来,有不少家长误认为,在开车时将孩子放在儿童安全 [座椅] 上是最为安全的方式。
5 体验人员在驴妈妈平台体验预订景点门票时,发现 [故宫] 门票均绑定了内馆门票、导览、讲解等收费项目,没有单卖的故宫门票供选择,涉嫌强制消费。
11 总而言之,从大环境上来说,目前仍然是处于教育用户的阶段,加上扫地 [机器人] 品类的下限很低,市场还没有形成一个足够清晰的认知。
12 TOF技术虽然在手机应用上占据了大量的市场,但大多数 [应用] 由于比较“鸡肋”而难以支撑其进一步发展。
14 在谈到与特斯拉的竞争时,小鹏汽车高管表示,小鹏没有照搬特斯拉的系统,但特斯拉系统中很好的部分会去 [学习] ,与此同时特斯拉踩过坑我们可以避免。
18 在三大运营商积极展开5G部署工作的同时,各大手机 [厂商] 也在积极布局5G产品,说到这里就不能不提到OPPO了。
29 学生禁带手机进校园的规定落实之初,被抓到玩手机的学生会由 [班主任] 谈话,没收的手机交给家长。
36 随后李国庆微博发长文回应,称俞渝对我私生活做出的 [诽谤] 和诬蔑,我只想在这里回应一句话:等着收律师函吧。
39 对大多数人来说,再花钱购买另一项订阅 [服务] 可能听起来并不是很有吸引力,但苹果押注其初创公司云集的内容,即使是最节俭的客户也会被说服。
52 9月10日晚间消息,阿里20周年年会今日举行,马云登台发言正式 [宣布] 卸任阿里巴巴董事局主席。
79 安信证券研报指出,随着资本市场对人工智能认知的不断深入,市场对 [人工智能] 的投资日趋成熟和理性,投融资频次在2018年以来有所放缓,但投资金额持续增加。
81 上海移动的工作人员也向记者表示,暂时没有听说停止销售该类套餐。目前三大运营商中,只有中国电信明确将停售达量限速 [套餐] 。
测试集中的 100 个句子的预测结果保存在predictions/prediction_ngram.txt
中。
可以发现预测结果中存在很多“原”,这是因为“原”这个单词是词表中的第一个词,而待预测句子的历史在训练集中没有出现过,因此 predict
函数就按顺序返回了第一个词而已,没有特殊意义。事实上,这种情况的正确处理方式是不进行预测,直接标记为无法预测即可。
本文地址:https://www.jeddd.com/article/python-ngram-language-prediction.html
probability函数的分母是不是写错了?不应该加 len(prefix_counter)吧?平滑应该是分母分子都加1就可以呀?
没写错,这个确实就是加一平滑
你好,请问可以分享代码学习一下吗?
完整代码已经上传,文中有链接
学习了