50

从零开始使用深度学习训练一个新闻分类器

 6 years ago
source link: https://mp.weixin.qq.com/s/qR-d9Zay-7NJZgmYYlwn0A
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

我们在浏览新闻的时候,通常会看到新闻网站对每个新闻都进行了分类:

Image

新闻分类的应用相当广泛。对于网站来说,可以根据你看得较多的新闻类别给你推荐新闻;对于用户来说,则是可以忽略掉不感兴趣的分类,提高了浏览体验。

比如我抓取了近一个月,网易新闻 APP 向我推荐的 13.7 万条新闻,以下的新闻类别图彻底地暴露了本人是个喜欢看体育和花边娱乐新闻的俗人:

那么各大新闻网站的新闻分类是如何对新闻进行分类的呢?据了解,有可能是网站编辑人工进行分类,但目前更有可能是通过各种高级的算法和 AI 来进行自动分类。

本文使用 Python 和 Keras,展示了如何从收集数据开始,到数据分析、预处理,再到使用 深度学习/神经网络 创建一个准确率达到人类水准的新闻分类器。

虽然这是个比较啰里吧嗦的教程,但是这里的 “从零开始”,是假设你已熟悉了 Python 基础语法的基础上的。

选择要爬取的数据源

我们知道,在监督学习中,数据的预处理往往花费的时间要比真正训练模型的时候还要多。所以找到一个规整易于爬取的数据源是相当重要的。

来看下我们的需求,即“给出一个新闻标题,返回该新闻的分类”,那么我们收集的每一条数据中都必须有 新闻标题 和 分类。

