Улучшение рекомендательной системы сайта на основе поведения пользователей

1 комментерий

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

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

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

Ранее на code-live.ru для подбора рекомендаций использовалась система тегов. То есть для просматриваемой статьи искались другие статьи с максимальным совпадением тегов и выдавались в качестве рекомендаций.

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

Было решено применить другой подход — показывать в первую очередь те статьи, на которые чаще всего переходят пользователи со страницы текущей статьи.

В Google Analytics и Яндекс Метрике есть такие инструменты, как карта переходов пользователей по страницам сайта. К сожалению, ни в одной, ни в другой системе на момент публикации, нет возможности сделать экспорт графа посещений в формате, который можно просто распарсить скриптом.

В обоих системах есть лишь возможность экспортировать графическое представление карты посещений в PDF-файл. Погуглив, оказалось, что в платной Google Analytics есть возможность экспорта графа посещений в машиночитаемый формат. Но, к сожалению, у меня нет платного аккаунта GA, и приобретать подписку ради такой мелочи у меня не было никакого желания.

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

Парсер открывает файл, считывает из него построчно данные, разбирает каждую строку в отдельный экземпляр класса LogItem и помещает этот экземпляр в список insert_pool.

Когда размер списка достигает двух тысячи записей, происходит вставка данных в базу данных через один INSERT-запрос. После этого список очищается и парсинг продолжается до тех пор, пока все данные в логах не будут перенесены в базу.

# Формат представления дат в логах
LOG_DATE_FORMAT = '%d/%b/%Y:%H:%M:%S'
# Максимальное количество записей, которые вставляются в базу через один запрос
INSERT_POOL_SIZE = 2000

class Token:
    """Токен для парсинга логов"""

    SPECIAL_CHAR = 1
    STRING = 2

    type = None
    content = ""

    def __init__(self, type=None, content=""):
        self.type = type
        self.content = content

def tokenize_log_item(string):
    """Разбить запись лога на токены"""

    tokens = []
    tok = Token(None, "")
    for char in string:
        if char in '"[] ':
            if tok.type == Token.STRING:
                tokens.append(tok)
            tok = Token(Token.SPECIAL_CHAR, char)
            tokens.append(tok)
            continue
        if tok.type != Token.STRING:
            tok = Token(Token.STRING, "")
        tok.content += char
    return tuple(tokens)

def parse_log_item(string):
    """Распарсить запись лога и вернуть словарь с полями"""

    result = {}
    tokens = tokenize_log_item(string)
    datestring = tokens[7].content
    date = datetime.datetime.strptime(datestring, LOG_DATE_FORMAT)
    result['ip'] = tokens[0].content
    result['date'] = date
    result['method'] = tokens[13].content
    result['uri'] = tokens[15].content[:255]
    result['referer'] = tokens[25].content[:255]
    try:
        result['status'] = int(tokens[20].content)
    except ValueError:
        result['status'] = 200
    return result

Помните, что формат логов вашего web-сервера может отличаться от моего. Ниже привожу пример записи лога, под формат которой писался парсер.

xxx.xxx.xxx.xxx - - [18/Apr/2015:05:03:14 +0300] "GET /post/cpp-hello-world/comments/feed/ HTTP/1.1" 200 4221 "-" "Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2272.118 Safari/537.36" "3.45"

Этот сайт написан на Django, поэтому я использовал встроенную систему консольных команд и моделей для запуска парсинга логов и работы с базой данных.

Я создал приложение analytics, в котором определил модели для хранения данных по посещениям сайта. Внутри директории приложения добавил файл management/commands/collect_page_views.py, который будет вызываться раз неделю по крону и обновлять таблицу посещений на основе новых логов.

from django.core.management.base import BaseCommand
from django.db import transaction
from analytics.models import PageView
import datetime
import logging

logging.basicConfig(level=logging.WARNING)
logger = logging.getLogger(__file__)

