python

Кластеризация и визуализация текстовой информации

  • понедельник, 8 января 2018 г. в 03:12:37
https://habrahabr.ru/post/346206/
  • Машинное обучение
  • Python


В русскоязычном секторе интернета очень мало учебных практических примеров (а с примером кода ещё меньше) анализа текстовых сообщений на русском языке. Поэтому я решил собрать данные воедино и рассмотреть пример кластеризации, так как не требуется подготовка данных для обучения.

Большинство используемых библиотек уже есть в дистрибутиве Anaconda 3, поэтому советую использовать его. Недостающие модули/библиотеки можно установить стандартно через pip install «название пакета».
Подключаем следующие библиотеки:

import numpy as np
import pandas as pd
import nltk
import re
import os
import codecs
from sklearn import feature_extraction
import mpld3
import matplotlib.pyplot as plt
import matplotlib as mpl

Для анализа можно взять любые данные. Мне на глаза тогда попала данная задача: Статистика поисковых запросов проекта Госзатраты. Им нужно было разбить данные на три группы: частные, государственные и коммерческие организации. Придумывать экстраординарное ничего не хотелось, поэтому решил проверить, как поведет кластеризация в данном случае (забегая наперед — не очень). Но можно выкачать данные из VK какого-нибудь паблика:

import vk
#передаешь id сессии
session = vk.Session(access_token='')
# URL для получения access_token, вместо tvoi_id вставляете id созданного приложения Вк:
# https://oauth.vk.com/authorize?client_id=tvoi_id&scope=friends,pages,groups,offline&redirect_uri=https://oauth.vk.com/blank.html&display=page&v=5.21&response_type=token 
api = vk.API(session)
poss=[]
id_pab=-59229916 #id пабликов начинаются с минуса, id стены пользователя без минуса
info=api.wall.get(owner_id=id_pab, offset=0, count=1)
kolvo = (info[0]//100)+1

shag=100
sdvig=0
h=0

import time

while h<kolvo:
    if(h>70):
        print(h) #не обязательное условие, просто для контроля примерного окончания процесса
    pubpost=api.wall.get(owner_id=id_pab, offset=sdvig, count=100)
    i=1
    while i < len(pubpost):
        b=pubpost[i]['text']
        poss.append(b)
        i=i+1
    h=h+1
    sdvig=sdvig+shag
    time.sleep(1)
len(poss)

import io
with io.open("public.txt", 'w', encoding='utf-8', errors='ignore') as file:
    for line in poss:
        file.write("%s\n" % line)
file.close()

titles = open('public.txt', encoding='utf-8', errors='ignore').read().split('\n')

print(str(len(titles)) + ' постов считано')

import re
posti=[]

#удалим все знаки препинания и цифры
for line in titles:
    chis = re.sub(r'(\<(/?[^>]+)>)', ' ', line)
    #chis = re.sub()
    chis = re.sub('[^а-яА-Я ]', '', chis)
    posti.append(chis)

Я буду использовать данные поисковых запросов чтобы показать, как плохо кластеризуются короткие текстовые данные. Я заранее очистил от спецсимволов и знаков препинания текст плюс провел замену сокращений (например, ИП – индивидуальный предприниматель). Получился текст, где в каждой строке находился один поисковый запрос.

Считываем данные в массив и приступаем к нормализации – приведению слова к начальной форме. Это можно сделать несколькими способами, используя стеммер Портера, стеммер MyStem и PyMorphy2. Хочу предупредить – MyStem работает через wrapper, поэтому скорость выполнения операций очень медленная. Остановимся на стеммере Портера, хотя никто не мешает использовать другие и комбинировать их с друг другом (например, пройтись PyMorphy2, а после стеммером Портера).

titles = open('material4.csv', 'r', encoding='utf-8', errors='ignore').read().split('\n')
print(str(len(titles)) + ' запросов считано')

from nltk.stem.snowball import SnowballStemmer
stemmer = SnowballStemmer("russian")

def token_and_stem(text):
    tokens = [word for sent in nltk.sent_tokenize(text) for word in nltk.word_tokenize(sent)]
    filtered_tokens = []
    for token in tokens:
        if re.search('[а-яА-Я]', token):
            filtered_tokens.append(token)
    stems = [stemmer.stem(t) for t in filtered_tokens]
    return stems

def token_only(text):
    tokens = [word.lower() for sent in nltk.sent_tokenize(text) for word in nltk.word_tokenize(sent)]
    filtered_tokens = []
    for token in tokens:
        if re.search('[а-яА-Я]', token):
            filtered_tokens.append(token)
    return filtered_tokens

#Создаем словари (массивы) из полученных основ
totalvocab_stem = []
totalvocab_token = []
for i in titles:
    allwords_stemmed = token_and_stem(i)
    #print(allwords_stemmed)
    totalvocab_stem.extend(allwords_stemmed)
    
    allwords_tokenized = token_only(i)
    totalvocab_token.extend(allwords_tokenized)


Pymorphy2
import pymorphy2
morph = pymorphy2.MorphAnalyzer()

G=[]

for i in titles:
    h=i.split(' ')
    #print(h)
    s=''
    for k in h:
        #print(k)
        p = morph.parse(k)[0].normal_form
        #print(p)
        s+=' '
        s += p
        #print(s)
        #G.append(p)
    #print(s)
    G.append(s)

pymof = open('pymof_pod.txt', 'w', encoding='utf-8', errors='ignore')
pymofcsv = open('pymofcsv_pod.csv', 'w', encoding='utf-8', errors='ignore')
for item in G:
    pymof.write("%s\n" % item)
    pymofcsv.write("%s\n" % item)
pymof.close()
pymofcsv.close()


pymystem3
Исполняемые файлы анализатора для текущей операционной системы будут автоматически загружены и установлены при первом использовании библиотеки.

from pymystem3 import Mystem
m = Mystem()
A = []

for i in titles:
    #print(i)
    lemmas = m.lemmatize(i)
    A.append(lemmas)

#Этот массив можно сохранить в файл либо "забэкапить"
import pickle
with open("mystem.pkl", 'wb') as handle:
                    pickle.dump(A, handle)


Создадим матрицу весов TF-IDF. Будем считать каждый поисковой запрос за документ (так делают при анализе постов в Twitter, где каждый твит – это документ). tfidf_vectorizer мы возьмем из пакета sklearn, а стоп-слова мы возьмем из корпуса ntlk (изначально придется скачать через nltk.download()). Параметры можно подстроить как вы считаете нужным – от верхней и нижней границы до количества n-gram (в данном случае возьмем 3).

stopwords = nltk.corpus.stopwords.words('russian')
#можно расширить список стоп-слов
stopwords.extend(['что', 'это', 'так', 'вот', 'быть', 'как', 'в', 'к', 'на'])

from sklearn.feature_extraction.text import TfidfVectorizer, CountVectorizer

n_featur=200000
tfidf_vectorizer = TfidfVectorizer(max_df=0.8, max_features=10000,
                                 min_df=0.01, stop_words=stopwords,
                                 use_idf=True, tokenizer=token_and_stem, ngram_range=(1,3))
get_ipython().magic('time tfidf_matrix = tfidf_vectorizer.fit_transform(titles)')
print(tfidf_matrix.shape)

Над полученной матрицей начинаем применять различные методы кластеризации:

num_clusters = 5

# Метод к-средних - KMeans
from sklearn.cluster import KMeans

km = KMeans(n_clusters=num_clusters)
get_ipython().magic('time km.fit(tfidf_matrix)')
idx = km.fit(tfidf_matrix)
clusters = km.labels_.tolist()

print(clusters)
print (km.labels_)

# MiniBatchKMeans
from sklearn.cluster import MiniBatchKMeans

mbk  = MiniBatchKMeans(init='random', n_clusters=num_clusters) #(init='k-means++', ‘random’ or an ndarray)
mbk.fit_transform(tfidf_matrix)
%time mbk.fit(tfidf_matrix)
miniclusters = mbk.labels_.tolist()
print (mbk.labels_)


# DBSCAN
from sklearn.cluster import DBSCAN
get_ipython().magic('time db = DBSCAN(eps=0.3, min_samples=10).fit(tfidf_matrix)')
labels = db.labels_
labels.shape
print(labels)

# Аггломеративная класстеризация
from sklearn.cluster import AgglomerativeClustering

agglo1 = AgglomerativeClustering(n_clusters=num_clusters, affinity='euclidean') #affinity можно выбрать любое или попробовать все по очереди: cosine, l1, l2, manhattan
get_ipython().magic('time answer = agglo1.fit_predict(tfidf_matrix.toarray())')
answer.shape

Полученные данные можно сгруппировать в dataframe и посчитать количество запросов, попавших в каждый кластер.

#k-means
clusterkm = km.labels_.tolist()
#minikmeans
clustermbk = mbk.labels_.tolist()
#dbscan
clusters3 = labels
 #agglo
#clusters4 = answer.tolist()

frame = pd.DataFrame(titles, index = [clusterkm])

#k-means
out = { 'title': titles, 'cluster': clusterkm }
frame1 = pd.DataFrame(out, index = [clusterkm], columns = ['title', 'cluster'])

#mini
out = { 'title': titles, 'cluster': clustermbk }
frame_minik = pd.DataFrame(out, index = [clustermbk], columns = ['title', 'cluster'])

frame1['cluster'].value_counts()
frame_minik['cluster'].value_counts()

Из-за большого количества запросов не совсем удобно смотреть таблицы и хотелось бы больше интерактивности для понимания. Поэтому сделаем графики взаимного расположения запросов относительного друг друга.

Сначала необходимо вычислить расстояние между векторами. Для этого будет применяться косинусовое расстояние. В статьях предлагают использовать вычитание из единицы, чтобы не было отрицательных значений и находилось в пределах от 0 до 1, поэтому сделаем так же:

from sklearn.metrics.pairwise import cosine_similarity
dist = 1 - cosine_similarity(tfidf_matrix)
dist.shape

Так как графики будут двух-, трехмерные, а исходная матрица расстояний n-мерная, то придется применять алгоритмы снижения размерности. На выбор есть много алгоритмов (MDS, PCA, t-SNE), но остановим выбор на Incremental PCA. Этот выбор сделан в следствии практического применения – я пробовал MDS и PCA, но оперативной памяти мне не хватало (8 гигабайт) и когда начинал использоваться файл подкачки, то можно было сразу уводить компьютер на перезагрузку.

Алгоритм Incremental PCA используется в качестве замены метода главных компонентов (PCA), когда набор данных, подлежащий разложению, слишком велик, чтобы разместиться в оперативной памяти. IPCA создает низкоуровневое приближение для входных данных, используя объем памяти, который не зависит от количества входных выборок данных.

# Метод главных компонент - PCA

from sklearn.decomposition import IncrementalPCA
icpa = IncrementalPCA(n_components=2, batch_size=16)
get_ipython().magic('time icpa.fit(dist) #demo =')
get_ipython().magic('time demo2 = icpa.transform(dist)')
xs, ys = demo2[:, 0], demo2[:, 1]

# PCA 3D
from sklearn.decomposition import IncrementalPCA
icpa = IncrementalPCA(n_components=3, batch_size=16)
get_ipython().magic('time icpa.fit(dist) #demo =')
get_ipython().magic('time ddd = icpa.transform(dist)')
xs, ys, zs = ddd[:, 0], ddd[:, 1], ddd[:, 2]

#Можно сразу примерно посмотреть, что получится в итоге
#from mpl_toolkits.mplot3d import Axes3D
#fig = plt.figure()
#ax = fig.add_subplot(111, projection='3d')
#ax.scatter(xs, ys, zs)
#ax.set_xlabel('X')
#ax.set_ylabel('Y')
#ax.set_zlabel('Z')
#plt.show()

Перейдем непосредственно к самой визуализации:

from matplotlib import rc
#включаем русские символы на графике
font = {'family' : 'Verdana'}#, 'weigth': 'normal'}
rc('font', **font)

#можно сгенерировать цвета для кластеров
import random
def generate_colors(n):
    color_list = []
    for c in range(0,n):
        r = lambda: random.randint(0,255)
        color_list.append( '#%02X%02X%02X' % (r(),r(),r()) )
    return color_list

#устанавливаем цвета
cluster_colors = {0: '#ff0000', 1: '#ff0066', 2: '#ff0099',  3: '#ff00cc', 4: '#ff00ff',}
#даем имена кластерам, но из-за рандома пусть будут просто 01234
cluster_names = {0: '0',  1: '1', 2: '2',  3: '3', 4: '4',}
#matplotlib inline

#создаем data frame, который содержит координаты (из PCA) + номера кластеров и сами запросы
df = pd.DataFrame(dict(x=xs, y=ys, label=clusterkm, title=titles)) 
#группируем по кластерам
groups = df.groupby('label')

fig, ax = plt.subplots(figsize=(72, 36)) #figsize подбирается под ваш вкус

for name, group in groups:
    ax.plot(group.x, group.y, marker='o', linestyle='', ms=12, label=cluster_names[name], color=cluster_colors[name], mec='none')
    ax.set_aspect('auto')
    ax.tick_params(        axis= 'x',          
        which='both',      
        bottom='off',      
        top='off',         
        labelbottom='off')
    ax.tick_params(        axis= 'y',         
        which='both',     
        left='off',      
        top='off',       
        labelleft='off')
    
ax.legend(numpoints=1)  #показать легенду только 1 точки

#добавляем метки/названия в х,у позиции с поисковым запросом
#for i in range(len(df)):
#    ax.text(df.ix[i]['x'], df.ix[i]['y'], df.ix[i]['title'], size=6)  

#показать график
plt.show() 
plt.close()

Если раскомментировать строку с добавлением названий, то выглядеть это будет примерно так:

Пример с 10 кластерами
image

Не совсем то, что хотелось бы ожидать. Воспользуемся mpld3 для перевода рисунка в интерактивный график.

# Plot 
fig, ax = plt.subplots(figsize=(25,27)) 
ax.margins(0.03)


for name, group in groups_mbk:
    points = ax.plot(group.x, group.y, marker='o', linestyle='', ms=12, #ms=18
                     label=cluster_names[name], mec='none', 
                     color=cluster_colors[name])
    ax.set_aspect('auto')
    labels = [i for i in group.title]
    
    tooltip = mpld3.plugins.PointHTMLTooltip(points[0], labels, voffset=10, hoffset=10, #css=css)
    mpld3.plugins.connect(fig, tooltip) #   , TopToolbar()
    
    ax.axes.get_xaxis().set_ticks([])
    ax.axes.get_yaxis().set_ticks([])

    #ax.axes.get_xaxis().set_visible(False)
    #ax.axes.get_yaxis().set_visible(False)

ax.set_title("Mini K-Means", size=20) #groups_mbk
    
ax.legend(numpoints=1)
mpld3.disable_notebook()
#mpld3.display()
mpld3.save_html(fig, "mbk.html")

mpld3.show()
#mpld3.save_json(fig, "vivod.json")
#mpld3.fig_to_html(fig)

fig, ax = plt.subplots(figsize=(51,25))

scatter = ax.scatter(np.random.normal(size=N),
                     np.random.normal(size=N),
                     c=np.random.random(size=N),
                     s=1000 * np.random.random(size=N),
                     alpha=0.3,
                     cmap=plt.cm.jet)
ax.grid(color='white', linestyle='solid')    

ax.set_title("Кластеры", size=20)

fig, ax = plt.subplots(figsize=(51,25))

labels = ['point {0}'.format(i + 1) for i in range(N)]
tooltip = mpld3.plugins.PointLabelTooltip(scatter, labels=labels)
mpld3.plugins.connect(fig, tooltip)

mpld3.show()fig, ax = plt.subplots(figsize=(72,36))
for name, group in groups:
    points = ax.plot(group.x, group.y, marker='o', linestyle='', ms=18, 
                     label=cluster_names[name], mec='none', 
                     color=cluster_colors[name])
    ax.set_aspect('auto')
    labels = [i for i in group.title]
    tooltip = mpld3.plugins.PointLabelTooltip(points, labels=labels)
    mpld3.plugins.connect(fig, tooltip)

ax.set_title("K-means", size=20)

mpld3.display()

Теперь при наведении на любую точку графика всплывает текст с соотвествующим поисковым запросом. Пример готового html файла можно посмотреть здесь: Mini K-Means

Если хочется в 3D и с изменяемым масштабом, то существует сервис Plotly, который имеет плагин для Python.

Plotly 3D
#для примера просто 3D график из полученных значений
import plotly
plotly.__version__

import plotly.plotly as py
import plotly.graph_objs as go

trace1 = go.Scatter3d(
    x=xs,
    y=ys,
    z=zs,
    mode='markers',
    marker=dict(
        size=12,
        line=dict(
            color='rgba(217, 217, 217, 0.14)',
            width=0.5
        ),
        opacity=0.8
    )
)

data = [trace1]
layout = go.Layout(
    margin=dict(
        l=0,
        r=0,
        b=0,
        t=0
    )
)
fig = go.Figure(data=data, layout=layout)
py.iplot(fig, filename='cluster-3d-plot')


Результаты можно увидеть здесь: Пример

И заключительным пунктом выполним иерархическую (аггломеративную) кластеризацию по методу Уорда для создания дендограммы.

In [44]:
from scipy.cluster.hierarchy import ward, dendrogram
linkage_matrix = ward(dist)

fig, ax = plt.subplots(figsize=(15, 20))
ax = dendrogram(linkage_matrix, orientation="right", labels=titles);

plt.tick_params(\
    axis= 'x',
    which='both', 
    bottom='off',
    top='off',
    labelbottom='off')

plt.tight_layout() 

#сохраним рисунок
plt.savefig('ward_clusters2.png', dpi=200)

Выводы

К сожалению, в области исследования естественного языка очень много нерешённых вопросов и не все данные легко и просто сгруппировать в конкретные группы. Но надеюсь, что данное руководство усилит интерес к данной теме и даст базис для дальнейших экспериментов.