在各个提供了历史新闻的新闻网站中,中国新闻网 ( http://www.chinanews.com ) 的滚动新闻页面应该是最容易爬取的了:

网页链接,如:http://www.chinanews.com/scroll-news/2017/1224/news.shtml 可以直接指定某天的新闻,并且该页面直接包含了当天所有的新闻标题和对应的分类。

编写数据爬虫

还是以上面的链接 http://www.chinanews.com/scroll-news/2017/1224/news.shtml 为例,在页面上右键点击“显示网页源代码”,很容易可以找到我们所需要爬取的新闻标题和分类:

<div class="content_list">
    <ul>
        <li><div class="dd_lm">[<a href=http://www.chinanews.com/world.shtml>国际</a>]</div> <div class="dd_bt"><a href="/gj/2017/12-24/8408109.shtml">天津交响乐团为尼泊尔带来新年音乐会</a></div><div class="dd_time">12-24 23:53</div></li>
        <li><div class="dd_lm">[<a href=http://www.chinanews.com/wenhua.shtml>文化</a>]</div> <div class="dd_bt"><a href="/cul/2017/12-24/8408110.shtml">漫画“吾皇”系列作者白茶:走了心的作品才能走红</a></div><div class="dd_time">12-24 23:50</div></li>
        省略一大波输出...
    </ul>
</div>

获取网页内容

我这里使用 requests :

base_url = "http://www.chinanews.com/scroll-news/%s/%s/news.shtml" % (year, month_day)
resp = requests.get(base_url, timeout=10)
resp.encoding = "gbk"

需要注意的是该网页的编码方式是 gbk,需要用 resp.encoding 显式说明,否则会给接下来的解析带来乱码的麻烦。

这里我们就要用到大名鼎鼎的“美丽汤” BeautifulSoup 了。当然这里也可以用正则解析出来,但是比起处心积累地构造过几天都不知道怎么来的正则,还是用 bs 比较省心:

soup = BeautifulSoup(resp.text, "html.parser")

首先找到包含了我们要抓的数据的 div 段 <div class="content_list"> ... </div>

content = soup.find('div', class_='content_list')

再从 div 里找到所有的 li ,即每条包含 新闻标题和分类 的列表,如 

<li><div class="dd_lm">[<a href=http://www.chinanews.com/world.shtml>国际</a>]</div> <div class="dd_bt"><a href="/gj/2017/12-24/8408109.shtml">天津交响乐团为尼泊尔带来新年音乐会</a></div><div class="dd_time">12-24 23:53</div></li>
li = content.find_all("li")

每个 li 中又包含了三个 div ,分别是 分类、新闻标题、发布时间。这里我们只需要前两个部分,但是还需要抓取下链接,作为去重的 key :

category = item.find('div', class_='dd_lm').text.replace(r'[', '').replace(r']', '')
title = item.find('div', class_='dd_bt').text
href = item.find('div', class_='dd_bt').a.attrs['href']

我比较喜欢将数据凑成字典存到 Redis,方便之后使用 Pandas 处理。

di = {'category':category, 'title':title}
if not cli.hget('chinanews', href):
    cli.hset('chinanews', href, str(di))

上面提到页面的 URL 里需要用到年月日信息,我这里使用了 timedelta 来从当前时间往后计算,比如下面就是抓取 1500 天的新闻标题及分类:

today = datetime.today()
for i in range(1, 1500):
    whichday = (today - timedelta(days=i)).strftime("%Y-%m%d")
    fetch_oneday(whichday)
    time.sleep(2)   # 做个有风度的爬虫

(爬虫的详细代码见文章最后的链接)

大概一小时的收集之后,我们就有了 2299879 将近 230 万条数据:1500 天的新闻标题和对应的分类。

在对数据进行预处理之前,我们必须对数据进行一个初略的分析,去掉一些噪点之类的数据。

从 Redis 里读入数据

cli = redis.Redis()
data = cli.hgetall('chinanews')
df = pd.DataFrame([ast.literal_eval(data[k]) for k in data])

去除无用类别

categories = df.groupby('category').size()
pie = Pie("分类")
pie.add("", categories.index.tolist(), categories.values.tolist(), is_label_show=True, is_legend_show=False)
pie

一共有 30 个分类,并可以看到数据的分布并不均匀,我们先去掉数据量少于 20000 条的类别,因为数据量少对模型的准确性还是有影响的;另外对于 “图片”、“视频”、“报摘” 这三种分类,很明显没有区分度,只能去掉了:

categories = df.groupby('category').size()
categories = categories[categories > 20000].index.tolist()
# 设置 map 方法
filted = df['category'].map(lambda x: x in categories)
# 然后应用到 dataframe 上
df_filted = df[filted]

df_filted = df_filted[ (df_filted['category'] != u'图片') & (df_filted['category'] != u'视频') & (df_filted['category'] != u'报摘')]

现在我们还有 2178902 条数据,以及 22 种分类:

数据预处理

现在我们的数据是长这样子的:

神经网络只能处理数字,因此我们需要将 category 和 title 分别映射为数字。

category

分类的转换比较容易,可以将所有分类从 1 开始映射,首先生成一个分类的列表,然后生成两个字典,分别是 分类名称:数字 以及 数字:分类名称 的字典,方便映射和查找:

catagories = df_filted.groupby('category').size().index.tolist()
catagory_dict = {}
int_catagory = {}
for i, k in enumerate(catagories):
    catagory_dict.update({k:i})
    int_catagory.update({i:k})

dataframe 加上一个映射的 column,使用 apply 方法:

df_filted['c2id'] = df_filted['category'].apply(lambda x: catagory_dict[x])

这样就完成了 category 到 数字的映射,重新取 title,c2id 两列来准备接下来的工作:

prepared_data = df_filted[ ['title', 'c2id'] ]

title

新闻标题其实是个句子,我们要把句子映射为数字列表,有两种方式:

第一种是分词后再映射,另一种是直接单个字进行转换映射。

前者的优点是准确率高 ( 假设当新闻标题里出现“勒布朗”这个词时,99% 可能这是条体育新闻,而如果是单个“勒”字很显然比较难进行分类 );缺点是在预测新标题时,如果出现了词库里没有的词,则无法进行预测 ( 人名之类的最常出现这种问题,无法进行映射转换,且分词效果也不好,如果是按单个字来映射则没有这个问题 )。

后者的优缺点刚好相反。

因为我们最终的目的是用作新数据的分类,所以用单字进行转换映射较好。

prepared_data['words'] = prepared_data['title'].apply(lambda x: re.findall('[\x80-\xff]{3}|[\w\W]', x))

正则 [\x80-\xff]{3}|[\w\W] 中,[\x80-\xff]{3} 配置中文字符,[\w\W] 配置标点符号空格等其他所有字符。

转换后的结果:

生成字的映射字典:

all_words = []
for w in prepared_data['words']:
    all_words.extend(w)
word_dict = pd.DataFrame(pd.Series(all_words).value_counts())
word_dict['id'] = list(range(1, len(word_dict)+1))

我们得到了一个 6790 个 “字” ( 包括标点符号和空格等 ) 的字典,基本上包括了所有常用字,对应的 id 则为不同的数字。

字映射为数字

新加一个 w2v 的列存放转换后的数字队列 ( 执行比较久 ):

prepared_data['w2v'] = prepared_data['words'].apply(lambda x: list(word_dict['id'][x]))

然后补全或者截断为固定长度为 25 的队列 ( 新闻标题一般不会超过 25 个字 ):

maxlen = 25
prepared_data['w2v'] = list(sequence.pad_sequences(prepared_data['w2v'], maxlen=maxlen))

最终准备好的 dataframe:

现在,我们的 X 数据是 w2v 列,而 标签 (target) Y 则是 c2id 列。

生成训练数据和测试数据

简单地使用 sklearn.model_selection 的 train_test_split 随机将所有数据以 3:1 的比例分隔为训练数据和测试数据:

seed = 7
X = np.array(list(prepared_data['w2v']))
Y = np.array(list(prepared_data['c2id']))
x_train, x_test, y_train, y_test = train_test_split(X, Y, test_size=0.25, random_state=seed)

这里,比较重要的是要对 Y 进行 to_categorical 处理,把 Y 变成 one-hot 的形式。

为什么要转成 one-hot 呢?因为分类器往往默认数据数据是连续的,并且是有序的。但是按照我们上述的表示,数字并不是有序的,而是随机分配的。使用 one-hot,使得这些特征互斥,每次只有一个激活。因此数据就变成稀疏的了。

y_train = to_categorical(y_train)
y_test = to_categorical(y_test)

LSTM 模型

文本模型,通常都是使用 RNN 模型,我们直接参考 Keras 的 LSTM 官方例子即可:

https://github.com/keras-team/keras/blob/master/examples/imdb_lstm.py

model = Sequential()
model.add(Embedding(len(word_dict)+1, 256))
model.add(LSTM(256))
model.add(Dropout(0.5))
model.add(Dense(y_train.shape[1]))
model.add(Activation('softmax'))
model.compile(loss='categorical_crossentropy', optimizer='adam', metrics=['accuracy'])

与例子中的模型不同的是,我在中间加了层 dropout 避免过拟合;另外我们是多分类,所以最后的全连接层,输出应该是类别的个数,激活函数改为 softmax,损失函数改为 categorical_crossentropy。

model.fit(x_train, y_train, batch_size=128, epochs=20)

因为数据较大,所以训练起来速度较慢,在使用了一块 GTX1080 GPU 的情况下,一个轮次还要差不多 10 分钟左右。

10 个轮次的训练之后,准确率下降变慢,停留在 74% 左右。

测试数据验证

model.evaluate(x=x_test, y=y_test)
Image

测试集的准确率在 69% 左右。

使用新数据来测试模型

结果有点差强人意,我们用新数据测试下吧。

新数据需要用预处理中的方式处理成数字列表:

def predict_(title):
    words = re.findall('[\x80-\xff]{3}|[\w\W]', title)
    w2v = [word_dict[word_dict['0']==x]['id'].values[0] for x in words]
    xn = sequence.pad_sequences([w2v], maxlen=maxlen)
    predicted = model.predict_classes(xn, verbose=0)[0]
    return int_catagory[predicted]

再拉取新的新闻测试下效果:

以下是部分输出:

从上面的结果可以看出,其实部分新闻标题的分类,感觉模型判断出来的结果更准确,比如:

[社会] prediction:[I  T] 华为否认提前发年终奖 网传消息实为销售激励计划
[社会] prediction:[房产] 中国这个地方如同仙境 豪华别墅每平米仅1300元
[汽车] prediction:[I  T] 阿里巴巴高管调整 蒋凡任淘宝总裁 靖捷任天猫总裁
[社会] prediction:[港澳] 香港青年学子“冬聚吉林”:多层面了解国家变化

如果是将预测的前三种分类都当成正确的话:

def predict_3(title):
    words = re.findall('[\x80-\xff]{3}|[\w\W]', title)
    w2v = [word_dict[word_dict['0']==x]['id'].values[0] for x in words]
    xn = sequence.pad_sequences([w2v], maxlen=maxlen)
    predicted = model.predict(xn, verbose=0)[0]
    predicted_sort = predicted.argsort() 
    li = [(int_catagory[p], predicted[p]*100) for p in predicted_sort[-3:]]
    return li[::-1]

可以看到成功率接近 90%

从这个结果来看,我们的模型还是可行的。

model = Sequential()
model.add(Embedding(len(word_dict)+1, 256))
model.add(GRU(256))
model.add(Dense(y_train.shape[1]))
model.add(Activation('softmax'))
model.compile(loss='categorical_crossentropy', optimizer='adam', metrics=['accuracy'])

精确度略低于 LSTM ( 训练集 73.52% / 10 epochs;测试集 67.83% );

但训练用时较少,每轮次 480s 左右。

BiLSTM + CNN

embedding_size=128
hidden_size=256

model = Sequential()
model.add(Embedding(input_dim=len(word_dict)+1, output_dim=128, input_length=25))
model.add(Bidirectional(LSTM(256, return_sequences=True)))
model.add(TimeDistributed(Dense(64)))
model.add(Activation('softplus'))
model.add(MaxPooling1D(5))
model.add(Flatten())
model.add(Dense(y_train.shape[1]))
model.add(Activation('softmax'))
model.compile(loss='categorical_crossentropy', optimizer='adam', metrics=['accuracy'])

训练集精确度较 LSTM 略高 ( 76.55% / 10 epochs),但测试集的精确度略低 ( 68.29% ),且训练用时更长,需要 1080s / 1 epochs。

保存模型和数据

最终还是使用了 LSTM 模型。将数据和模型保存下来,方便下一次调用:

model.save('model.hdf5')

import pickle
def save_obj(obj, name ):
    with open(name + '.pkl', 'wb') as f:
        pickle.dump(obj, f, pickle.HIGHEST_PROTOCOL)

save_obj(int_catagory, 'int_catagory')
save_obj(catagory_dict, 'catagory_dict')
word_dict.to_csv('word_dict.csv', encoding='utf8')
prepared_data.to_csv('prepared_data.csv', encoding='utf8')

比如类似某云平台的这种 API 服务:

使用 Flask 搭建一个 API 服务,用户 Post 一个新闻标题过来,返回预测的前三种可能分类:

➜  ~ curl -H "Content-Type: application/json" -X POST -d '{"title":"彭文生:房价下降才能促进宏观杠杆率的可持续下"}' http://192.168.15.24:5000/classify
{
  "result": {
    "1.category": "房产",
    "1.possibility": "0.650669",
    "2.category": "财经",
    "2.possibility": "0.285769",
    "3.category": "金融",
    "3.possibility": "0.0191255"
  }
}
➜  ~ curl -H "Content-Type: application/json" -X POST -d '{"title":"潘粤明后台自拍 扮相温文尔雅表情搞怪反差萌"}' http://192.168.15.24:5000/classify
{
  "result": {
    "1.category": "娱乐",
    "1.possibility": "0.845201",
    "2.category": "台湾",
    "2.possibility": "0.108812",
    "3.category": "港澳",
    "3.possibility": "0.0148304"
  }
}

还不赖吧,现在,你可以炒掉那些只会给新闻分类的网站编辑了。

代码及 Jupyter 笔记:https://github.com/jackhuntcn/news_category_classify


Have Fun! 

不定期更新 入门级及不靠谱的 数据抓取、数据分析、深度学习以及其他有趣脚本的原创文章,欢迎长按下面的二维码关注👇


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK