自然语言处理实战(一)

作者: 阡陌哥哥 | 来源:发表于2019-04-08 16:45 被阅读0次

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

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

引言

新闻作为网络上广泛传播的一种文学载体,一直得到工业界和学术界的高度重视。随之互联网的高速发展和普及,网络上产生了大量的新闻文本,其种类之繁多,数量之丰富,远超普通报刊杂志。互联网上的信息,以其快速著称,包括产生快、传播快和更新快。在这浩如烟好的互联网新闻世界,网民往往会根据自己的兴趣,选择特定类型的新闻进行阅读。而传统的新闻分类主要由人工操作,工作量大而且枯燥无味,因此,新闻分类一直是在文本分类领域众多研究者比较关心的问题。

针对新闻类文本分类,有基于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点非常近, 可知点与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

相关文章

网友评论

    本文标题:自然语言处理实战(一)

    本文链接:https://www.haomeiwen.com/subject/mjlfiqtx.html