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


Отдельно хочу сказать, что это мой первый опыт создания более-менее серьезного проекта в сфере Data Science, поэтому если кто-то из более опытных коллег увидит, что еще можно улучшить, то буду только рад за советы.

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

Надо сказать, что в данном проекте у меня не было возможности делать интеграцию с бэкэндом интернет-магазина - классическая история для малых и средних интернет-магазинов. Необходимо было рассчитывать только на систему, которую я сделаю в стороне от сайта. Поэтому в качестве визуального решения на самом сайте я решил сделать всплывающий js-виджет. Одной строчкой в html-код добавляется js, понимает заголовок страницы, на который пришел пользователь, и передает его в бэкэнд сервиса. Если бэкэнд нашел в своей базе заранее загруженных товаров товар, то он ищет опять же в заранее подготовленной базе товаров, рекомендации и возвращает их в js, а js их потом отображает в виджете. Также, для снижения влияния на скорость загрузки, js создает iframe, в котором производит все работы с отображением виджета. Помимо прочего, это еще и позволяет убрать проблему с пересечением css-классов виджета и сайта.

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

А теперь перейдем непосредственно к поиску похожих товаров.

Для данного магазина я сделал два варианта поиска схожих товаров (опять же, классика A/B-тестирования) - рекомендации просто по схожим характеристикам; рекомендации, которые включали в себя первым слоем схожесть изображения, а вторым - схожесть характеристик.

С поиском схожих просто по характеристикам дело ясное. Перейдем к поиску схожих по изображениям.

Для начала подгружаем необходимые библиотеки:

!pip install theano

%matplotlib inline
from keras.models import Sequential
from keras.layers.core import Flatten, Dense, Dropout
from keras.layers.convolutional import Convolution2D, MaxPooling2D, ZeroPadding2D
from keras.optimizers import SGD
import cv2, numpy as np
import os
import h5py
from matplotlib import pyplot as plt

from keras.applications import vgg16
from keras.applications import Xception
from keras.preprocessing.image import load_img,img_to_array
from keras.models import Model
from keras.applications.imagenet_utils import preprocess_input

from PIL import Image
import os
import matplotlib.pyplot as plt
import numpy as np
from sklearn.metrics.pairwise import cosine_similarity
import pandas as pd

import theano
theano.config.openmp = True

Дальше мне необходимо отсортировать изображения (так как они у меня названы исходя из индексов товаров в датафрейме, то я их отсортирую по алфавиту):

import re
def sorted_alphanumeric(data):
    convert = lambda text: int(text) if text.isdigit() else text.lower()
    alphanum_key = lambda key: [ convert(c) for c in re.split('([0-9]+)', key) ] 
    return sorted(data, key=alphanum_key)

dirlist = sorted_alphanumeric(os.listdir('images'))

r1 = []
r2 = []
for i,x in enumerate(dirlist):
    if x.endswith(".jpg"):
        r1.append((int(x[:-4]),i))
        r2.append((i,int(x[:-4])))

extid_to_intid_dict = dict(r1)
intid_to_extid_dict = dict(r2)

Задаем некоторые параметры:

imgs_path = "images/"
imgs_model_width, imgs_model_height = 224, 224

nb_closest_images = 3 # количество ближайших изображений (для тестирования вывода)

Загружаем саму модель (уже предобученную):

vgg_model = vgg16.VGG16(weights='imagenet')

Удаляем последний слой (следний слой дает на выходе вероятности каждого из 1000 классов ImageNet - нам это не нужно в данном случае. Нам нужен просто 4096-мерный вектор для каждого из изображений, по которым мы потом будем искать с помощью простой косинусной метрикой).

Как найти имя слоя — это отдельный анекдот, поэтому я смотрел в исходники модели.

Итак:

feat_extractor = Model(inputs=vgg_model.input, outputs=vgg_model.get_layer("fc2").output)

Если кому интересно, можно вывести на экран список слоев CNN и посмотреть. Но в целом это никак не относится к коду, который ищет схожие изображения:

feat_extractor.summary()

Далее я иду в каталог заранее подготовленных изображений товаров (то есть, я беру xml каталога интернет-магазина, прохожусь по урлам, которые нашел для товаров, и скачиваю их в папку; ниже покажу код, который делает эту работу):

files = [imgs_path + x for x in os.listdir(imgs_path) if "jpg" in x]

print("number of images:",len(files))

Дальше мне необходимо отсортировать изображения по названиям для более аккуратной работы с ними в последующем:

import re

def atof(text):
    try:
        retval = float(text)
    except ValueError:
        retval = text
    return retval

def natural_keys(text):
    '''
    alist.sort(key=natural_keys) sorts in human order
    http://nedbatchelder.com/blog/200712/human_sorting.html
    (See Toothy's implementation in the comments)
    float regex comes from https://stackoverflow.com/a/12643073/190597
    '''
    return [ atof(c) for c in re.split(r'[+-]?([0-9]+(?:[.][0-9]*)?|[.][0-9]+)', text) ]

files.sort(key=natural_keys)

Далее загружаю изображения в специальный PIL формат:

original = load_img(files[1], target_size=(imgs_model_width, imgs_model_height))
plt.imshow(original)
plt.show()
print("image loaded successfully!")

Конвертирую PIL изображения в numpy array:
в PIL формат данных - width, height, channel
в Numpy - height, width, channel

numpy_image = img_to_array(original) # сырое изображения в вектор

Конвертирую изображения в batch format.
expand_dims добавит дополнительное измерение к данным на определенной оси

Мы хотим, чтобы входная матрица в сеть имела следующий формат - batchsize, height, width, channels. Таким образом, мы добавляем дополнительное измерение к оси 0.

image_batch = np.expand_dims(numpy_image, axis=0) # превращаем в вектор-строку (2-dims)
print('image batch size', image_batch.shape)

Подготавливаем изображение для VGG:

processed_image = preprocess_input(image_batch.copy()) #  библиотечная подготовка изображения

Теперь нам необходимо получить как бы особенности данного вектора (вытащить признаки):

img_features = feat_extractor.predict(processed_image)

То есть это будет векторочек признаков для данного изображения:

print("features successfully extracted!")
print("number of image features:",img_features.size)
img_features

Теперь всё тайное стало явью, и осталось дело техники — итерироваться по всем изображениям и для каждого из него произвести векторизацию.

importedImages = []

for f in files:
    filename = f
    original = load_img(filename, target_size=(224, 224))
    numpy_image = img_to_array(original)
    image_batch = np.expand_dims(numpy_image, axis=0)
    
    importedImages.append(image_batch)
    
images = np.vstack(importedImages)

processed_imgs = preprocess_input(images.copy())

Вытащим все особенности изображения:

imgs_features = feat_extractor.predict(processed_imgs)

print("features successfully extracted!")
imgs_features.shape

Ну и дальше посчитаем уже косинусную схожесть между изображениями:

cosSimilarities = cosine_similarity(imgs_features)

Сохраним результат в pandas dataframe:

columns_name = re.findall(r'[0-9]+', str(files))

cos_similarities_df = pd.DataFrame(cosSimilarities, columns=files, index=files)
cos_similarities_df.head()

Дальше получилась такая ситуация. В каталоге товаров данного магазина около 6000 SKU. После всех манипуляций выше получилась матрица размером 6000 * 6000. В каждом пересечении строк и столбцов находилось число формата float от 0 до 1 с 8 знаками после нуля, которое показывало схожесть. Когда я пробовал сохранить эту матрицу как есть в файл для дальнейшего применения в сервисе рекомендаций, то у меня получался файл весом около 430 мегабайт (хотя в оперативной памяти такая матрица занимала около 130 мегайбайт). Для меня это было не приемлемо. Хотя бы по той простой причине, что мне нужно было как-то выкладывать этот файл в GitHub, а дальше автоматома деплоить на сервер. GitHub не позволяет грузить файлы больше 100 мегайбат (или во всяком случае я не знаю как это делать). Да и в целом мне казалось, что это какой-то неприлично большой файл. Поэтому я начал думать :) И придумал вот что - мне по-большому счету не важно сколько знаков после запятой будет здесь - мне главное просто сравнивать цифры между собой. Поэтому я сделал следующее:

cos_similarities_df_2.round(2) # cos_similarities_df_2 - название датафрейма с косинусными метриками, которые сохранил выше

То есть, для начала я просто взял и отсек лишние цифры. Но формат колонки все равно оставался float. А в pandas float может быть минимально float16 - много.

Тогда я решил перевести эти значения в int:

cos_similarities_df_2.apply(lambda x: x * 100)

cos_similarities_df_2.apply(lambda x: x.astype(np.uint8))

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

Ну и дальше я сохранил этот файл в h5:

cos_similarities_df_2.to_hdf('storage/cos_similarities.h5', 'data')

В итоге получился файл весом 40 мегбайт. Это уже, во-первых, удовлетворяло моим требованиям для хранения в GitHub, а во-вторых, в целом уже не так пугало своим размером :)

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

import re

# function to retrieve the most similar products for a given one

