Автоматическая рекомендация: немного теории и практики

1. Вступление

В этой заметке будут рассмотрены некоторые базовые теоретические и практические вопросы автоматической рекомендации. Особое внимание будет уделено рассказу об опыте использования Apache Mahout на крупных порталах (написанных на Yii 2) с большой посещаемостью (несколько миллионов человек в сутки). Будут приведены примеры исходного кода на PHP и JAVA, которые помогут читателю лучше понять процесс интеграции Mahout.
2. Робастность оценки

Прежде всего, мы должны убедиться, что на результаты не влияют различные помехи. Как правило, самым простым способом рекомендовать некую сущность является метод ранжирования по среднему значению пользовательской оценки. Чем выше средняя оценка, тем выше вероятность целесообразности рекомендовать объект. Однако, даже в таком простом подходе есть очень важный момент — помехи в точности. Посмотрим на пару примеров. Предположим, что у пользователя есть возможность оценить товар (или иной объект) по шкале от 1 до 10. Пусть есть конечное множество пользовательских оценок некого объекта: 5, 7, 4, 8, 6, 5, 10, 5, 10, 9, 10, 10, 10, 5, 2, 10. Для более комфортного восприятия отобразим их в виде диаграммы:

392484fd97dc4b838aee4f16cc3fb09c.png

Мощность множества равна 16. В данном конкретном случае мы не наблюдаем существенной разницы между медианой (7,5) и среднем арифметическим значением (7,25), а дисперсия случайной величины составляет всего 7,267. В ряде случаев нам может понадобиться отфильтровать выбросы (например, если вариационный размах может быть большим). Естественно, мы можем использовать робастные показатели. Пусть есть множество: 1, 2, 1, 2, 3, 1, 5, 100, 4. Его графическое представление:

d3cdc94569b449b687fbcecabcdff3c9.png

В упомянутом примере среднее арифметическое значение явно не отражает реальную ситуацию. Более того, дисперсия в выборке явно большая (в данном примере чуть больше 1060). Использование робастных показателей (медиана и квартили) помогает нам избежать подобных проблем.

3. Учёт корреляции

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

125b908353ba4e8791e173f72b7e1e8a.png

Одного взгляда на график вполне достаточно, чтобы увидеть сильную схожесть мнений двух экспертов. В данном примере линейный коэффициент корреляции Карла Пирсона составил 0,9684747092. Исходя из полученных данных можно прогнозировать, что вероятность наступления события «поставили похожую оценку» для других сайтов будет достаточна высока. Зная вкусы рекомендовать проще, верно? А можем ли мы опираться на оценки только единомышленников пользователя, а не на все подряд?

4. Автоматическая рекомендация

Рассмотрим пример работы интересной бесплатной библиотеки Apache Mahout. Допустим, что существуют три объекта. Я выставил оценку только двум из них (первый объект я оценил всего в два балла, а второй объект в пять). Кроме меня есть ещё три человека, которые оценивали объекты. Но в отличии от меня они оценили все три объекта. Посмотрим на таблицу с оценками других людей:

274b51b4b42745f89323e5f629d6dd54.png

Запишем все данные в CSV формате:

5c65d2ccb1a94eef95498ff2782c756c.png

Действительно, если передать такие данные в Mahout, то он порекомендует мне именно третий объект. Смысл рекомендовать первые два объекта отсутствует, так как я уже не просто знаю о них, а даже выставил им оценку. Более того, Mahout смог учитывать схожесть моего мнения с тремя другими людьми — если я дам сильно отличающуюся оценку первому объекту (допустим 10), то Mahout не будем мне ничего рекомендовать. Я ничего не перепутал? Сейчас проверим.

Класс MySQLJDBCDataModel умеет получать данные из базы MySQL (таблица должна содержать: user_id, item_id, и preference). А класс FileDataModel может загружать из файла (CSV файл, где каждая строка имеет вид «1,1,2.0», а пустые строки игнорируются). Теоретически, приложение на Yii вообще ничего не должно знать о методах рекомендаций, а просто брать из базы (связь по таблице: идентификатор пользователя, идентификатор рекомендуемого объекта) нужную информацию и отображать её для пользователя.

Мне приходилось делать достаточно много задач по высоконагруженным сайтам (с посещаемостью несколько миллионов человек в сутки) на Yii, включая интеграцию с различными аналитическими системами и поисковыми платформами. Естественно, часто приходилось разбираться в огромном количестве проектов на Java, но вот Mahout я подключал впервые.

Разумеется, способов обмена данными может быть много. Начиная от прямых выгрузок (экспорт с помощью Hibernate в базу данных сайта) из внешних систем, до использования очередей (Gearman, RabbitMQ). Я видел несколько забавных случаев, когда для получения данных использовался парсинг сайтов с помощью JSOUP и даже весьма медленного PhantomJS, а иногда выгружали из Excel при помощи POI. Но не будем о грустном.

Кстати, со способами хранения тоже бывает не скучно — начиная от MongoDB до поисковых систем (Endeca, Solr, Sphinx, даже встроенные в ATG чудеса). Конечно, такие варианты имеют право на существование и не редко используются огромными проектами, однако, в этой заметке я хотел бы рассмотреть более распространённый вариант.

Допустим, у нас есть сайт на Yii с посещаемостью несколько миллионов человек в сутки. В качестве базы данных пусть используется кластер MySQL (все тяготы и лишения нагрузки принимает на себя memcached). Приложение не имеет прав записи в базу данных, а данные передаёт исключительно через API (в кластер Redis), откуда их забирает (благодаря бесплатным библиотекам google-gson и Jedis) написанная на Java аналитическая система. Именно к ней и была добавлена библиотека Mahout.

Но я хочу получить не просто список идентификаторов, а готовые (для виджета) данные. Что же мне нужно? Допустим, я хочу отобразить картинку. А ещё мне нужен заголовок. Конечно, мне нужна ссылка на рекомендуемый объект (ту страницу, куда попадёт пользователь, если кликнет по виджету). Это будет универсальный вариант. В ответственную за выгрузку систему я могу добавить нужную мне логику наполнения этой таблицы. В подобном случае структура таблицы может быть примерно такой:

use yii\db\Schema;
use yii\db\Migration;

class m150923_110338_recommend extends Migration
{
    public function up()
    {
        $this->createTable('recommend', [
            'id' => $this->primaryKey(),
            'status' => $this->boolean()->notNull(),
            'url' => $this->string(255)->notNull(),
            'title' => $this->string(255)->notNull(),
            'image' => $this->string(255)->notNull(),
            'created_at' => $this->datetime()->notNull(),
            'updated_at' => $this->datetime()->notNull(),
        ]);
    }

    public function down()
    {
        $this->dropTable('recommend');
    }
}

В модели должен быть метод, который позволит нам понять, какому пользователю мы будем рекомендовать эту сущность. Часть рекомендуемых объектов скажет нам Mahout. Разумеется, мы с самого начала предусмотрим ситуацию, когда Mahout не сможет нам ничего порекомендовать (или количество будет недостаточным). Модель может быть примерно такой:

namespace common\models;

use Yii;
use common\models\Api;

/**
 * This is the model class for table "recommend".
 *
 * @property integer $id
 * @property integer $status
 * @property string $url
 * @property string $title
 * @property string $image
 * @property string $created_at
 * @property string $updated_at
 */
class Recommend extends \yii\db\ActiveRecord
{
    const STATUS_INACTIVE = 0;
    const STATUS_ACTIVE = 1;
    
    /**
     * @inheritdoc
     */
    public static function tableName()
    {
        return 'recommend';
    }

    /**
     * @inheritdoc
     */
    public function rules()
    {
        return [
            [['status', 'url', 'title', 'image', 'created_at', 'updated_at'], 'required'],
            [['status'], 'integer'],
            [['created_at', 'updated_at'], 'safe'],
            [['url', 'title', 'image'], 'string', 'max' => 255]
        ];
    }

    /**
     * @inheritdoc
     */
    public function attributeLabels()
    {
        return [
            'id' => 'ID',
            'status' => 'Статус',
            'url' => 'Ссылка',
            'title' => 'Название',
            'image' => 'Ссылка на картинку',
            'created_at' => 'Создано',
            'updated_at' => 'Обновлено',
        ];
    }
    
