DBSCAN文本聚类与python实现

1. 引言

    聚类算法作为分类任务中的无监督方法,在很多场景中都会用到,比如用户聚类、文档主题分类等等。常见的聚类算法大致可以分为两种,一种是基于分区的算法,如k-means等,这种方法虽然易于理解,但是有以下三个缺点:

  • 需要人为事先确定聚类的个数,当数据集比较大时,这是很难事先给出一个比较合适的值的。
  • 只适用于具有凸形状的簇,无法适用于具有任意形状的簇。
  • 对内存的占用资源比较大,很难推广到大规模数据集。

    另一种是基于层次划分的算法,如层次聚类法等,这种方法虽然不用事先确定聚类的个数,但是也存在以下缺点:

  • 需要确定停止分裂的条件
  • 计算速度慢。

因此,本文介绍另一个聚类算法——DBSCAN,该方法是基于密度的聚类方法,能够有效解决上面提到的各种问题。

2. DBSCAN聚类算法介绍

2.1 DBSCAN聚类相关概念

    DBSCAN是一种基于密度的聚类算法,其基本假设是一个集群的密度要显著高于噪声点的密度,因此,该方法的基本思想是对于集群中的每一个点,在给定的半径范围内,其相邻点的数量必须超过预先设定的某一个阈值
    在介绍DBSCAN算法之前,先介绍几个基本的概念:

  • Eps领域(Eps-neighborhood) :对于一个点,记其Eps领域为 N E p s ( p ) \mathrm{N}_{\mathrm{Eps}}(\mathrm{p}) NEps(p),则其定义如下:
    N E p s ( p ) = { q ∈ D ∣ dist ⁡ ( p , q ) ≤ E p s } \mathrm{N}_{\mathrm{Eps}}(\mathrm{p})=\{\mathrm{q} \in D | \operatorname{dist}(\mathrm{p}, \mathrm{q}) \leq \mathrm{Eps}\} NEps(p)={qDdist(p,q)Eps}
    其中, D D D表示整个数据集集合, dist ⁡ ( p , q ) \operatorname{dist}(p, q) dist(p,q)表示点 p p p q q q的距离。
  • 直接密度可达(directly density-reachable):称一个点 p p p直接密度可达点 q q q,如果满足以下条件:
    p ∈ N E p s ( q ) ∣ N E p s ( q ) ∣ ≥ M i n P t s \mathrm{p} \in \mathrm{N}_{\mathrm{Eps}}(\mathrm{q}) \\ \left|\mathrm{N}_{\mathrm{Eps}}(\mathrm{q})\right| \geq \mathrm{MinPts} pNEps(q)NEps(q)MinPts
    其中,MinPts表示一个中心点的Eps领域必须包含的最小数量,需要事先确定。当点 p p p q q q都是一个集群的中心点时,则此时直接密度可达对两个点来说都是对称的,当时当 p p p是边界点时,则此时直接密度可达不是对称的,如下图所示。
    在这里插入图片描述
  • 密度可达(density-reachable):如果存在一串点 p 1 , … , p n , p 1 = q , p n = p \mathrm{p}_{1}, \dots, \mathrm{p}_{\mathrm{n}}, \mathrm{p}_{1}=\mathrm{q}, \mathrm{p}_{\mathrm{n}}=\mathrm{p} p1,,pn,p1=q,pn=p,有 p i + 1 \mathrm{p}_{\mathrm{i+1}} pi+1 p i \mathrm{p}_{\mathrm{i}} pi直接密度可达,那么就称 p p p q q q密度可达。
  • 密度相连(density-connected):如果存在一个点 o o o,使得 p p p q q q都从 o o o密度可达,那么称点 p p p q q q密度相连。密度相连则是对称的。
  • 类簇(cluster):对于一个数据集 D D D,称子集 C C C D D D的一个类簇,如果其满足以下条件:
    1)对于任意的点 p , q \mathrm{p}, \mathrm{q} p,q,如果 p ∈ C \mathrm{p} \in \mathrm{C} pC,并且 q q q p p p密度可达,那么 q ∈ C q \in C qC
    2) ∀ p , q ∈ C \forall \mathrm{p}, \mathrm{q} \in \mathrm{C} p,qC p , q \mathrm{p}, \mathrm{q} p,q都密度相连
  • 噪声(noise):记 C 1 , … , C k C_{1}, \ldots, C_{k} C1,,Ck为数据集 D D D中的 k k k个簇,则噪声点则为 D D D中那些不属于任何一个类簇的点,即:
    n o i s e = { p ∈ D ∣ ∀ i : p ∉ C i } noise=\{\mathrm{p} \in D|\forall \mathrm{i} : \mathrm{p}\notin C_{i} \} noise={pDi:p/Ci}
  • 引理1:令 p p p为数据集 D D D中的一个点,并且有 ∣ N E p s ( p ) ∣ ≥ M i n P t s \left|\mathrm{N}_{\mathrm{Eps}}(\mathrm{p})\right| \geq \mathrm{MinPts} NEps(p)MinPts,则集合 O = { o ∣ o ∈ D 并 且 O=\{\mathrm{o}|\mathrm{o} \in \mathrm{D} 并且 O={ooDo 从 从 p 密 度 可 达 } 密度可达\} },则集合 O O O为一个类簇。
  • 引理2:令 C C C为数据集 D D D中的一个类簇,对于 C C C中的一个点 p p p,如果 ∣ N E p s ( p ) ∣ ≥ M i n P t s \left|\mathrm{N}_{\mathrm{Eps}}(\mathrm{p})\right| \geq \mathrm{MinPts} NEps(p)MinPts,则集合 C C C与集合 O = { o ∣ O=\{\mathrm{o}| O={oo 从 从 p 密 度 可 达 } 密度可达\} }相等。
2.2 DBSCAN的算法步骤

    DBSCAN的算法步骤如下(这里直接引用周志华老师的《机器学习》):
在这里插入图片描述

2.3 参数Eps和MinPts的确定

    由前面的介绍我们可以发现,DBSCAN的参数其实就两个,一个是半径Eps,一个是半径范围内的最少点数量MinPts,那么,在建立模型时,如何确定这两个参数的取值呢?作者提出了一个叫sorted k-dist图的方法。即对于数据集中的每个点,分别计算他们与第k个最近邻的距离,然后将这些距离进行逆排序,并绘制他们距离分布曲线,如下图所示:
在这里插入图片描述
    接着,通过肉眼观察曲线的分布,当出现第一个谷点时,则该点即可作为临界点,对应的距离可以作为Eps的取值,另外,k即可作为MinPts的取值。作者在实验中发现,一般k取值为4是比较合适的,因为随着k的增大,其实距离的变动不会很敏感,因为在一个半径范围内,其实一个点第k个最近邻可能会存在多个不同的点,因此,增加k其实对d的影响可能不会太大。

3. DBSCAN的python实现

    笔者利用DBSCAN对文本进行聚类,python中sklearn已经可以实现DBSCAN,笔者直接继承该类,在其基础上添加sorted k-dist图的计算代码如下:

import scipy
import numpy as np
from tqdm import tqdm
import matplotlib.pyplot as plt
from sklearn.cluster import DBSCAN


class DBSCAN(DBSCAN):
    def k_dist_plot(self, data, k=4):
        """
        绘制k-dist图
        :param data: 向量化后的数据. [np.array or csr_matrix]
        :param k: 第k个近邻
        :return:
        """
        if isinstance(data, scipy.sparse.csr.csr_matrix):
            data = data.todense()
        k_list = []
        for i in tqdm(range(len(data))):
            dist = np.square(data[i, :] - data)
            dist = np.sqrt(np.sum(dist, axis=-1))
            this_k_dist = np.sort(dist)[k + 1]
            k_list.append(this_k_dist)

        k_list = sorted(k_list, reverse=True)
        plt.switch_backend('agg')
        plt.figure()
        plt.plot(range(len(k_list)), k_list, 'b-')
        plt.xlabel('points')
        plt.ylabel('k-dist')
        # plt.show()
        plt.savefig('./data/k-dist.png')

    另外,将DBSCAN聚类算法应用在文本方面,对23万的消息文本进行文本聚类,在文本的向量化方面,采用两种方法,分别是doc2vec和tfidf,但是笔者发现DBSCAN聚类采用tfidf效果会更好一点,另外,对文本进行表情过滤、简体字转化、数字标准化、停用词过滤等常规清洗,数据清洗的代码如下:

import os
import re
import tqdm
import scipy
import pickle
import config
import numpy as np
import pandas as pd
from log import init_log
from jieba import lcut
from hanziconv import HanziConv
from collections import Counter
import matplotlib.pyplot as plt
from sklearn.decomposition import PCA
from mysql_connect import MysqlConnector
from gensim.models.doc2vec import Doc2Vec
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import Normalizer
from keras.preprocessing.text import Tokenizer
from sklearn.decomposition import TruncatedSVD
from gensim.models.doc2vec import TaggedDocument
from sklearn.feature_extraction.text import TfidfVectorizer


class DataLoader(object):
    def __init__(self):
        self.mysql_connector = MysqlConnector()
        self.load_stop_words()
        self.load_emoji_text()
        self.load_ques_words()
        self.log = init_log()
        if not os.path.exists('./data'): os.makedirs('./data')

    def read_train_data(self, id=2078168):
        """
        从数据库读取训练数据
        :param id: 对应panda_wechat_records中的id
        :return:
        """
        self.log.info('从数据库加载数据...')
        sql_id = "SELECT id from panda_wechat_records WHERE `from` like '%text%' " \
                 "and id <={0} order by id".format(id)

        sql_text = "SELECT `from` from panda_wechat_records WHERE `from` like '%text%' " \
                   "and id <={0} order by id".format(id)

        id_list = self.mysql_connector.local_query(sql_id)
        text_list = self.mysql_connector.local_query(sql_text)
        return id_list, text_list

    def filter_backslash(self, text):
        """
        过滤转义字符
        :param text:
        :return:
        """
        text = text.replace("\\'", '"').replace('\\/', '/')
        return text

    def extract_text(self, text):
        """
        提取from字段中的文本
        :param text:
        :return:
        """
        text = eval(text)
        concat_text = ''
        for i in text:
            if not isinstance(i, dict):
                i = self.filter_backslash(i)
                i = eval(i)
            if i['type'] != 'text':
                continue
            else:
                concat_text = concat_text + ',' + i['detail'].replace(' ', '')
        return concat_text

    def load_stop_words(self):
        """从数据库加载停用词"""
        sql = 'select word from stop_words'
        self.mysql_connector.local_reconnector()
        cur = self.mysql_connector.local_conn.cursor()
        cur.execute(sql)
        stop_words = cur.fetchall()
        cur.close()
        self.stop_words = [i[0] for i in stop_words]

    def load_emoji_text(self):
        """加载emoji表情列表"""
        with open('./data/emoji.txt', 'r', encoding='utf-8') as f:
            emoji_list = f.readlines()
            self.emoji_list = [emoji.replace('\n', '') for emoji in emoji_list]

    def load_ques_words(self):
        """加载疑问词列表"""
        with open('./data/question_words.txt', 'r', encoding='utf-8') as f:
            ques_words = f.readlines()
            self.ques_words = [words.replace('\n', '').strip() for words in ques_words]

    def number_normalization(self, text):
        """
        对数字用#NUMBER进行统一标识
        :param text:
        :return:
        """
        for i in range(len(text)):
            if text[i].isdigit():
                text[i] = '#NUMBER'
        return text

    def filter_stop_words(self, text):
        """
        过滤停用词
        :param text:
        :return:
        """
        text = list(filter(lambda x: x not in self.stop_words, text))
        return text

    def filter_emoji(self, text):
        """
        过滤emoji表情
        :param text:
        :return:
        """
        text = re.sub('|'.join(self.emoji_list), '', text)
        return text

    def count_texts_len(self, cut_text_list):
        """
        统计所有句子的长度,并返回句子长度的95%分位数和25%分位数
        :param cut_text_list:句子列表,词汇间以空格隔开.[list]
        :return:
        """
        text_len = [len(i.split(' ')) for i in cut_text_list]
        min_len = int(np.percentile(np.array(text_len), 25))
        max_len = int(np.percentile(np.array(text_len), 95))
        return min_len, max_len

    def tokenizer(self,
                  id_list,
                  text_list,
                  char_level=config.char_level,
                  num_norm=config.num_norm,
                  stop_words_filter=config.stop_words_filter,
                  emoji_filter=config.emoji_filter,
                  ques_only=config.ques_only,
                  min_len=config.min_len,
                  max_len=config.max_len):
        """
        分词器,对文本进行分词,并进行大小写转换、繁体字转换、停用词过滤、表情过滤等
        :param id_list: 文本的id列表. [list]
        :param text_list: 文本的列表. [list]
        :param char_level: 是否基于字符级. [boolean]
        :param num_norm: 是否对数字进行标准化表示. [boolean]
        :param stop_words_filter: 是否进行停用词过滤. [boolean]
        :param emoji_filter:是否进行表情过滤. [boolean]
        :param ques_only:是否只对问题进行聚类. [boolean]
        :param min_len:文本的最小长度. [int]
        :param max_len:文本的最大长度. [int]
        :return:
        """
        self.log.info('对数据进行分词、清洗...')
        new_id_list = []  # 过滤后的文本id列表
        cut_text_list = []  # 切词后的文本列表
        origin_text_list = []  # 过滤后的原始文本列表
        for i in tqdm.tqdm(range(len(text_list))):
            id = id_list[i][0]
            content = text_list[i][0]

            # 提取from字段中的文本
            try:
                content = self.extract_text(content)
            except:
                self.log.info(content)
                continue

            # 将文本转化为简体字
            content = HanziConv.toSimplified(content).lower()
            origin_content = content

            # 根据疑问词过滤出问句
            if ques_only:
                ques_words_pattern = '|'.join(self.ques_words)
                if not re.search(ques_words_pattern, content):
                    continue

            # 过滤文本中的微信emoji表情
            if emoji_filter:
                content = self.filter_emoji(content)

            # 对文本进行分词
            if char_level:
                content = list(content)
            else:
                content = lcut(content)

            # 对文本中的数字进行标准化
            if num_norm:
                content = self.number_normalization(content)

            # 过滤文本中的停用词
            if stop_words_filter:
                content = self.filter_stop_words(content)

            # 剔除长度低于min_len的文本
            if min_len:
                if len(content) < min_len:
                    continue

            # 剔除长度高于max_len的文本
            if max_len:
                if len(content) > max_len:
                    continue

            new_id_list.append(id)
            cut_text_list.append(' '.join(content))
            origin_text_list.append(origin_content)

        # 将处理后的数据保存到data路径下
        with open('./data/tokenizer_result.pkl', 'wb') as f:
            pickle.dump(new_id_list, f)
            pickle.dump(cut_text_list, f)
            pickle.dump(origin_text_list, f)

        return new_id_list, cut_text_list, origin_text_list

    def load_tokenizer_result(self):
        """
        加载分词和清洗后的数据
        :return:
        """
        with open('./data/tokenizer_result.pkl', 'rb') as f:
            new_id_list = pickle.load(f)
            cut_text_list = pickle.load(f)
            origin_text_list = pickle.load(f)
        return new_id_list, cut_text_list, origin_text_list

    def get_data_info(self, cut_text_list, num_words=1000000):
        """
        统计数据的句子长度、词频
        :param cut_text_list:分词后的文本列表,词汇之间采用空格连接. [list]
        :param num_words:最高词汇数量. [int]
        :return:
        """
        min_len, max_len = self.count_texts_len(cut_text_list)
        tokenizer = Tokenizer(num_words, filters='')
        tokenizer.fit_on_texts(cut_text_list)
        word_counts = Counter(tokenizer.word_counts.values())
        word_counts = sorted(word_counts.items(), key=lambda x: x[0])
        self.log.info('句子长度的25%分位数为:{0}'.format(min_len))
        self.log.info('句子长度的95%分位数为:{0}'.format(max_len))
        self.log.info('词汇总数量为:{0}'.format(len(tokenizer.index_word)))
        self.log.info('词汇出现次数频次统计:{0}'.format(word_counts))

    def doc2vec(self,
                cut_text_list,
                min_count=config.doc2vec_config.min_count,
                window=config.doc2vec_config.window,
                size=config.doc2vec_config.size,
                sample=config.doc2vec_config.sample,
                negative=config.doc2vec_config.negative,
                workers=config.doc2vec_config.workers):
        """
        采用doc2vec将文本进行向量化
        :param cut_text_list: 分词后的文本列表,词汇之间采用空格连接. [list]
        :param min_count: 最低词频. [int]
        :param window: doc2vec窗口大小. [int]
        :param size: doc2vec向量的维度大小. [int]
        :param sample: 高频词负采样的概率. [float]
        :param negative: 负采样的词汇数. [int]
        :param workers: 进程数. [int]
        :return:
        """
        corpus = []
        for i, text in enumerate(cut_text_list):
            text = text.split(' ')
            corpus.append(TaggedDocument(text, tags=[i]))

        doc2vec_model = Doc2Vec(corpus, min_count=min_count, window=window, size=size,
                                sample=sample, negative=negative, workers=workers)

        doc2vec_model.train(corpus,
                            total_examples=doc2vec_model.corpus_count,
                            epochs=70)
        encoder_data = doc2vec_model.docvecs.doctag_syn0
        return encoder_data

    def tfidf_vector(self,
                     cut_text_list,
                     max_df=config.tfidf_config.max_df,
                     max_features=config.tfidf_config.max_features,
                     min_df=config.tfidf_config.min_df,
                     use_idf=config.tfidf_config.use_idf):
        """
        采用tdif将句子转化为向量表示
        :param cut_text_list: 切词后的文本列表,词汇之间用空格连接. [list]
        :param max_df: tfidf最高的df值,取值为0-1之间的浮点数. [float]
        :param max_features: 最高的词汇数量. [int]
        :param min_df: tfidf最低的df值,可以是0-1之间的取值,也可以是一个整数,表示最低词频. [float or int]
        :param use_idf:是否使用idf值. [boolean]
        :return:
        """
        vectorizer = TfidfVectorizer(max_df=max_df, max_features=max_features,
                                     min_df=min_df, stop_words=self.stop_words,
                                     use_idf=use_idf)

        encoder_data = vectorizer.fit_transform(cut_text_list)
        return encoder_data

    def svd(self, encoder_data, dim=config.reduction_dim):
        """
        对向量化后的数据进行svd降维
        :param encoder_data: 向量化后的数据. [np.array or csr_matrix]
        :param dim: 降维后的维度大小. [int]
        :return:
        """
        self.log.info('对数据进行svd降维...')
        svd = TruncatedSVD(dim)
        normalizer = Normalizer(copy=False)
        lsa = make_pipeline(svd, normalizer)
        encoder_data = lsa.fit_transform(encoder_data)
        return encoder_data

    def pca(self, encoder_data, dim=config.reduction_dim):
        """
        对向量化后的数据进行PCA降维
        :param encoder_data: 向量化后的数据. [np.array or csr_matrix]
        :param dim: 降维后的维度大小. [int]
        :return:
        """
        self.log.info('对数据进行pca降维...')
        if isinstance(encoder_data, scipy.sparse.csr.csr_matrix):
            encoder_data = encoder_data.todense()
        estimator = PCA(n_components=dim)
        encoder_data = estimator.fit_transform(encoder_data)
        return encoder_data

    def encode_data(self, cut_text_list, vector_type=config.vector_type, reduction_method=config.reduction_method):
        self.log.info('将文本进行向量化...')
        # 确定向量化的方式
        if vector_type == 'tfidf':
            encoder_data = self.tfidf_vector(cut_text_list)
        elif vector_type == 'doc2vec':
            encoder_data = self.doc2vec(cut_text_list)

        # 是否用svd进行降维
        if reduction_method == 'svd':
            encoder_data = self.svd(encoder_data)
        elif reduction_method == 'pca':
            encoder_data = self.pca(encoder_data)

        return encoder_data

    def plot_cluster(self, encoder_data, label, reduction_method=config.reduction_method):
        """
        绘制聚类后的效果图
        :param encoder_data: 向量表示后的数据,可以是np.array或者csr_matrix的形式
        :param label: 聚类后的标签
        :param reduction_method: 降维的方法,可以是pca或者svd
        :return:
        """
        self.log.info('绘制聚类效果图...')
        plt.switch_backend('agg')
        plt.figure()
        plt.rcParams['font.sans-serif'] = ['SimHei']
        plt.rcParams['axes.unicode_minus'] = False
        if reduction_method == 'svd':
            encoder_data = self.svd(encoder_data, dim=2)
        elif reduction_method == 'pca':
            encoder_data = self.pca(encoder_data, dim=2)
        data = pd.DataFrame()
        data['x1'] = encoder_data[:, 0]
        data['x2'] = encoder_data[:, 1]
        data['label'] = label
        unique_label = list(set(label))
        colors = [plt.cm.Spectral(each) for each in np.linspace(0, 1, len(unique_label))]
        for col, lab in zip(colors, unique_label):
            if lab == -1:
                col = [0, 0, 0, 1]
                continue
            this_cluster = data[data.label == lab]
            plt.scatter(this_cluster.x1, this_cluster.x2, c=tuple(col), s=5)
        plt.title('总共聚类的数目为:{0}'.format(len(unique_label)-1))
        plt.savefig('./data/cluster.png')

    最终聚类发现DBSCAN的效果还是蛮不错的,对大数据集的聚类速度比较快。部分类别的效果如下:
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

4. 总结

    最后,总结一下DBSCAN的优缺点:

  • DBSCAN可以发现任意形状的簇,对噪声不敏感,可以自动发现簇的数量
  • 计算速度快,对内存占用小,适用于大型数据集
  • 13
    点赞
  • 72
    收藏
    觉得还不错? 一键收藏
  • 11
    评论
评论 11
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值