用 Python 实现 n-gram 语言模型进行新闻文本内容预测

概述

语言模型与新闻的内容预测,分为 4 个阶段:

  1. 从主流新闻网站科技频道抓取新闻内容;
  2. 数据预处理;
  3. 实现 n-gram 语言模型;
  4. 在测试集上检验模型。

数据获取

第一阶段的任务是从新闻网站上获取语料数据。

经过简单的考察,我选择新浪滚动新闻科技频道作为抓取的目标网站,选择该网站的原因有二:一是因为新浪是国内知名的新闻网站;二是由于滚动新闻页面结构简单,方便抓取。

抓取新闻内容的工作主要有两步:

  1. 从滚动新闻列表获取到新闻详情页的链接;
  2. 进入到新闻详情页面,抓取新闻正文并保存到文件中。

下面先说明如何抓取一篇新闻的正文内容(第 2 步),然后再说明如何获取到新闻详情页的链接(第 1 步)。

“数据获取”阶段的代码见 get_news_text.ipynb

抓取新闻正文

从滚动新闻页面随意点开一个新闻详情页,查看其源代码,得知新闻正文是静态的,因此可以直接用 Python Requests 模块获取页面并配合 BeautifulSoup 模块进行 HTML 内容的解析。

用浏览器 F12 开发者工具查看页面结构,发现新闻标题存在于一个 class 为 main-title 的标签中。(实际抓取时发现,并不是所有页面都是这样的,也可能是存在于 ID 为 artibodyTitle 的标签中。分情况处理即可。)使用同样的方法得知新闻正文在一个 ID 为 artibodydiv 标签中。

1572072079733
1572072079733

给定一个页面的 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 条新闻。)

image-20191130171954399
image-20191130171954399

至于新闻标题,我将每个编号的新闻对应的标题保存到了 metadata.csv 文件中。(该操作是可选的,后面并没有用到。)该文件内容如下:

image-20191130172030991
image-20191130172030991

浏览抓取结果

抓取下来的新闻全部存放在 data 目录下,命名为 1.txt ~ 1002.txt,共 1002 个文件。随机抽取几个手动检查,均符合预期。下面是随机抽取的两个:

image-20191127152857227
image-20191127152857227

image-20191127152916474
image-20191127152916474

数据预处理

尽管前面爬取到的数据已经是纯文本的新闻原文了,但是其中可能还存在一些特殊字符,如标点符号、空格、段首空白字符(缩进制表符 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 个文件。

image-20191127153240758
image-20191127153240758

image-20191127153306940
image-20191127153306940

构建词表

在上面分词步骤遍历所有新闻时,还需要记录每个句子的分词结果包含那些词,将这些词记录在一个字典 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。

image-20191127153002564
image-20191127153002564

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_counterprefix_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 模型预测这个挖去的词是什么。先预览一下测试集:

image-20191127153448304
image-20191127153448304

思路是将词表中的所有词作为候选词,依次尝试将每个词填入空,然后计算句子的概率,最终选取使得句子概率最大的那个词。由于每个待测句子都只有一个待预测词,所以实际上无需计算整个句子的概率,只需计算待预测词附近的概率即可——具体来讲就是待预测词及其前后各 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 函数就按顺序返回了第一个词而已,没有特殊意义。事实上,这种情况的正确处理方式是不进行预测,直接标记为无法预测即可。

完整代码

(尚未上传)


Comments

添加新评论