def retrieve_most_similar_products(given_img):

    print("-----------------------------------------------------------------------")
    print("original product:")
    original = load_img(given_img, target_size=(imgs_model_width, imgs_model_height))
    original_img = int(re.findall(r'[0-9]+', given_img)[0])
    print((df_items_2.iloc[[original_img]]['name'].iat[0], df_items_2.iloc[[original_img]]['pricer_uah'].iat[0], df_items_2.iloc[[original_img]]['url'].iat[0]))
   
    plt.imshow(original)
    plt.show()

    print("-----------------------------------------------------------------------")
    print("most similar products:")

    closest_imgs = cos_similarities_df[given_img].sort_values(ascending=False)[1:nb_closest_images+1].index
    closest_imgs_scores = cos_similarities_df[given_img].sort_values(ascending=False)[1:nb_closest_images+1]

    for i in range(0,len(closest_imgs)):
        original = load_img(closest_imgs[i], target_size=(imgs_model_width, imgs_model_height))
        item = int(re.findall(r'[0-9]+', closest_imgs[i])[0])
        print(item)
        print((df_items_2.iloc[[item]]['name'].iat[0], df_items_2.iloc[[item]]['pricer_uah'].iat[0], df_items_2.iloc[[item]]['url'].iat[0]))
        plt.imshow(original)
        plt.show()
        print("similarity score : ",closest_imgs_scores[i])

kbr = '' # напишите сюда название товара
find_rec = int(df_items_2.index[df_items_2['name'] == kbr].tolist()[0]) # df_items_2 название моего базового датафрейма, куда я скачал каталог товаров
print(find_rec)

retrieve_most_similar_products(files[find_rec])

Вот и все :)

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

А теперь еще покажу как я спарсил изображения, если это кому-то будет полезно:

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

import os

if not os.path.exists('storage'):
    os.makedirs('storage')

if not os.path.exists('images'):
    os.makedirs('images')

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

А код, который забирает изображения, вот:

# importing required modules
import urllib.request

image_counter = 0

error_list = []

# Функция для загрузки изображения в нужную мне директорию
def image_from_df(row):
    global image_counter
    
    item_id = image_counter
    
    filename = f'images/{item_id}.jpg'
    image_url = f'{row.image}'

    try:
      conn = urllib.request.urlopen(image_url)
       
    except urllib.error.HTTPError as e:

      # Return code error (e.g. 404, 501, ...)
      error_list.append(item_id)

    except urllib.error.URLError as e:

      # Not an HTTP-specific error (e.g. connection refused)
      
      print('URLError: {}'.format(e.reason))


    else:

      # 200
      urllib.request.urlretrieve(image_url, filename)
      image_counter += 1

Теперь я беру мой сформированный датасет товаров из xml, и начинаю идти по каждой строчке и забирать изображения:

df_items_2.apply(lambda row: image_from_df(row), axis=1)

Попадаются товары, у которых нет изображений. Тогда я их просто удаляю из датафрейма и не буду для них делать рекомендации. Обратите на это внимание в своих работах. Я попался на эту тему, так как сначала мне отдавали xml с товарами, которые все имели изображения. А когда после очередного запуска скрипта он упал, то потратил пару часов, чтобы понять, что не все товары имеют изображения и пришлось уже писать как положено для тех случаев, когда не уверен в том, что данные придут как положено.

for i in error_list:

  df_items_2.drop(df_items_2.index[i], inplace = True)
  df_items_2.reset_index(drop=True, inplace = True) 

print(f'Удалил строки без изображений: {error_list}')
print(len(error_list))

Собственно говоря, вот и все. Надеюсь, это кому-то будет полезно! )

А если нет, то не судите строго - хотел как лучше )

P.S. Кстати, недавно открыл для себя следующую вариацию VGG - VGG19. Судя по тестам, эта версия дает еще более лучшие предсказания.

P.S.S Отдельно хочу выразить большую благодарность людям, без которого я бы не смог все это реализовать: это мой брат, Senior JavaScript Developer (помогал мне написать js для сайта и обходить CORS-политики); это Костя Дедищев, Senior Python Developer и Senior Engineer (помогал мне заворачивать все в Docker и настраивать CI/CD pipeline); это Екатерина Артюгина из SkillFactory, которая три месяца возилась со мной и с другими ребятами с рамках SkillFactory Accelerator (это курс я взял специально для того, чтобы создать свой первый реальный Data Science проект под присмотром более опытных ребят); это Marina Shcherbakova, которая придумала курс; это и Валерий Бабушкин (ментор, который помогал понять суть A/B-тестов и включить их в проект); это Валентин Малых (еще один ментор, который помогал с разумением NLP проблем и в частности работы опечаточников при создании чат-ботов (еще второй проект, над которым я работал в рамках акселератора и о котором я может быть расскажу чуть позже); это Эмиль Маггерамов (ментор, который в целом курировал мое продвижение в акселераторе по созданию данного проекта); это одногруппники Valery Kuryshev и Георгий Брегман (регулярно раз в неделю созванивались и делились полученным за неделю опытом).