一种新的文本表示方法——基于搜狗新闻数据的分类研究
本文记录作者的研究成果和无敌详细的实验过程,干货满满!
本文是完全原创,首发于简书,转载请私信。
本文仅用于学术交流!
自然语言处理实战(二),地址:https://www.jianshu.com/p/c7dc9346f97b
自然语言处理实战(三),地址:https://www.jianshu.com/p/effed68ae9a5
引言
新闻作为网络上广泛传播的一种文学载体,一直得到工业界和学术界的高度重视。随之互联网的高速发展和普及,网络上产生了大量的新闻文本,其种类之繁多,数量之丰富,远超普通报刊杂志。互联网上的信息,以其快速著称,包括产生快、传播快和更新快。在这浩如烟好的互联网新闻世界,网民往往会根据自己的兴趣,选择特定类型的新闻进行阅读。而传统的新闻分类主要由人工操作,工作量大而且枯燥无味,因此,新闻分类一直是在文本分类领域众多研究者比较关心的问题。
针对新闻类文本分类,有基于TF-IDF加权思路的,有基于卷积神经网络的,还有基于LAD的,甚至有人提出基于胶囊网络来做。不管怎么说,各种分类模型,最大的改进都是在文本表示上,而使用的算法,基本都输熟知的算法。笔者个人也认为,在自然语言处理中,数据的预处理和表示方法对模型的效果至关重要,因此,本文主要探讨的也是文本表示方法。
文本表示方法
文本表示方法大致分为三类,即基于向量空间模型、基于主题模型和基于神经网络的方法。
向量空间模型是将文本表示成实数值分量所构成的向量,一般而言,每个分量对应一个词项,相当于将文本表示成空间中的一个点。向量不仅可以用来训练分类器,而且计算向量之间的相似度可以度量文本之间的相似度。最常用的是TF-IDF计算方式,即向量的维度对应词表的大小,对应维度使用TF-IDF计算。向量空间模型的优点是简单明了,向量维度意义明确,效果不错,但也存在明显的缺点,其一,维度随着词表增大而增大,且向量高度稀疏;其二,无法处理“一义多词”和“一词多义”问题。
基于LDA主题模型的文档主题向量方法, 即用文本属于各主题的概率向量表示文本。冯国明等人提出了一种基于LDA矩阵的文本表示方法,将文本段落以语料最大段落数作为行数补齐,然后使用LDA对文档段落进行向量表示得到段落向量, 将段落向量逐行排列为矩阵, 这样文本就被表示为一个稠密、保留较多信息的段落向量矩阵(Paragraph Vectors Matrix, PVM)。其流程如图1 所示。
其中,Pi为文档中的段落, K 为 LDA 模型主题数, N 为语料中文档的最大段落数, pv 为文档中段落经过LDA 处理后的段落向量。
现今,基于神经网络的方法受到广泛关注,各种各样的模型被相继提出,主要可分为三类:
第一类,基于词向量合成的模型,该类方法仅是在词向量基础上简单合成;
第二类,基于RNN/CNN的模型,该类方法利用更复杂的深度学习模型对文本进行建模;
第三类,基于注意力机制的模型,在已有神经网络模型基础上,引入注意力机制,提升文本建模效果。
关于文本表示更详细的说明,请参考我的另一篇文章常用的文本表示模型。
基于段落关键词的文本表示模型
文本表示是自然语言处理中的重点,冯国明的论文《基于CapsNet的中文文本分类研究》,提出了基于LDA文本矩阵表示方法和基于Word2Vec 词向量表示方法(W2V_cuboid )。前面我们介绍了第一种方法,下面简单说明一下冯国明是怎么利用Word2Vec 词向量表示文本的。
W2V_cuboid 文本表示法将文档中的词进行方阵排列,以语料中最大词数为准进行补齐形成词矩阵,使用训练语料对 Word2Vec 进行训练, 利用训练好的模型将词矩阵的每个词转化为词向量,形成词向量体(Word Vectors Cuboid, WVC), 得到稠密、保留 完整文本信息、基于语义的文本表示。其流程如图 2所示。
冯国明的两种表示方法都将文本表示成了含有大量参数的矩阵,不仅需要大量的训练样本,而且第二种方法对噪声没有过滤能力,难以避免训练困难和过拟合的问题。
基于此,笔者试图寻找一种轻量级的文本表示方法。首先分析业务场景,我们的目的是找到一种新闻文本的分类方法,因为新闻的类型本身并没有多复杂,在众多平台中,顶多将他们的新闻分为十几到五十种类型。所以,笔者认为新闻文本的分类并不需要过高维度或过多参数的表示模型。
本文的文本表示方法的思路如下:1. 对于每篇新闻文本,每一个段落里面提取三个关键词;2. 将每一个关键词用训练好的Word2Vec表示;3. 将每一段的三个关键词拼成一个一维向量;4. 将每一段的段落向量进行矢量相加,得到文章向量表示。
语聊预处理
训练词向量
训练词向量选择的是最新的维基百科数据集,训练过程及代码如下:
def dataprocess():
"""读取wiki语料库,并将xml格式内容解析为txt格式"""
space = ' '
i = 0
output = open('D:\python\demo\paper\zhwiki-articles.txt', 'w', encoding='utf-8')
wiki = WikiCorpus('D:\python\demo\paper\zhwiki-latest-pages-articles.xml.bz2', lemmatize=False, dictionary={})
for text in wiki.get_texts():
output.write(space.join(text) + '\n')
i = i + 1
if i % 1000 == 0:
print('Saved ' + str(i) + ' articles')
output.close()
print('Finished Saved ' + str(i) + ' articles')
print('Finished!!!!!!')
def createstoplist(stoppath):
"""加载停用词表"""
print('load stopwords...')
stoplist=[line.strip() for line in codecs.open(stoppath,'r',encoding='utf-8').readlines()]
stopwords={}.fromkeys(stoplist)
return stopwords
def isNumOrAlpha(word):
"""判断是否是英文或数字,或者英文或数字的组合"""
try:
return word.encode('utf-8').isalpha() or word.encode('utf-8').isdigit() or word.encode('utf-8').isalnum()
except UnicodeEncodeError:
return False
def deal_control_char(s):
"""函数传入带不可见字符串s,返回过滤后字符串"""
temp = re.sub('[\001\002\003\004\005\006\007\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1a]+', '', s)
return temp
def trans_seg():
"""
繁体转简体,jieba分词
"""
stopwords = createstoplist('D:\python\demo\paper\word2vec\stopwords.txt')
cc = opencc.OpenCC('t2s')
i = 0
with codecs.open('D:\python\demo\paper\zhwiki-segment1.txt', 'a+', 'utf-8') as wopen:
print("开始分词操作")
with codecs.open('D:\python\demo\paper\sw_cut_std_wiki_00.00', 'r', 'utf-8') as ropen:
lines = ropen.readlines()
for line in lines:
line = line.strip()
line = deal_control_char(line)
i += 1
if i % 10000 == 0:
print('handle line', i)
text = ''
for char in line.split():
if isNumOrAlpha(char):
continue
char = cc.convert(char)
text += char
words = jieba.cut(text)
seg = ''
for word in words:
if word not in stopwords:
if not isNumOrAlpha(word) and len(word) > 1:
seg += word + ' '
wopen.write(seg + '\n')
print("success!")
def word2vec():
"""利用gensim中的word2vec训练词向量"""
print('Start...')
rawdata='D:\python\demo\paper\zhwiki-segment1.txt'
modelpath='D:\python\demo\paper\sw_cut_std_wiki_00.model'
model=Word2Vec(LineSentence(rawdata), size=200, window=5, min_count=5, workers=multiprocessing.cpu_count())
model.save(modelpath)
#model.wv.save_word2vec_format(vectorpath,binary=False)
print("Finished!")
按照上述方法,笔者训练了两种词向量,一个64维,一个200维。保存的模型文件如下图3:
上面那三个是200维词向量的模型,下面的是64维词向量的模型。
处理搜狗新闻数据
搜狗新闻数据集
打开之后的样子如下:
处理过程直接上代码:
def prapare_data():
"""
1 找出每篇文章中的段落,并用<p>标签包起来
2 给每一篇新闻加上标签和段落数(主要为了分析数据)
:return:
"""
rootdir = 'D:\python\demo\paper\material\sogou_all'
filelist = os.listdir(rootdir)
for item in range(0, len(filelist)):
path = os.path.join(rootdir, filelist[item])
if os.path.isfile(path):
filelist[item] = path
for filepath in filelist:
label = re.findall(r'(.*?).csv', os.path.basename(filepath))[0]
print("正在处理" + label + ".csv 中...")
with codecs.open(filepath, 'r', 'utf-8') as ropen:
# 将处理后的文件放在train3文件夹中
writer_all_article = codecs.open('D:\python\demo\paper\material\\train3\\' + label + ".csv", 'a+', 'utf-8')
reader = csv.reader(ropen)
writer_all = csv.writer(writer_all_article)
article_list = [row[1] for row in reader]
for article in article_list:
# 对于每篇文章,首先分出段落,将段落用<p>包裹
article = re.sub(r'。([\s\t\n\r\v\f\b]+)', '<p><p>', article)
article = re.sub(r'(.+)', '<p>' + article + '<p>', article)
paragraphs = re.findall(r'<p>(.*?)<p>', article)
row = []
row.append(label)
row.append(len(paragraphs))
row.append(article)
writer_all.writerow(row)
writer_all_article.close()
处理后的数据如图所示:
拿出其中一个看:
<p>我来说两句作者:摄影/缪礼东奥运官方网站6月27日讯 今日9时35分,最后一棒火炬手、山西省体操击剑管理中心队员穆勇峰点燃设在云冈石窟第20窟大佛前广场的圣火盆,北京奥运会火炬接力大同站传递活动随之圆满结束<p><p>奥运火炬山西境内的传递活动在云冈石窟第20窟大佛前广场顺利结束。云冈石窟是在1500年前北魏文成帝时开凿的,这一宏丽工程是4万名工匠用60多年的创造性劳动构筑的世界闻名艺术宝库。现存大小窟龛53个,石雕塑像51000余尊。石窟群中,第三窟最大,面积达1250平方米;第五窟的坐佛最大,高达17米;第二十窟13米高的露天大佛最富神韵,是云冈石窟的代表作<p><p>云冈石窟是我国多民族文化相互融合的伟大成就,是华夏文明的实物象征,把这里作为火炬传递的结束点,突出了大同历史文化名城的城市定位,展示了大同悠久历史和多民族文化交融的特色美丽。图为大同云冈石窟。(奥运官方网站火炬接力前方报道记者 缪礼东 文并摄 www.beijing2008.cn)(责任编辑:李奇)<p>
发现每个文件中的第一行没啥用,直接删掉就好。
为了简单期间,笔者只选取了房产、教育、旅游、时尚和体育五个类别进行研究,并且从每个类别中随机采样3000篇新闻。
def sogou_sample():
"""
1 对预处理得到的搜狗数据进行采样,每一类随机抽取3000个样本
2 选择的五个类别分别是[fangchan_long, jiaoyu_long, lvyou_long, shishang_long, tiyu_long]
:return:
"""
selected = ['fangchan.csv', 'jiaoyu.csv', 'lvyou.csv', 'shishang.csv', 'tiyu.csv']
folder_path = 'D:\python\demo\paper\material\\train3'
files = os.listdir(folder_path)
# 抽样数据保存到train4文件
with codecs.open('D:\python\demo\paper\material\\train4\\train3000.csv', 'a+', 'utf-8') as wopen:
writer = csv.writer(wopen)
for file in files:
print("开始处理", file)
if file in selected:
file_path = os.path.join(folder_path, file)
with codecs.open(file_path, 'r', 'utf-8') as ropen:
read_list = csv.reader(ropen)
sample_data = random.sample(list(read_list), 3000)
for line in sample_data:
writer.writerow(line)
print(file + "处理完成")
得到了一个train3000.csv的文件,里面含有五个列表,15000篇新闻数据。
数据已经准备好,可以开始本文所提出的文本表示模型的生成了。
构建文本表示模型
上面已经讲过思路,这里不再赘述,直接上代码:
def prepare_tensor(file_path, model_path, model_vector_length, keywords_num=3):
"""
1 根据输入的文本抽取每个段落的关键词
2 得到每个关键词的词向量
3 得到段落的向量表示
4 得到文章的矩阵表示
:param file_path: 要处理的数据文件,格式为“标签 段落数(长) 正文”的csv文件
:param model_path: 要使用的word2vec训练得到的词向量模型路径
:param model_vector_length: 词向量的长度
:param keywords_num: 每段提取的关键词的个数,默认为3
:return:
"""
stopwords = createstoplist('D:\python\demo\paper\word2vec\stopwords.txt')
# 保存csv文件的时候可能会出现编码篡改,首先解决编码问题
handleEncoding(file_path, 'D:\python\demo\paper\material\\train4\\train3000u.csv')
file_path = "D:\python\demo\paper\material\\train4\\train3000u.csv"
label_dict = {'fangchan': 1, 'jiaoyu': 2, 'lvyou': 3, 'shishang': 4, 'tiyu': 5}
corpus_list = []
digit_label_list = []
article_tensor_list = []
with codecs.open(file_path, 'r', 'utf-8') as f:
reader = csv.reader(f)
article_list = [[row[0], row[2]] for row in reader]
count_article = 0
for article in article_list: # 遍历每篇文章
print("正在处理第%d篇文章" %count_article)
# 对于每篇文章,分出段落
paragraphs = re.findall(r'<p>(.*?)<p>', article[1])
# 保存每个段落的关键词
paragraph_keywords_list = []
# 得到每篇文章的关键词列表,每段取3个关键词
for paragraph in paragraphs:
# 先分词
words = jieba.cut(paragraph)
# 去除停用词
seg = ''
for word in words:
if word not in stopwords:
# if not isNumOrAlpha(word) and len(word) > 1:
seg += word + ' '
tr4w = TextRank4Keyword()
tr4w.analyze(text=seg, window=2)
paragraph_keywords_list.append(tr4w.get_keywords(num=keywords_num, word_min_len=2))
# 降维,得到一篇文章的向量表示
article_tensor = make_tensor(paragraph_keywords_list, model_path, model_vector_length)
article_tensor_list.append(article_tensor)
# 将这一篇文章的标签转换为数字标签
digit_label = label_dict[article[0]]
digit_label_list.append(digit_label)
count_article += 1
corpus_list.append(digit_label_list)
corpus_list.append(article_tensor_list)
return corpus_list
def make_tensor(paragraph_keywords_list, model_path, model_vector_length):
"""
:param paragraph_keywords_list: 一篇文章的各个段落的关键词列表,如[[{'word': '中国', 'weight': 0.07172397924117986}, {'word': '表示', 'weight': 0.05006493160735426},
:param model_path: 要使用的word2vec训练得到的词向量模型路径
:return: 一篇文章的向量表示,1*192维
"""
model = gensim.models.Word2Vec.load(model_path)
# 每篇文章的向量表示
article_tensor = np.zeros(model_vector_length * 3)
for paragraph_keywords in paragraph_keywords_list:
paragraph_tensor = []
for keyword_dict in paragraph_keywords:
# 取出每个段落的每个关键词
keyword = keyword_dict['word']
if keyword in model:
word_vec = list(model[keyword])
# 得到一个192维的段落向量
paragraph_tensor += word_vec
if len(paragraph_tensor) < 192:
# 如果得到的段落向量小于192,补零
for i in range(192 - len(paragraph_tensor)):
paragraph_tensor.append(0)
# 创建文章的向量表示
article_tensor += np.array(paragraph_tensor)
return list(article_tensor)
def handleEncoding(original_file, newfile):
"""
修改文件为utf-8的有效编码
:param original_file:
:param newfile:
:return:
"""
# newfile=original_file[0:original_file.rfind(.)]+'_copy.csv'
f = open(original_file, 'rb+')
content = f.read() # 读取文件内容,content为bytes类型,而非string类型
source_encoding = 'utf-8'
#####确定encoding类型
try:
content.decode('utf-8').encode('utf-8')
source_encoding = 'utf-8'
except:
try:
content.decode('gbk').encode('utf-8')
source_encoding = 'gbk'
except:
try:
content.decode('gb2312').encode('utf-8')
source_encoding = 'gb2312'
except:
try:
content.decode('gb18030').encode('utf-8')
source_encoding = 'gb18030'
except:
try:
content.decode('big5').encode('utf-8')
source_encoding = 'gb18030'
except:
content.decode('cp936').encode('utf-8')
source_encoding = 'cp936'
f.close()
#####按照确定的encoding读取文件内容,并另存为utf-8编码:
block_size = 4096
with codecs.open(original_file, 'r', source_encoding) as f:
with codecs.open(newfile, 'w', 'utf-8') as f2:
while True:
content = f.read(block_size)
if not content:
break
f2.write(content)
到目前为止,我们已经将文本表示为向量,并且都带有标签的,存放在corpus_list列表里面,它里面有两个字列表,第一个是标签列表,分别用1到5的整数表示了各个类别,第二个是文章向量列表,最后我们将其保存下来。
def save_processed_data(processed_data, save_path):
"""
因为我们已经将语聊处理为列表,这个函数将这个列表保存起来
保存的数据读取方式:
r_pd = pd.read_pickle('D:\python\demo\paper\material\\train2\\train1000.pkl')
labels_list = r_pd.iloc[0, :].tolist()得到数据的标签列表,列表里面每个数据类型都是int,分别是1, 2, 3, 4, 5
tensor_list = r_pd.iloc[1, :].tolist()得到数据的
:param processed_data: 已经处理过的语聊数据
:param save_path: 保存位置
:return:
"""
df = pd.DataFrame(processed_data)
df.to_pickle(save_path)
数据特征
为了验证本文所提出的轻量级文本表示模型的有效性,当然要使用一些机器学习或深度学习的算法跑一下看看。首先,分析一下得到的数据怎么样。
做了这么多,得到的处理后的数据到底如何?当我取出标签列表的时候,我看到如下样子的数据分布:
可以发现,不同类别的样本存在集中现象,可能会对模型的训练造成一定的影响,为了使数据分布更加随机,我又做了依次打乱操作,代码如下。
def random_data(file_path, save_path):
"""
此函数用于打乱样本的顺序
:param file_path:
:param save_path:
:return:
"""
random_label_list = []
random_tensor_list = []
data_list = []
r_pd = pd.read_pickle(file_path)
label_list = r_pd.iloc[0, :].tolist()
tensor_list = r_pd.iloc[1, :].tolist()
index = list(range(0, len(label_list)))
np.random.shuffle(index)
for i in index:
random_label_list.append(label_list[i])
random_tensor_list.append(tensor_list[i])
data_list.append(random_label_list)
data_list.append(random_tensor_list)
save_processed_data(data_list, save_path)
print("数据处理完毕!")
重新打乱后的数据分布如下图5所示:
训练KNN模型
准备好数据之后,我们开始选择要使用的机器学习算法。
选择使用的算法,要考虑以下问题:
首先这是一个分类问题,很多机器学习算法都可以做分类问题。
第二,分析我们的样本特征。样本特征是词向量线性组合成的一个192维的向量,词向量是Word2Vec向量,因此特征之间具有不可分割的联系,那么久不适宜用朴素贝叶斯了。
第三,决策树一般不单独使用,而是作为其他算法的基础,比如随机森林、提升算法等。同样,逻辑回归也是一个基础算法,用于神经网络模型训练。
作为初步尝试,我觉得先使用KNN和SVM看看效果。
KNN代码:
def main_knn(data_path, train_data_weight, k):
"""
KNN模型主函数
:param data: 数据集
:param train_data_weight: 数据集中训练数据的比重
:param k: k最近邻训练所选择的k
:return: 准确率
"""
data = pd.read_pickle(data_path)
# x表示特征,y表示类别
x = data.iloc[1, :].tolist()
y = data.iloc[0, :].tolist()
x_train, x_test, y_train, y_test = train_test_split(x, y, train_size=train_data_weight, random_state=0)
knn = KNeighborsClassifier(n_neighbors=k)
print("开始训练knn模型")
knn.fit(x_train, y_train)
print("开始预测")
predict_labels = knn.predict(x_test)
train_labels = knn.predict(x_train)
test_accuracy = sum(predict_labels == y_test) / len(y_test)
train_accuracy = sum(train_labels == y_train) / len(y_train)
return train_accuracy, test_accuracy
def best_k(max_k):
"""
寻找knn模型最好的k
:param max_k: 最大k设定为max_k
:return:
"""
acc_dict = {}
# 如果不用codecs的open直接用open回默认添加一个空行
with codecs.open('D:\python\demo\paper\material\\train4\\knn_acc.csv', 'a+', encoding='utf-8') as f:
acc_writer = csv.writer(f)
for i in range(1, max_k):
row = []
print("开始k为{0}的训练".format(i))
train_acc, test_acc = main_knn('D:\python\demo\paper\material\\train4\\train3000random.pkl', 0.8, i)
row.append(i)
row.append(train_acc)
row.append(test_acc)
acc_writer.writerow(row)
acc = [train_acc, test_acc]
acc_dict[i] = acc
print(acc_dict)
return acc_dict
为了更好的分析不同的k值下,训练准确率和测试准确率的变化,有必要画个图看看:
def plot_acc(acc_file):
"""
画出不同的k值情况下,训练准确率和测试准确率曲线
:param acc_file:
:return:
"""
# 设置绘图风格
plt.style.use('ggplot')
# 中文和符号的正常显示
plt.rcParams['font.sans-serif'] = ['SimHei']
plt.rcParams['axes.unicode_minus'] = False
# 获得数据
k_list = []
train_acc = []
test_acc = []
with codecs.open(acc_file, 'r', encoding='utf-8') as rf:
acc_reader = csv.reader(rf)
# 获得每一列的数据
for row in acc_reader:
k_list.append(int(row[0]))
train_acc.append(float(row[1]))
test_acc.append(float(row[2]))
print("k值:", k_list)
print("训练准确率:", train_acc)
print("测试准确率:", test_acc)
# 绘制训练准确率折线图
plt.figure('1')
line1, = plt.plot(k_list, train_acc, linestyle='-', linewidth=2, color='steelblue', marker='o', markersize=6,
markeredgecolor='white', markerfacecolor='brown', label='训练集准确率')
# 绘制测试准确率折线图
line2, = plt.plot(k_list, test_acc, linestyle='-', linewidth=2, color='g', marker='o', markersize=6,
markeredgecolor='white', markerfacecolor='g', label='测试集准确率')
# 添加标题和坐标轴标签
plt.title('KNN准确率变化图')
plt.xlabel('不同的k值')
plt.ylabel('准确率')
# 设置图例
plt.legend((line1, line2), ('训练集准确率', '测试集准确率'), loc='upper right')
# 设置坐标轴刻度
plt.xticks(np.arange(0, 20, 1))
# plt.yticks(np.linspace(0.8, 1, 2))
# 不要图框上边界和右边界的刻度
# plt.tick_params(top='off', right='off')
plt.show()
画出的图像如图6所示:
k值: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19]
训练准确率: [0.9995833333333334, 0.9388333333333333, 0.9249166666666667, 0.9106666666666666, 0.902, 0.8905833333333333, 0.8865, 0.8823333333333333, 0.8801666666666667, 0.8749166666666667, 0.87325, 0.8661666666666666, 0.864, 0.8625833333333334, 0.8595, 0.8574166666666667, 0.85525, 0.8538333333333333, 0.85225]
测试准确率: [0.87, 0.8496666666666667, 0.864, 0.8593333333333333, 0.8606666666666667, 0.8546666666666667, 0.85, 0.8486666666666667, 0.8513333333333334, 0.846, 0.8466666666666667, 0.842, 0.8416666666666667, 0.8423333333333334, 0.84, 0.8373333333333334, 0.8383333333333334, 0.832, 0.8306666666666667]
通过分析,作者发现当k等于1的时候,训练准确率达到了0.9995833333333334,也就是说12000个样本里面,只有5个样本分错了。于是,作者比较好奇,在代码中,到底是如何分类的。
分析sklearn中knn源码
为了搞清楚knn的细节,有必要分析一下我们所使用的源码。
在main_knn中,我们使用的KNN类是
class KNeighborsClassifier(NeighborsBase, KNeighborsMixin,
SupervisedIntegerMixin, ClassifierMixin):
def __init__(self, n_neighbors=5,
weights='uniform', algorithm='auto', leaf_size=30,
p=2, metric='minkowski', metric_params=None, n_jobs=None,
**kwargs):
super(KNeighborsClassifier, self).__init__(
n_neighbors=n_neighbors,
algorithm=algorithm,
leaf_size=leaf_size, metric=metric, p=p,
metric_params=metric_params,
n_jobs=n_jobs, **kwargs)
self.weights = _check_weights(weights)
KNeighborsClassifier继承于NeighborsBase,KNeighborsMixin,SupervisedIntegerMixin,ClassifierMixin四个类。初始化参数一目了然,重点是看看我们所关心的 fit 和predict函数。
fit() 函数一般是用来训练模型的,但knn中的 fit() 函数却有所特殊,他主要是用来做一切判断和数据转换的,我们这里使用的是SupervisedIntegerMixin类中的 fit() 函数。
def fit(self, X, y):
"""Fit the model using X as training data and y as target values
Parameters
----------
X : {array-like, sparse matrix, BallTree, KDTree}
Training data. If array or matrix, shape [n_samples, n_features],
or [n_samples, n_samples] if metric='precomputed'.
y : {array-like, sparse matrix}
Target values of shape = [n_samples] or [n_samples, n_outputs]
"""
if not isinstance(X, (KDTree, BallTree)):
X, y = check_X_y(X, y, "csr", multi_output=True)
if y.ndim == 1 or y.ndim == 2 and y.shape[1] == 1:
if y.ndim != 1:
warnings.warn("A column-vector y was passed when a 1d array "
"was expected. Please change the shape of y to "
"(n_samples, ), for example using ravel().",
DataConversionWarning, stacklevel=2)
self.outputs_2d_ = False
y = y.reshape((-1, 1))
else:
self.outputs_2d_ = True
check_classification_targets(y)
self.classes_ = []
self._y = np.empty(y.shape, dtype=np.int)
for k in range(self._y.shape[1]):
classes, self._y[:, k] = np.unique(y[:, k], return_inverse=True)
self.classes_.append(classes)
if not self.outputs_2d_:
self.classes_ = self.classes_[0]
self._y = self._y.ravel()
return self._fit(X)
输入的X是特征,y是标签,既可以是数组也可以是稀疏矩阵。为了解决数据量很大时,暴力计算困难的问题,输入X可以采用BallTree或KDTree两种数据结构,优化计算效率,可以在实例化KNeighborsClassifier的时候指定。
KDTree
基本思想是,若A点距离B点非常远,B点距离C点非常近, 可知A点与C点很遥远,不需要明确计算它们的距离。 通过这样的方式,近邻搜索的计算成本可以降低为O[DNlog(N)]或更低。 这是对于暴力搜索在大样本数N中表现的显著改善。KD 树的构造非常快,对于低维度 (D<20) 近邻搜索也非常快, 当D增长到很大时,效率变低: 这就是所谓的 “维度灾难” 的一种体现。
BallTree
BallTree解决了KDTree在高维上效率低下的问题,这种方法构建的树要比 KD 树消耗更多的时间,但是这种数据结构对于高结构化的数据是非常有效的, 即使在高维度上也是一样。
看来 fit() 函数并不能解决我们的问题,我们特别想知道knn是怎么分类的,特别是当k=1时。
下面分析使用的另一个函数:predict()。predict() 是中直接定义的函数:
def predict(self, X):
"""Predict the class labels for the provided data
Parameters
----------
X : array-like, shape (n_query, n_features), \
or (n_query, n_indexed) if metric == 'precomputed'
Test samples.
Returns
-------
y : array of shape [n_samples] or [n_samples, n_outputs]
Class labels for each data sample.
"""
X = check_array(X, accept_sparse='csr')
neigh_dist, neigh_ind = self.kneighbors(X)
classes_ = self.classes_
_y = self._y
if not self.outputs_2d_:
_y = self._y.reshape((-1, 1))
classes_ = [self.classes_]
n_outputs = len(classes_)
n_samples = X.shape[0]
weights = _get_weights(neigh_dist, self.weights)
y_pred = np.empty((n_samples, n_outputs), dtype=classes_[0].dtype)
for k, classes_k in enumerate(classes_):
if weights is None:
mode, _ = stats.mode(_y[neigh_ind, k], axis=1)
else:
mode, _ = weighted_mode(_y[neigh_ind, k], weights, axis=1)
mode = np.asarray(mode.ravel(), dtype=np.intp)
y_pred[:, k] = classes_k.take(mode)
if not self.outputs_2d_:
y_pred = y_pred.ravel()
return y_pred
只有当运行这个函数的时候,才真正的开始计算和分类。输入测试集数据,返回每个样本的预测标签。
kneighbors(X)得到当前测试样本k个最近的样本点的索引和距离,可以用非监督的方式举个例子(源码中也是这样举例的):
sample = [[0., 0., 0.], [0., .5, 0.], [1., 1., .5]]
neigh = NearestNeighbors(n_neighbors=1)
neigh.fit(sample)
re = neigh.kneighbors([[0., 0., 0.]])
print(re)
>>>(array([[0.]]), array([[0]], dtype=int64))
当输入一个完全相同的数组的时候,返回的距离是0。换种说法,当我们用训练集放在预测函数中的时候,必然会有一个数组和我们的输入中的一个数组相同,此时计算的距离是0。
得到距离和索引之后,看下一句代码:
weights = _get_weights(neigh_dist, self.weights)
def _get_weights(dist, weights):
"""Get the weights from an array of distances and a parameter ``weights``
Parameters
===========
dist : ndarray
The input distances
weights : {'uniform', 'distance' or a callable}
The kind of weighting used
Returns
========
weights_arr : array of the same shape as ``dist``
if ``weights == 'uniform'``, then returns None
"""
if weights in (None, 'uniform'):
return None
elif weights == 'distance':
# if user attempts to classify a point that was zero distance from one
# or more training points, those training points are weighted as 1.0
# and the other points as 0.0
if dist.dtype is np.dtype(object):
for point_dist_i, point_dist in enumerate(dist):
# check if point_dist is iterable
# (ex: RadiusNeighborClassifier.predict may set an element of
# dist to 1e-6 to represent an 'outlier')
if hasattr(point_dist, '__contains__') and 0. in point_dist:
dist[point_dist_i] = point_dist == 0.
else:
dist[point_dist_i] = 1. / point_dist
else:
with np.errstate(divide='ignore'):
dist = 1. / dist
inf_mask = np.isinf(dist)
inf_row = np.any(inf_mask, axis=1)
dist[inf_row] = inf_mask[inf_row]
return dist
elif callable(weights):
return weights(dist)
else:
raise ValueError("weights not recognized: should be 'uniform', "
"'distance', or a callable function")
这个函数的输入有两个参数,第一个就是我们上面计算得到的测试样本和他的k个最近样本数据的距离,第二个参数是一个权重列表,说明如下:
weights : str or callable, optional (default = 'uniform')
weight function used in prediction. Possible values:
- 'uniform' : uniform weights. All points in each neighborhood
are weighted equally.
- 'distance' : weight points by the inverse of their distance.
in this case, closer neighbors of a query point will have a
greater influence than neighbors which are further away.
- [callable] : a user-defined function which accepts an
array of distances, and returns an array of the same shape
containing the weights.
那么我们使用默认情况,每个样本的权重相同。因为我们使用的是默认权重,即uniform,所以这个函数直接返回None。但如果我们使用了distance权重,距离为0的样本的权重变为1,其他样本权重变为0(当weight = 'distance'时,在训练集上没有意义)。另外,若不同样本都有权重,选择预测类别时是这样的:
>>> x = [4, 1, 4, 2, 4, 2]
>>> weights = [1, 1, 1, 1, 1, 1]
>>> weighted_mode(x, weights)
(array([4.]), array([3.]))
因为4出现了3次,所以三次的权重之和为3,是最大的,返回的第一个数组是类别标记4,第二个数组是4出现的次数。
继续往下走,当得到的weights是None时,执行以下语句:
if weights is None:
mode, _ = stats.mode(_y[neigh_ind, k], axis=1)
最终怎么分类并不是简单的投票,而是将得到的k个列表放到一个“统计函数”里面,这也解释了为甚k=1时或者设定weight = 'distance'时,得到的准确率是0.9995833333333334而不是1。
所以,使用训练准确率和测试准确率来选择k是不合适的,在实践中往往使用交叉验证来选择k。
knn交叉验证
K值较小,则模型复杂度较高,容易发生过拟合,学习的估计误差会增大,预测结果对近邻的实例点非常敏感。
K值较大可以减少学习的估计误差,但是学习的近似误差会增大,与输入实例较远的训练实例也会对预测起作用,使预测发生错误,k值增大模型的复杂度会下降。
在应用中,k值一般取一个比较小的值,通常采用交叉验证法来来选取最优的K值。
def cross_val_knn(data_path, save_score_path, KFold, k_range):
"""
使用交叉验证选择knn的超参数k
:param data_path: 样本数据
:param save_score_path: 得到的每个knn模型的分数保存目录
:param KFold: 交叉验证的折数
:param k_range: 选择使用的knn模型k的取值范围
:return:
"""
data = pd.read_pickle(data_path)
# x表示特征,y表示类别
x = data.iloc[1, :].tolist()
y = data.iloc[0, :].tolist()
with codecs.open(save_score_path, 'a+', encoding='utf-8') as wopen:
score_w = csv.writer(wopen)
for k in range(1, k_range+1):
print("正在进行k={0}的交叉验证运算".format(k))
knn = KNeighborsClassifier(n_neighbors=k)
score = cross_val_score(knn, x, y, cv=KFold, scoring='accuracy', n_jobs=4)
score_mean = score.mean()
row = [k]
row.append(score_mean)
print("k={0}时的分数是{1}".format(k, score_mean))
score_w.writerow(row)
print("knn交叉验证结束!")
上述代码打印结果:
k=1时的分数是0.8754666666666667
正在进行k=2的交叉验证运算
k=2时的分数是0.8538666666666668
正在进行k=3的交叉验证运算
k=3时的分数是0.8654666666666667
正在进行k=4的交叉验证运算
k=4时的分数是0.8618
正在进行k=5的交叉验证运算
k=5时的分数是0.8636666666666667
正在进行k=6的交叉验证运算
k=6时的分数是0.8589333333333332
正在进行k=7的交叉验证运算
k=7时的分数是0.8602666666666666
正在进行k=8的交叉验证运算
k=8时的分数是0.8569333333333333
正在进行k=9的交叉验证运算
k=9时的分数是0.8572000000000001
正在进行k=10的交叉验证运算
k=10时的分数是0.8554666666666666
knn交叉验证结束!
在k取不同值的时候,各个knn模型的评分如下图7:
和图6类似,仍然是k = 1时取得最高分,但k = 2时又有一个下降,之后k = 3则一直是最好的。作者认为,在 k 小于3时,模型是不太稳定的,因此,作者更倾向于取k = 3作为最好的k值。
当取 k = 3 时,计算knn模型的各个度量指标:
def knn_3_assess(data_path):
"""
当k=3时,knn模型的评价指标:准确率、精确率、召回率、F1值
:param data_path:
:return:
"""
data = pd.read_pickle(data_path)
x = data.iloc[1, :].tolist()
y = data.iloc[0, :].tolist()
x_train, x_test, y_train, y_test = train_test_split(x, y, train_size=0.8, random_state=0)
knn = KNeighborsClassifier(n_neighbors=3, n_jobs=4)
knn.fit(x_train, y_train)
predict_labels = list(knn.predict(x_test))
knn_confusion_matrix = confusion_matrix(y_test, predict_labels)
print("混淆矩阵:\n", knn_confusion_matrix)
# 准确率:预测正确的样本占所有样本的比重
knn_accuracy = accuracy_score(y_test, predict_labels)
print("准确率:", knn_accuracy)
# 精确率:查准率。即正确预测为正的占全部预测为正的比例。
knn_precision = precision_score(y_test, predict_labels, average='macro')
print("精确率:", knn_precision)
# 召回率:查全率。即正确预测为正的占全部实际为正的比例。
knn_recall = recall_score(y_test, predict_labels, average='macro')
print("召回率:", knn_recall)
# F1值:同时考虑准确率和召回率,越大越好
knn_f1 = f1_score(y_test, predict_labels, average='macro')
print("F1值:", knn_f1)
输出:
混淆矩阵:
[[550 9 17 12 7]
[ 24 554 9 28 6]
[ 85 21 448 35 31]
[ 22 22 19 497 13]
[ 11 5 11 21 543]]
准确率: 0.864
精确率: 0.8667016372396169
召回率: 0.8650412733399653
F1值: 0.8631060910801059
参考文献
[1] 钟瑛, 陈盼. 网络新闻分类及其评优标准探析——以中西网络新闻奖评选为例[J]. 国际新闻界, 2009(9):67-71.
[2] 朱全银, 潘禄, 刘文儒, et al. Web科技新闻分类抽取算法[J]. 淮阴工学院学报, 2015, 24(5):18-24.
[3] 冯国明, 张晓冬, 刘素辉. 基于CapsNet的中文文本分类研究[J]. 数据分析与知识发现, 2019, 2(12).
https://www.jianshu.com/p/dbbfc8cef1be
https://www.zhihu.com/question/30957691