class Command(BaseCommand):
    help = u'''Парсит логи сервера и сохраняет в таблицу данные \
по посещенным страницам'''
    args = '<access_log_path>'

    def handle(self, log_path, *args, **options):
        insert_pool = []
        pool_size = 0
        count = 0

        last_page_view = PageView.objects.filter().\
            order_by('-date')[:1]
        last_view_date = None
        if last_page_view.exists():
            last_view_date = last_page_view[0].date

        sid = transaction.savepoint()

        try:
            with open(log_path) as f:
                while True:
                    line = f.readline()
                    if not line:
                        break
                    data = parse_log_item(line)
                    if last_view_date and data['date'] <= last_view_date:
                        # Старые логи не сохраняем заново
                        continue
                    page_view = PageView(**data)
                    insert_pool.append(page_view)
                    pool_size += 1
                    count += 1
                    logger.info('Processing log item #%d' % count)

                    if pool_size >= INSERT_POOL_SIZE:
                        logger.warn('Bulk creating models...')
                        PageView.objects.bulk_create(insert_pool)
                        pool_size = 0
                        insert_pool = []

                if pool_size > 0:
                    logger.warn('Bulk creating models...')
                    PageView.objects.bulk_create(insert_pool)

        except Exception as ex:
            transaction.savepoint_rollback(sid)
            raise ex

        transaction.savepoint_commit(sid)
        logger.warn('Total rows created: %d' % count)

Вот пример команды, с помощью которой происходит обновление данных по посещениям в базе данных.

python manage.py collect_page_views /home/www/code-live.ru/logs/access.log

Первый запуск скрипта добавил в базу больше двух миллионов записей. И ушло на это минут двадцать.

В последующие разы, скрипт будет игнорировать записи старых логов, которые уже были добавлены в базу.

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

По этим данным мы уже сможем показать улучшенные рекомендации для любой статьи на сайте.

Ниже приведен код файла management/commands/update_recommendation.py, который обновляет данные по переходам между статьями на основе данных по посещениям.

# coding:utf-8

import logging

from django.core.management.base import BaseCommand
from django.db import transaction
from django.db.models import Count

from analytics.models import PageView, Recommendation

logging.basicConfig(level=logging.WARNING)
logger = logging.getLogger(__file__)

SITE_URL = 'https://code-live.ru'
INSERT_POOL_MAX_SIZE = 2000

class Command(BaseCommand):
    help = u'Обновляет рекомендации статей'

    def handle(self, *args, **options):
        page_views = PageView.objects.filter(
            method='GET',
            status=200,
            uri__startswith='/post/',
            referer__startswith=SITE_URL + '/post/'
        ).values('uri', 'referer')\
         .annotate(weight=Count('referer'))

        referrer_offset = len(SITE_URL)
        count = 0
        insert_pool = []
        insert_pool_size = 0

        sid = transaction.savepoint()
        Recommendation.objects.all().delete()

        for view in page_views:
            source = view['referer'][referrer_offset:]
            target = view['uri']
            weight = int(view['weight'])
            if source == target:
                continue
            recommendation = Recommendation(source=source,
                                            target=target,
                                            weight=weight)
            insert_pool.append(recommendation)
            insert_pool_size += 1
            logger.info('Processing recommendation #%d' % count)

            if insert_pool_size >= INSERT_POOL_MAX_SIZE:
                insert_pool_size = 0
                logger.warn('Insert pool size is exceeded')
                logger.warn('Bulk creating models...')
                Recommendation.objects.bulk_create(insert_pool)
                insert_pool = []
            count += 1

        if insert_pool_size > 0:
            logger.warn('Bulk creating models...')
            Recommendation.objects.bulk_create(insert_pool)

        transaction.savepoint_commit(sid)
        logger.warn('Total rows created: %d' % count)

Рекомендации подгружаются через Ajax при открытии статьи, чтобы не замедлять загрузку страницы. Ниже приведен код Django-представления (View), который возвращает рекомендации для статьи.

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