    /**
     * @inheritdoc
     */
    public function behaviors()
    {
        return [
            [
                'class' => \yii\behaviors\TimestampBehavior::className(),
                'value' => new \yii\db\Expression('NOW()'),
            ],
        ];
    }
    
    /**
     * Status list
     */
    public function statusList()
    {
        return [
            self::STATUS_INACTIVE => 'Скрыто',
            self::STATUS_ACTIVE => 'На сайте',
        ];
    }
    
    /**
     * @param integer $userId
     * @param integer $limit
     */
    public static function getItemsByUserId($userId = 1, $limit = 6)
    {
        $itemIds = [];

        // В методе get класса Api уже есть JSON::decode и обработка исключений
        // Мы получаем ID для Recommend, а не для объектов сайта (товаров, новостей, страниц)
        $mahout = Api::get('s=mahout&order=value&limit=' . (int)$limit . '&user=' . (int)$userId);
        if(!empty($mahout['status']) && $mahout['status'] == true) {
            $itemIds = $mahout['item-ids'];
        }

        if(count($itemIds) < $limit) {
            // Рекомендации в зависимости от событий на сайте (добавления товаров в корзину, 
            // подписка на новости, источник перехода на сайт, поиск на сайте и т.д.). Если недостаточно, 
            // то возвращаем универсальный массив.
            $limit = $limit - count($itemIds);
            $recommend = Api::get('s=recommend&limit=' . (int)$limit . '&user=' . (int)$userId);
            if(!empty($recommend['status']) && $recommend['status'] == true) {
                $itemIds = array_merge($itemIds, $recommend['item-ids']);
            }
        }
        
        return static::find()->where(['id' => $itemIds, 'status' => static::STATUS_ACTIVE])->all();
    }
}

И контроллер тоже будет совсем не хитрым:

namespace frontend\controllers;

use Yii;
use yii\web\Controller;
use common\models\Recommend;

class MainController extends Controller
{
    private $_itemsLimit = 6;
    private $_cacheTime = 120;

    public function actionIndex()
    {
        $userId = Yii::$app->request->cookies->getValue('userId', 1);
        $recommends = Recommend::getDb()->cache(function ($db) use ($userId) {
            return Recommend::getItemsByUserId($userId, $this->_itemsLimit);
        }, $this->_cacheTime);

        return $this->render('index', ['recommends' => $recommends]);
    }
}

А вот и представление (view в MVC):

<?php
use yii\helpers\Html;

$this->title = 'Example';
$this->params['breadcrumbs'][] = $this->title;
?>

<h3>Рекомендуемые товары:</h3>

<div class="row">
    <?php foreach ($recommends as $recommend) {  ?>
        <div class="col-md-2">
            <a href="<?=  $recommend->url ?>" target="_blank">
                <img src="<?= $recommend->image ?>" class="img-thumbnail" alt="<?= Html::encode($recommend->title) ?>">
            </a>
        </div>
    <?php } ?>
</div>

Прототип готов. Осталось перенести нужный код в реальную систему. Начать задачу мне предстояло в понедельник, а в субботу я решил попробовать Mahout на своём домашнем компьютере. Куча прочитанных книг — это хорошо, а практика тоже важна. За несколько минут я набросал простое приложение на Java, которое забирает данные из файла формата CSV, а записывает результат в формате JSON.

Интерфейс просит нас реализовать всего один метод, который будет возвращать JSON. В данном конкретном случае нам нужно предать ссылку на CSV файл с данными и список идентификаторов пользователей, которым нужно что-то рекомендовать:

package com.api.service;

import java.util.List;

public interface IService {
        String run(String datasetFile, List<Integer> userIds);
}

Далее создадим фабрику:

package com.api.service;

public class ServiceFactory {
        /**
         * Get Service
         * @param type
         * @return
         */
        public IService getService(String type) {
                if (type == null) {
                        return null;
                }
                if(type.equalsIgnoreCase("Mahout")) {
                        return new MahoutService();
                }
                return null;
        }
}

Для примера я получу список рекомендуемых объектов для каждого пользователя, который фигурирует в списке идентификаторов:

package com.api.service;

import java.io.IOException;
import java.util.List;

import org.apache.mahout.cf.taste.common.TasteException;

import com.api.model.CustomUserRecommender;
import com.api.util.MahoutHelper;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;

public class MahoutService implements IService {

        @Override
        public String run(String datasetFile, List<Integer> userIds)  {
                Gson gson = new GsonBuilder().create();
                MahoutHelper mahoutHelper = new MahoutHelper();
                List<CustomUserRecommender> customUserRecommenders = null;
                
                try {
                        customUserRecommenders = mahoutHelper.customUserRecommender(userIds, datasetFile);
                } catch (IOException | TasteException e) {
                        e.printStackTrace();
                }
                
                return gson.toJson(customUserRecommenders);
        }
}

А вот и «тот самый» класс:

package com.api.util;

import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;

import org.apache.mahout.cf.taste.common.TasteException;
import org.apache.mahout.cf.taste.impl.model.file.FileDataModel;
import org.apache.mahout.cf.taste.impl.neighborhood.ThresholdUserNeighborhood;
import org.apache.mahout.cf.taste.impl.recommender.GenericUserBasedRecommender;
import org.apache.mahout.cf.taste.impl.similarity.PearsonCorrelationSimilarity;
import org.apache.mahout.cf.taste.model.DataModel;
import org.apache.mahout.cf.taste.neighborhood.UserNeighborhood;
import org.apache.mahout.cf.taste.recommender.UserBasedRecommender;
import org.apache.mahout.cf.taste.similarity.UserSimilarity;

import com.api.model.CustomUserRecommender;

public class MahoutHelper {
        
        /**
         * @param List<Integer> userIds
         * @param String datasetFile
         * @return List<CustomUserRecommender>
         * @throws IOException
         * @throws TasteException
         */
        public List<CustomUserRecommender> customUserRecommender(List<Integer> userIds, String datasetFile) throws IOException, TasteException {
                List<CustomUserRecommender> customUserRecommenders = new ArrayList<CustomUserRecommender>();
                DataModel datamodel = new FileDataModel(new File(datasetFile)); 
                UserSimilarity usersimilarity = new PearsonCorrelationSimilarity(datamodel);
                UserNeighborhood userneighborhood = new ThresholdUserNeighborhood(0.1, usersimilarity, datamodel);
                UserBasedRecommender recommender = new GenericUserBasedRecommender(datamodel, userneighborhood, usersimilarity);
                
                for (Integer userId : userIds) {
                        customUserRecommenders.add(new CustomUserRecommender(userId, recommender.recommend(userId, 10)));
                }
                
                return customUserRecommenders;
        }
}

В реальном проекте библиотека Mahout была добавлена в существующую систему (готовое решение). Как я уже упоминал, в качестве способа передачи данных был выбран API. Как показала практика, очень хорошо на конверсию влияет добавление рекомендаций на ключевые страницы (например, карточка товара). Не редко персональный рейтинг рекомендуемых объектов отправляют по электронной почте, допустим, один раз в неделю.

По возможности, старайтесь на каждой странице делать небольшую форму опроса пользователя об интересности и полезности для него того или иного товара. Как минимум, можно сделать два символа («+» и «-»). Дихотомическая классификация обычно выражается числовыми оценками (лучше 2 и 10, чтобы разница была более очевидной). Старайтесь мотивировать людей оставлять оценки — чем больше оценок, тем проще дать точную рекомендацию. Можно учитывать заказы товаров (раз купил, значит высоко оценил). Только будьте очень осторожны, чтобы избежать всевозможных домыслов. Пожалуйста, постоянно проверяйте полученные данные сериями экспериментов (A/B тестов).

Не хочу напоминать очевидных вещей, но мнение большинства людей не всегда объективно верное. Например, может быть очень красивая девушка 25 лет, которая переживает из-за комплексов, возникших у неё в детстве. Некоторые парни могут сильно верить в эффективность НЛП и гипноза, как способов соблазнения девушек. Даже добрая старушка может мазать рану своему внуку спиртовым раствором бриллиантового зелёного, хотя применение мирамистина будет явно разумней. Список можно продолжать очень долго. В идеале, следует добавить ручную фильтрацию заведомо некачественных рекомендаций (если речь идёт об оценивании других сайтов) или ужесточить контроль качества (если оцениваются объекты на вашем сайте).

© Habrahabr.ru