Вступление

Хммм, когда я ищу star, в результатах поиска IMDB нет Звездных войн; эти результаты поиска не ошибочные; но они на самом деле не принимают во внимание мои личные предпочтения в отношении фильмов (это имеет смысл для общей базы данных, такой как IMDB).

Но ради интереса давайте создадим простой поиск, учитывающий личные предпочтения. Если я наберу star, я ожидаю появления Звездных войн или Звездного пути, потому что мне нравятся научно-фантастические сюжеты! Для людей, которые действительно любят фильмы о музыке, вероятно, подойдет Рождение звезды.

Предварительные требования

Вам понадобится Apache Spark, Docker (для запуска Elasticsearch 7.10 и Cerebro) и Python по порядку. следовать. Если они у вас еще не установлены, это не проблема, вы можете просто прочитать. Весь код доступен в репозитории Github.

Получение данных и обучение модели рекомендаций

Во-первых, давайте получим некоторые данные. MovieLens 25M рейтингов фильмов. 25 миллионов оценок (пять звезд) и один миллион приложений с тегами, примененных к 62 000 фильмам 162 000 пользователей. Вы можете найти этот набор данных на сайте MovieLens.

!unzip -o ml-25m.zip

Мы будем использовать pandas и spark для загрузки фильмов и оценок в блокнот Jupyter.

import findspark
findspark.init()
from pyspark.sql import SparkSession
import pandas as pd
spark = SparkSession.builder.appName('recommender').getOrCreate()
# load movies and extract the year as new column
movies = pd.read_csv('ml-25m/movies.csv',',', engine='python')
movies['year'] = movies['title'].str.extract('\(([0-9]{4})\)', expand=False).str.strip()
# load ratings
ratings = spark.read.options(inferSchema=True, header=True) \
     .csv('ml-25m/ratings.csv')
# create a training and validation set
(training, validation) = ratings.randomSplit([0.8, 0.2])

После загрузки данных мы можем начать построение модели рекомендаций фильма для пользователей, используя матричную факторизацию с чередующимися наименьшими квадратами (ALS) (ссылка). Такие алгоритмы матричной факторизации работают путем разложения матрицы взаимодействия на произведение двух матриц уменьшенной размерности. Один представляет скрытые факторы фильмов, а другой - скрытые факторы пользователей. Проблема с этими методами - разреженность матрицы взаимодействия. Не все пользователи будут оценивать все фильмы, наоборот, более вероятно, что означает, что большинство значений матрицы взаимодействия неизвестны. Цель состоит в том, чтобы заполнить недостающие записи матрицы ассоциации пользователь-элемент, перебирая возможные скрытые факторы для фильмов и пользователей и проверяя ошибку между истинной оценкой и прогнозируемой оценкой в ​​конце каждого шага.

als = ALS(maxIter=10, regParam=0.05, rank=48, userCol="userId", itemCol="movieId", ratingCol="rating",
          coldStartStrategy="drop")
model = als.fit(training)
itemfactors = spark.createDataFrame(model.itemFactors.rdd)

По умолчанию рейтинг (т.е. количество скрытых факторов) равен 10. Более скрытый фактор (т.е. увеличение размерности) позволит добавить больше нюансов в персонализацию; но будьте осторожны с переоснащением, если ставите его слишком высоко. Чтобы определить лучший rank и параметр регуляризации (regParam), был выполнен быстрый поиск по сетке, чтобы выбрать лучшую модель на основе RMSE данных проверки. Это дало ранг 48 и параметр регуляризации 0,05. Поиск по сетке плюс обучение заняли около 1 часа на Блокноте Google Colab. (Хороший стартер для установки Apache Spark здесь)

Теперь, когда у нас есть скрытые факторы для каждого фильма и каждого пользователя (вектор из 48 чисел с плавающей запятой), мы можем начать рекомендовать!

Хорошая рекомендация (фильм для фильма, фильм для пользователей) определяется углом между их соответствующими скрытыми векторами (то есть скалярным произведением). Оранжевая стрелка на рисунке выше представляет случайного пользователя; фиолетовые векторы связаны с тремя разными фильмами. Первый фильм (слева) явно представляет собой фильм, который разделяет огромное количество свойств с предпочтениями пользователя. Фильм (посередине) противоположен тому, что нравится пользователю, скорее всего, плохая рекомендация. Последний фильм (справа) на самом деле не разделяет с пользователем какие-либо свойства; Невозможно сказать, понравится ли этот фильм пользователю.

Загрузка данных в Elasticsearch и запрос

Во-первых, нам нужен работающий Elasticsearch. Я использую для этого Docker. Идеально!

# This script pulls the elasticsearch:7.10.0 docker container and runs the database
docker pull docker.elastic.co/elasticsearch/elasticsearch:7.10.0
docker run -d -p 9200:9200 -p 9300:9300 -e "discovery.type=single-node" docker.elastic.co/elasticsearch/elasticsearch:7.10.0

Сначала нам нужно создать новый индекс для наших данных, задав свойства индекса, доступные анализаторы (анализ) и отображение (ссылка).

Анализ определяет, как данные должны обрабатываться в Elasticsearch; например удалить стоп-слова, токенизировать, остановить и т. д.

Сопоставление - это процесс определения того, как документ хранится и индексируется в Elasticsearch.

  • какие строковые поля следует рассматривать как полнотекстовые и как их индексировать
  • какие поля содержат числа, даты или векторы.

Поскольку это локальная настройка, мы создадим 1 осколок и 0 реплик. Мы предполагаем, что набор данных состоит в основном из названий фильмов на английском языке, поэтому мы собираемся использовать английский стеммер и удалить некоторые распространенные английские стоп-слова для нашей поисковой системы. Мы также добавим фильтр нижнего регистра, чтобы избежать поиска с учетом регистра. Скрытые векторы хранятся в поле dense_vector с 48 размерами.

from elasticsearch import Elasticsearch
es_client = Elasticsearch(http_compress=True)
index_name = "movielens"
try:
    es_client.indices.delete(index=index_name)
except Exception as e:
    print(e)
index_body = {
      'settings': {
        'number_of_shards': 1,
        'number_of_replicas': 0,
        'analysis': {
          "filter":{  
            "english_stop":{
              "type":"stop",
              "stopwords":"_english_"
            },
            "english_stemmer":{
              "type":"stemmer",
              "language":"english"
            }
          },  
          "analyzer": {
            "stem_english": { 
              "type":"custom",
              "tokenizer":"standard",
              "filter":[
                "lowercase",
                "english_stop",
                "english_stemmer"
              ]
            }
        }
      }},
      'mappings': {
          'properties': {
            'title': {
                'type': 'text',
                'analyzer': 'standard', 
                'fields': {
                  'english': {
                    'type':     'text',
                    'analyzer': 'stem_english' 
                  }
                }
            },
            'year':  {'type': 'integer'},
            "profile_vector": {
              "type": "dense_vector",
              "dims": 48
            }
          }
      }
    }
es_client.indices.create(index=index_name,body=index_body)

модуль Python Elasticsearch поставляется с некоторыми вспомогательными методами, которые упрощают вставку документа в Elasticsearch.

from elasticsearch import helpers
import uuid
items_frame = itemfactors.select('id','features').toPandas().rename(columns={"id": "movie_id", "features": "features"})
# join this with the original dataframe
db_movies = movies.merge(items_frame, left_on='movieId', right_on='movie_id')
# create a dataset for Elasticsearch
es_dataset = [{"_index": index_name, "_id": uuid.uuid4(), "_source" : {"title": doc[1]["title"], "profile_vector": doc[1]["features"]}} for doc in db_movies.iterrows()]
#bulk insert them
helpers.bulk(es_client, es_data)

Пора написать запрос Elasticsearch; Помните, что мы хотим учитывать предпочтения пользователей; но! Результаты по-прежнему должны учитывать поисковые запросы! (В случае введения примера все результаты должны содержать термин star в заголовке. После того, как эти совпадения будут найдены, мы собираемся пересчитать эти результаты. Этот скриптовый запрос восстановления займет ваш личный профиль (в форме скрытого вектор, который лучше всего описывает предпочтения пользователей). Этот сценарий использует `cosineSimilarity`, действительно определяет хорошие рекомендации (имейте в виду, что Elasticsearch не допускает отрицательных оценок, поэтому + 1.0). Для окончательной оценки 90% веса приходится на косинусное сходство и 10% с исходной оценкой, основанной только на запросе. (в зависимости от варианта использования это значение определенно требует дополнительной настройки)

{
  "size": 100,
  "query": {
    "match": {
      "title.english": {
        "operator": "or",
        "query": "star"
      }
    }
  },
  "rescore": {
    "window_size": 500,
    "query": {
      "rescore_query": {
        "script_score": {
          "query": {
            "match_all": {}
          },
          "script": {
            "source": "(cosineSimilarity(params.query_vector, 'profile_vector') + 1.0)",
            "params": {
              "query_vector": [PUT LATENT FACTORS]
            }
          }
        }
      },
      "query_weight": 0.1,
      "rescore_query_weight": 0.9
    }
  },
  "_source": {
    "include": [
      "title"
    ]
  }
}

Полученные результаты

Давай зададим несколько вопросов. В первом случае пользователю, отправившему запрос, нравятся такие фильмы, как Лак для волос, La La Land и Богемская рапсодия.

Если этот запрос повторить с тем же набором данных кем-то, кто действительно увлекается научно-фантастическими фильмами, результаты будут совсем другими! Первые 3 результата - это все фильмы по «Звездным войнам»! Ага, как я и хотел.