聚类算法作为分类任务中的无监督方法,在很多场景中都会用到,比如用户聚类、文档主题分类等等。常见的聚类算法大致可以分为两种,一种是基于分区的算法,如k-means等,这种方法虽然易于理解,但是有以下三个缺点:
另一种是基于层次划分的算法,如层次聚类法等,这种方法虽然不用事先确定聚类的个数,但是也存在以下缺点:
因此,本文介绍另一个聚类算法——DBSCAN,该方法是基于密度的聚类方法,能够有效解决上面提到的各种问题。
DBSCAN是一种基于密度的聚类算法,其基本假设是一个集群的密度要显著高于噪声点的密度,因此,该方法的基本思想是对于集群中的每一个点,在给定的半径范围内,其相邻点的数量必须超过预先设定的某一个阈值。
在介绍DBSCAN算法之前,先介绍几个基本的概念:
DBSCAN的算法步骤如下(这里直接引用周志华老师的《机器学习》):
由前面的介绍我们可以发现,DBSCAN的参数其实就两个,一个是半径Eps,一个是半径范围内的最少点数量MinPts,那么,在建立模型时,如何确定这两个参数的取值呢?作者提出了一个叫sorted k-dist图的方法。即对于数据集中的每个点,分别计算他们与第k个最近邻的距离,然后将这些距离进行逆排序,并绘制他们距离分布曲线,如下图所示:
接着,通过肉眼观察曲线的分布,当出现第一个谷点时,则该点即可作为临界点,对应的距离可以作为Eps的取值,另外,k即可作为MinPts的取值。作者在实验中发现,一般k取值为4是比较合适的,因为随着k的增大,其实距离的变动不会很敏感,因为在一个半径范围内,其实一个点第k个最近邻可能会存在多个不同的点,因此,增加k其实对d的影响可能不会太大。
笔者利用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的效果还是蛮不错的,对大数据集的聚类速度比较快。部分类别的效果如下:
最后,总结一下DBSCAN的优缺点: