自然语言处理实战(一)

一种新的文本表示方法——基于搜狗新闻数据的分类研究

本文记录作者的研究成果和无敌详细的实验过程,干货满满!
本文是完全原创,首发于简书,转载请私信。
本文仅用于学术交流!

自然语言处理实战(二),地址: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 所示。


图1 基于 LDA 的文本矩阵表示结构

其中,Pi为文档中的段落, K 为 LDA 模型主题数, N 为语料中文档的最大段落数, pv 为文档中段落经过LDA 处理后的段落向量。

现今,基于神经网络的方法受到广泛关注,各种各样的模型被相继提出,主要可分为三类:

第一类,基于词向量合成的模型,该类方法仅是在词向量基础上简单合成;

第二类,基于RNN/CNN的模型,该类方法利用更复杂的深度学习模型对文本进行建模;

第三类,基于注意力机制的模型,在已有神经网络模型基础上,引入注意力机制,提升文本建模效果。

关于文本表示更详细的说明,请参考我的另一篇文章常用的文本表示模型

基于段落关键词的文本表示模型

文本表示是自然语言处理中的重点,冯国明的论文《基于CapsNet的中文文本分类研究》,提出了基于LDA文本矩阵表示方法和基于Word2Vec 词向量表示方法(W2V_cuboid )。前面我们介绍了第一种方法,下面简单说明一下冯国明是怎么利用Word2Vec 词向量表示文本的。

W2V_cuboid 文本表示法将文档中的词进行方阵排列,以语料中最大词数为准进行补齐形成词矩阵,使用训练语料对 Word2Vec 进行训练, 利用训练好的模型将词矩阵的每个词转化为词向量,形成词向量体(Word Vectors Cuboid, WVC), 得到稠密、保留 完整文本信息、基于语义的文本表示。其流程如图 2所示。


图2 基于 Word2Vec 的词向量体表示结构

冯国明的两种表示方法都将文本表示成了含有大量参数的矩阵,不仅需要大量的训练样本,而且第二种方法对噪声没有过滤能力,难以避免训练困难和过拟合的问题。

基于此,笔者试图寻找一种轻量级的文本表示方法。首先分析业务场景,我们的目的是找到一种新闻文本的分类方法,因为新闻的类型本身并没有多复杂,在众多平台中,顶多将他们的新闻分为十几到五十种类型。所以,笔者认为新闻文本的分类并不需要过高维度或过多参数的表示模型。

本文的文本表示方法的思路如下: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:


图3 Word2Vec模型

上面那三个是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)

数据特征

为了验证本文所提出的轻量级文本表示模型的有效性,当然要使用一些机器学习或深度学习的算法跑一下看看。首先,分析一下得到的数据怎么样。

做了这么多,得到的处理后的数据到底如何?当我取出标签列表的时候,我看到如下样子的数据分布:


图4 数据集中的数据分布,其中不同的数字代表不同的类别标签

可以发现,不同类别的样本存在集中现象,可能会对模型的训练造成一定的影响,为了使数据分布更加随机,我又做了依次打乱操作,代码如下。

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所示:


图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所示:


图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:


图7 10折交叉验证计算的不同k下的knn模型评分。

和图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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,684评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,143评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,214评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,788评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,796评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,665评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,027评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,679评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 41,346评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,664评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,766评论 1 331
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,412评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,015评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,974评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,203评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,073评论 2 350
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,501评论 2 343

推荐阅读更多精彩内容

  • 主要内容 自然语言输入编码 前馈网络 卷积网络 循环网络(recurrent networks ) 递归网络(re...
    JackHorse阅读 4,100评论 0 2
  • 首页 资讯 文章 资源 小组 相亲 登录 注册 首页 最新文章 IT 职场 前端 后端 移动端 数据库 运维 其他...
    Helen_Cat阅读 3,843评论 1 10
  • 文本分类是NLP领域非常常见的应用场景,在现实生活中有着非常多的应用,例如舆情监测、新闻分类等等。在文本分类中,常...
    卖萌的哈士奇阅读 7,547评论 0 8
  • 前面的文章主要从理论的角度介绍了自然语言人机对话系统所可能涉及到的多个领域的经典模型和基础知识。这篇文章,甚至之后...
    我偏笑_NSNirvana阅读 13,861评论 2 64
  • 今日夏至,全年最长的一天,傍晚时分,迎来了一场酣畅淋漓的雨。 冒着大雨,和夫一起去吃了想念好久的兰花串,屋外是瓢泼...
    清浅光阴阅读 271评论 0 1