class RecommendedArticlesView(View, JsonViewMixin):
    "Рекомендованные статьи"

    MAX_COUNT = 10  # Максимальное количество рекомендаций

    def _bigdata_failback(self, **kwargs):
        "Подбор рекомендаций на основе тегов"

        context = {}
        if 'post_id' in self.kwargs and self.kwargs['post_id'] is not None:
            article = get_object_or_404(models.Article,
                                        id=self.kwargs['post_id'])
            context['title'] = 'Прочитайте похожие статьи'
            posts = get_related_articles(article, self.MAX_COUNT)
        else:
            context['title'] = 'Рекомендованные статьи'
            posts = models.Article.public.filter().\
                values('title', 'slug').order_by('-id')[:self.MAX_COUNT]
        context['posts'] = []
        for post in posts:
            context['posts'].append({
                'url': reverse('blog-article', args=(post['slug'],)),
                'title': post['title'],
            })
        return context

    def _bigdata_recommendations(self, **kwargs):
        "Подбор рекомендаций на основе предыдущих посещений"

        article = None
        if 'post_id' in self.kwargs and self.kwargs['post_id'] is not None:
            article = get_object_or_404(models.Article,
                                        id=self.kwargs['post_id'])
        if not article:
            return []

        article_url = reverse('blog-article', args=(article.slug,))
        recommendations_urls = Recommendation.objects.filter(
            source=article_url).order_by('-weight').\
            values_list('target', flat=True)[:self.MAX_COUNT]
        if len(recommendations_urls) == 0:
            return []

        slugs = []
        for url in recommendations_urls:
            try:
                resolve_match = resolve(url)
            except http.Http404:
                continue
            if resolve_match.url_name == 'blog-article':
                slugs.append(resolve_match.kwargs['slug'])

        result = []
        for slug in slugs:
            try:
                post = models.Article.public.filter(slug=slug).\
                    values('slug', 'title')[:1][0]
            except IndexError:
                continue
            result.append({
                'url': reverse('blog-article', args=(post['slug'],)),
                'title': post['title'],
            })
        return result

    def get_context_data(self, **kwargs):
        context = {
            'title': 'Рекомендованные статьи',
            'posts': self._bigdata_recommendations(**kwargs)
        }
        if len(context['posts']) == 0:
            context = self._bigdata_failback(**kwargs)
        return context

    def get(self, *args, **kwargs):
        return self.render_to_response(self.get_context_data())

Рекомендации, подобранные новым способом, стали определенно более актуальными. Сравните результаты до и после для статьи первого урока по C++.

Рекомендации на основе тегов:

Рекомендации на основе тегов

Рекомендации на основе посещений:

Рекомендации на основе прошлых посещений

Такой относительно простой способ оптимизации позволил увеличить среднее количество просмотров с 1,94 до 2,15 страниц на посещение и среднюю длительность посещения с 2:50 до 3:20 минут.

Ниже привожу код моделей Django, которые используются для хранения данных по посещениям и рекомендациям.

# coding: utf-8
from django.db import models

class PageView(models.Model):
    """Просмотр страницы пользователем"""

    ip = models.GenericIPAddressField(db_index=True)
    date = models.DateTimeField()
    method = models.CharField(max_length=20, db_index=True)
    uri = models.CharField(max_length=255, db_index=True)
    status = models.PositiveSmallIntegerField()
    referer = models.CharField(max_length=255, null=True,
                               blank=True, db_index=True)

class Recommendation(models.Model):
    """Рекомендация пользователю для просмотра"""

    source = models.CharField(max_length=255, db_index=True)
    target = models.CharField(max_length=255)
    weight = models.IntegerField(default=0, db_index=True)

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

Подход с парсингом логов довольно хардкорный, но он позволяет хорошо снизить нагрузку на сайт, по сравнению с тем, если бы мы писали логи посещений напрямую в базу.

Если у вас на сайте уже работает система аналитики, которая логирует посещения в БД, то можете использовать ее для показа рекомендаций.

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

Комментарии к статье: 1

Подождите, загружаются комментарии...

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

Если у вас есть вопросы по содержанию статьи, рекомендуем вам обратиться за помощью на наш форум.