☰ Оглавление

Построение системы рекомендаций, на основе текстов

С помощью Python, scipy, sklearn и nltk можно легко построить волне рабочую систему рекомендаций. Даже с учётом русской морфологии.

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

Приготовления

Исходные тексты сайта у меня живут в markdown-формате. То есть, уже текстовые. Поэтому я просто создал (с помощью утилиты find) список всех файлов, подлежащих анализу в файле LIST.

Какие модули нам понадобятся

На понадобятся scipy, sklearn и nltk

>>> import scipy, sklearn, nltk
>>> scipy.version.version
'0.13.3'
>>> sklearn.__version__
'0.14.1'
>>> nltk.__version__
'2.0b9'

Поддержка русского языка тут уже есть «из коробки».

Код и комментарии

#!/usr/bin/python
# coding: utf8

import nltk
from sklearn.feature_extraction.text import CountVectorizer
import scipy as sp

class StemmedCountVectorizer(CountVectorizer):
    def __init__(self, **kv):
        super(StemmedCountVectorizer, self).__init__(**kv)
        self._stemmer = nltk.stem.snowball.RussianStemmer(
            'russian',
            ignore_stopwords=False
        )
    def build_analyzer(self):
        analyzer = super(StemmedCountVectorizer, self).build_analyzer()
        return lambda doc: (self._stemmer.stem(w) for w in analyzer(doc))

def euclidean_distance(v1, v2):
    # Строго говоря, эту нормолизацию можно было сделать
    # эффективней, если использовать параметр axis в la.norm()
    delta = v1/sp.linalg.norm(v1.toarray()) - v2/sp.linalg.norm(v2.toarray())
    return sp.linalg.norm(delta.toarray())

def main():
    files = [x.strip() for x in open('LIST', 'r')]
    texts = [open(n, 'r').read().decode('utf8') for n in files]
    vectorizer = StemmedCountVectorizer(
        min_df=1,
        token_pattern=ur'[ЁАБВГДЕЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯабвгдежзийклмнопрстуфхцчшщъыьэюяё]{4,}'
    )
    x = vectorizer.fit_transform(texts)
    #print ', '.join(vectorizer.get_feature_names())
    #print x.toarray().transpose()
    n_samples, n_features = x.shape
    summary = []
    target = 3 # 96 # 6
    for i in range(0, n_samples):
        summary.append([files[i], euclidean_distance(x[target], x[i])])
    summary.sort(key = lambda x: x[1])
    for t in summary:
        print t

main()

Собственно, код на столько прост, что комментировать особо нечего. sklearn.feature_extraction.text предоставляет готовый инструмент для подсчёта количества слов в тексте — CountVectorizer. Он позволяет загрузить много документов, создаёт единый словарь из всех найденных слов и для каждого слова и документа создаёт счётчик. Таким образом, на выходе мы получаем список всех слов (их называют факторами), и матрицу из счётчиков. Вы можете раскомментирвоать два print-а и посмотреть на результат.

Хитрость тут только в том, что мы добавили преобразование слов в корни. Суть очень проста:

>>> import nltk
>>> stemmer = nltk.stem.snowball.RussianStemmer('russian')
>>> print stemmer.stem(u'лошадь')
лошад
>>> print stemmer.stem(u'Лошадью')
лошад
>>> print stemmer.stem(u'ЛОШАДЕЙ')
лошад

Мы отнаследовались от класса CountVectorizer и добавили функциональность выделения корней.

Как видите, расстояние между текстами мы считаем, как Евклидово расстояние, между соответствующими точками. (Да-да: scipy.linalg.norm считает обычный корень из суммы квадратов. Единственное, это можно было сделать эффективней. Я оставил более наглядное решение, чтобы вам легче было играться с кодом.)

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

Мы выбираем один документ (target). Считаем расстояния от него до остальных, сортируем по расстоянию и печатаем результат.

Результаты работы

Давайте найдём документы похожие на «Пример обучения нейрона».

Мы получим такой top с такими весами:

Я бы сказал, — не плохо.

Попробуем рассмотреть документ, посвящённый нитям в Python: «Нити в Python»:

Как и раньше, мы оставили только те документы, расстояние до которых меньше 1.2.

Давайте рассмотрим ещё один пример: «Байесовское машинное обучение»:

Думаю, достаточно, чтобы вы могли походить по страницам и оценить качество поиска.

Естественно, результаты приведены на момент выполнения скрипта. Что-то могло измениться, дополниться… Эта заметка, естественно, в анализе не участвовала.

Что можно улучшить

Хотя эта система рекомендаций выдаёт вполне вменяемый результат (я даже подумываю, не прикрутить ли эти рекомендации к каждой странице сайта), всё же, она не лишена недостатков.

В реальной жизни надо, как минимум:

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

Во что ещё можно поиграть с этими данными

Если ваш массив документов побольше (желательно, наз в 100 :-)), то вы можете поиграться с кластеризацией. Это очень просто. Начать можно так:

from sklearn.cluster import KMeans
…
km = KMeans(
    n_clusters=5,
    init='random',
    n_init=1,
    verbose=1
)
km.fit(z)

Не забывайте, что z лучше нормализовать. Нормализовать отдельные вектора можно либо, используя аргумент axis, либо через apply_along_axis:

np.apply_along_axis(sp.linalg.norm, 1, mtx)

Зависит от версии.

После km.fit() вы можете получить массив с номерами групп через km.labels_. Длинна этого массива как раз равна количеству ваших файлов. Дальше zip, sort… Полный вперёд.

Кстати, тут интересно будет приглядеться к результатам и убедиться, что группировка текстов по однозначным группами — это, обычно, не самое хорошее решение. Например, на моей странице есть заметка про Байесовское машинное обучение. Её, видимо, разумно отнести к группе «машинное обучение», он ведь её хоршо бы видеть и в группе «теория вероятностей»? Очень часто, текст нельзя однозначно отнести к одной группе.

В этой связи, уместно вспомнить про LDA. По иронии судьбы, модуль sklearn.lda не имеет никакого отношения к latent Dirichlet allocation, а выполняет linear discriminant analysis. Создание тематических моделей, это отдельная история, которой я, пожалуй, не буду тут касаться.