Systèmes de recommandations : utilisation de la similarité entre les images.


Systèmes de recommandations : utilisation de la similarité entre images

Améliorer vos systèmes de recommandations grâce à l'analyse d'images : Xception et Approximate Nearest Neighbors VS Deep Ranking

Dans tout bon commerce le contact direct avec les acheteurs est un vrai plus pour le vendeur. Celui-ci peut interagir avec son client et lui proposer des produits susceptibles de lui plaire en se basant sur ses goûts ou encore ses expériences passées. Il peut ainsi adapter son discours commercial. Ce rôle qui incombe aux vendeurs dans le cadre d'une boutique physique revient dans le cas du e-commerce aux systèmes de recommandations.

Un bon système de recommandation est donc un programme intelligent qui a pour but de générer plus de visibilité pour les produits, de susciter l’intérêt du client et donc de générer plus de ventes. Aujourd'hui, beaucoup de systèmes de recommandations en ligne se basent encore uniquement sur le texte et la description d'un produit pour en proposer d’autres plus probables d'être achetés. Ce système n'est pas tout à fait au point car il oublie un aspect assez important du produit: l'image qui le représente.

Pour prendre en compte ces aspects visuels comme le style ou le design, nous avons décidé au sein du Lab Inovation de Ciblo d’intégrer l'analyse d'images de produits, plus précisément ajouter la similarité entre images dans nos algorithmes de systèmes de recommandations.

Nous avons donc testé deux principales méthodes:

  • La première consiste à utiliser un réseau de neurones artificiels pré-entrainés (en l'occurrence Xception) pour l'extraction de features, features auxquelles on a appliqué ensuite un Approximate Nearest Neighbors.
  • La seconde consiste à appliquer l'algorithme de Deep Ranking, dont vous trouverez l'article de recherche ici.

Dans cette article nous allons donc expliquer chacune de ces méthodes et comparer les résultats obtenus. Notons que nous avons utilisé Python 3.6, Keras et tensorflow.

Première méthode : Xception et ANN

Etape 1 : Extraction de features

Dans cette première partie, nous parlerons brièvement des architectures de réseau comme Xception incluses dans la bibliothèque de Keras et entrainées sur les images d'ImageNet. Nous allons ensuite créer un script Python personnalisé en utilisant Keras qui peut charger ces architectures réseau pré-entrainés et qui nous permettront d'atteindre notre but.

Qu'est ce qu'ImageNet?

ImageNet est un projet mené par la société du même nom qui consiste à (manuellement) annoter et catégoriser des images en près de 22 000 catégories d'objets distincts à destination de la recherche en vision par ordinateur. Mais quand on parle d'ImageNet dans le context d'apprentissage, on fait en fait référence au challenge ImageNet Large Scale Visual Recognition Challenge.

Ce challenge a pour but la construction d'un modèle capable de classifier une image donnée en entrée en plus de 1.000 catégories d'objets distincts.

"apple"

Ainsi lorsqu'on disait plus haut que les réseaux cités sont entrainés sur les images d'ImageNet, on parle du dataset fourni pour le challenge et qui contient plus de 1,2 millions d'images, auxquelles on peut ajouter 50.000 images pour la validation et 100.000 images pour le dataset test.

Ces images représentent des objets du quotidien comme des voitures, des animaux, de la nourriture et un tas d'autres objets que l'on peut rencontrer/utiliser dans la vie.

Il faut savoir que depuis 2012, le classement de ce défi a été dominé par les réseaux de neurones convolutifs. Keras a donc mis à notre disposition plusieurs modèles, accompagnés de leur poids et en accès direct. Ils disposent tous d'une architecture différente, voici un récapitulatif de leurs performances:

"result_model"

Xception, un modèle proposé par François Chollet le créateur de Keras lui-même, est le moins lourd et obtient des résultats plus que convaincants. C'est donc avec lui que nous avons décidé de travailler.

En ce qui concerne son architecture, il s'agit en fait d'une extension du modèle Inception avec quelques changements. La voici:

"archi"

Maintenant que nous avons notre modèle pré-entrainé, nous pouvons commencer notre extraction de features. Une question doit tout de même vous tracasser, ces modèles répondent à la base à un challenge de classification alors que nous, nous cherchons à extraire des vecteurs caractéristiques de nos images. Ne vous inquiétez donc pas, ces modèles peuvent être utilisés pour la classification mais aussi pour l'extraction de caractéristiques.

Ce procédé se nomme le Transfert Learning : Nous utilisons un modèle pré-entrainé comme mécanisme d'extraction de features. Pour cela nous pouvons supprimer la couche de sortie (celle qui donne les probabilités d'être dans chacune des 1.000 classes) et nous arrêter aux vecteurs de dimensions 2048.

Notons aussi que les réseaux démontrent une forte capacité à généraliser aux images en dehors de l'ensemble de données ImageNet via Transfert Learning, cela tombe bien, nous avons plusieurs e-commerces à traiter et leur produits diffèrent d'un site à un autre.

Grâce à Keras, qui a entièrement intégré dans son noyau les modèles pré-entrainés, il devient très facile d'avoir nos features. Allons-y.

In [ ]:

# Importons les packages utilisés
import os
import pickle
import numpy as np
from keras.models import Model
from keras.preprocessing.image import load_img
from keras.preprocessing.image import img_to_array
from keras.applications.xception import Xception, preprocess_input

In [ ]:

# Load Xception

base_model = Xception(weights='imagenet')
model = Model(input=base_model.input,
              output=base_model.get_layer('avg_pool').output)

Après avoir importé les librairies nécessaires, nous avons chargé le modèle. Notons que les poids sont téléchargés automatiquement lors de sa première instanciation. Nos images se trouvent dans un répertoire portant le nom du site auxquelles elles appartiennent. Nous devons de plus, pour chacune d'entre elles appliquer un pré-processing, le même processing subit par les images d'ImageNet sur lesquels a été entrainé le modèle. Après cette étape, nous pouvons les "faire passer" dans le modèle.

In [ ]:

list_site = ["exemple-de-site-client", "un-autre-exemple"]
for site in list_site:
    features = {}
    path_r = "./" + site + "/images"
    liste_images = os.listdir(path_r)
    for image in liste_images:
        image = path_r + "/" + image
        image_v = load_img(image, target_size=shape)
        image_v = img_to_array(image_v)
        image_v = np.expand_dims(image_v, axis=0)
        image_v = preprocess(image_v)
        feature = model.predict(image_v)
        features[os.path.basename(
                    image).split('.')[0]] = np.squeeze(feature.reshape((-1,1)))
    with open("./" + site + "/features.pkl", 'wb') as f:
        pickle.dump(features, f)
    print("Features enregistrés!")

Pour chaque image nous obtenons donc un vecteur représentatif de ses caractéristiques, nos features. Nous enregistrons à la fin les résultats dans un pickle. Et voila! Nous avons nos features.

Etape 2 : Approximate Nearest Neighbors

Notre but à présent est d'avoir pour une image de produit, la liste des 10 produits les plus similaires (par rapport à leur images). Une bonne façon d'obtenir ce résultat est d'utiliser la bibliothèque Approximate Nearest Neighbours Oh Yeah d'Erik Bern pour identifier les voisins les plus proches de chaque vecteur d'image.

In [ ]:

import pickle
import pandas as pd
from scipy import spatial
from annoy import AnnoyIndex


def similarity(extracted_features):
    """
    similarity return a vector of similar images for each row of the input

    """
    feature_dimension = 2048
    n_nearest_neighbors = 10
    trees = 10000
    t = AnnoyIndex(feature_dimension)
    for index, key in extracted_features.items():
        t.add_item(int(index), key)
    t.build(trees)
    result = []
    result_with_similarity = []
    list_index = []
    for index, key in extracted_features.items():
        list_index.append(index)
        nearest_neighbors = t.get_nns_by_item(int(index), n_nearest_neighbors)
        result.append(nearest_neighbors)
        nearest_similarity = []
        for i in nearest_neighbors:
            neighbor_vector = extracted_features[str(i)]
            similarity = 1 - spatial.distance.cosine(key,
                                                     neighbor_vector)
            rounded_similarity = int((similarity * 10000)) / 10000.0
            nearest_similarity.append({
                    'product': i,
                    'similarity': rounded_similarity})
        result_with_similarity.append(nearest_similarity)
    result = pd.DataFrame(result, index=list_index)
    result_with_similarity = pd.DataFrame(result_with_similarity,
                                          index=list_index)
    return result, result_with_similarity

Cette fonction nous rendra alors deux matrices, comme nous voulons comparer nos résultats avec les résultats du Deep Ranking, c'est uniquement la 2eme matrice qui nous intéresse. Dans celle-ci on retrouve pour chaque produit (en ligne) les 10 produits similaires (en colonne) accompagnés d'un score de similarité compris en 0 et 1.

Résultats

Essayons un peu de voir les résultats de cette méthode sur un jeu de données du site bga-vetements, un site qui propose à la vente un large choix d'habillement professionnel et des accessoires de travail.

  • Exemple 1
Produit original
Produit Original
Résultats des 5 produits les plus similaires (Xception + ANN)
Résultats_Xception_+_ANN
  • Exemple 2
Produit original
Produit Original
Résultats des 5 produits les plus similaires (Xception + ANN)
Résultats_Xception_+_ANN
  • Exemple 3
Produit original
Produit Original
Résultats des 5 produits les plus similaires (Xception + ANN)
Résultats_Xception_+_ANN

Les résultats sont assez satisfaisants. Comparons maintenant ces résultats avec la seconde méthode.

Seconde méthode : Deep Ranking

Le modèle Deep Ranking ?

Le deep ranking est un modèle qui va prendre en entrée un triplet d'images et se baser sur celui-ci pour apprendre la similarité entre les images. Pour plus de détails vous pouvez retrouver la publication originale ici.

Un triplet d'images est constitué de l'image original appelée image "query", d'une image très similaire à cette image, dite "image positive" et d'une image non similaire, dite "image négative".

Triplet example

Dans l'exemple ci-dessus tiré de la publication originale, chaque colonne représente un triplet. Les lignes correspondent respectivement à une image "query", à une image positive et à une image négative, où l'image positive est plus similaire à l'image query que l'image négative.

Dans notre cas, nous n'allons pas suivre la même architecture du modèle expliquée dans la publication originale, celle-ci est bien trop coûteuse niveau mémoire. Heureusement pour nous, l'architecture est assez "modulable" pour changer l'algorithme de base, et pour cela nous allons nous baser sur les changements apportés par Akarsh Zingade dans son très bon article que vous pouvez retrouver ici.

Voici un extrait de notre implémentation :

In [ ]:

from __future__ import absolute_import
from __future__ import print_function
from keras.layers import *
from keras.models import Model
from keras.optimizers import SGD
from keras.preprocessing.image import ImageDataGenerator
from skimage import transform
import tensorflow as tf
from keras.backend.tensorflow_backend import set_session
from keras import objectives
from keras import backend as K
from keras.models import Sequential
from keras.layers import Dense, Dropout, Flatten
from keras.layers import Conv2D, MaxPooling2D
from skimage.transform import resize
from keras.layers import Embedding
from keras.applications.xception import Xception, preprocess_input
from Deep_Ranking.ImageDataGeneratorCustom import ImageDataGeneratorCustom

def convnet_model_():
    xception_model = Xception(weights='imagenet', include_top=False)
    x = xception_model.output
    x = GlobalAveragePooling2D()(x)
    x = Dense(2048, activation='relu')(x)
    x = Dropout(0.6)(x)
    x = Dense(2048, activation='relu')(x)
    x = Dropout(0.6)(x)
    x = Lambda(lambda  x_: K.l2_normalize(x,axis=1))(x)
    convnet_model = Model(inputs=xception_model.input, outputs=x)
    return convnet_model


def deep_rank_model():
    convnet_model = convnet_model_()
    first_input = Input(shape=(299,299,3))
    first_conv = Conv2D(96, kernel_size=(8, 8),strides=(16,16), padding='same')(first_input)
    first_max = MaxPool2D(pool_size=(3,3),strides = (4,4),padding='same')(first_conv)
    first_max = Flatten()(first_max)
    first_max = Lambda(lambda  x: K.l2_normalize(x,axis=1))(first_max)
    second_input = Input(shape=(299,299,3))
    second_conv = Conv2D(96, kernel_size=(8, 8),strides=(32,32), padding='same')(second_input)
    second_max = MaxPool2D(pool_size=(7,7),strides = (2,2),padding='same')(second_conv)
    second_max = Flatten()(second_max)
    second_max = Lambda(lambda  x: K.l2_normalize(x,axis=1))(second_max)
    merge_one = concatenate([first_max, second_max])
    merge_two = concatenate([merge_one, convnet_model.output])
    emb = Dense(2048)(merge_two)
    l2_norm_final = Lambda(lambda  x: K.l2_normalize(x,axis=1))(emb)
    final_model = Model(inputs=[first_input, second_input, convnet_model.input], outputs=l2_norm_final)
    return final_model


class DataGenerator(object):
    def __init__(self, params, target_size=(299, 299)):
        self.params = params
        self.target_size = target_size
        self.idg = ImageDataGeneratorCustom(**params)

    def get_train_generator(self, batch_size):
        return self.idg.flow_from_directory("./bga-vetements/images_all1/",
                                            batch_size=batch_size,
                                            target_size=self.target_size,
                                            shuffle=False,
                                            triplet_path='./bga-vetements/triplet9Sample_bga-vetements.txt',
                                            classes=["images"])

    def get_test_generator(self, batch_size):
        return self.idg.flow_from_directory("./bga-vetements/images_all1_val",
                                            batch_size=batch_size,
                                            target_size=self.target_size,
                                            shuffle=False,
                                            triplet_path='./bga-vetements/triplet9Sample_bga-vetements_val.txt')

_EPSILON = K.epsilon()
batch_size = 3
batch_size *= 3

def _loss_tensor(y_true, y_pred):
    y_pred = K.clip(y_pred, _EPSILON, 1.0-_EPSILON)
    loss = tf.convert_to_tensor(0, dtype=tf.float32)
    g = tf.constant(1.0, shape=[1], dtype=tf.float32)
    for i in range(0, batch_size, 3):
        try:
            q_embedding = y_pred[i+0]
            p_embedding = y_pred[i+1]
            n_embedding = y_pred[i+2]
            D_q_p = K.sqrt(K.sum((q_embedding - p_embedding)**2))
            D_q_n = K.sqrt(K.sum((q_embedding - n_embedding)**2))
            loss = (loss + g + D_q_p - D_q_n)
        except:
            continue
    loss = loss/(batch_size/3)
    zero = tf.constant(0.0, shape=[1], dtype=tf.float32)
    return tf.maximum(loss, zero)

def run_model(nb_image, nb_image_val, train_epocs, model_path):
    deepranking_model = deep_rank_model()
    dg = DataGenerator({
        "rescale": 1. / 255,
        "horizontal_flip": True,
        "vertical_flip": True,
        "zoom_range": 0.2,
        "shear_range": 0.2,
        "rotation_range": 30, "fill_mode": 'nearest'}, target_size=(299, 299))
    train_generator = dg.get_train_generator(batch_size)
    val_generator = dg.get_test_generator(batch_size)
    deepranking_model.compile(loss=_loss_tensor, optimizer=SGD(lr=1e-4,
                                                             momentum=0.9,
                                                             nesterov=True))
    train_steps_per_epoch = int(nb_image/(batch_size/3))
    val_steps_per_epoch = int(nb_image_val/(batch_size/3))
    deepranking_model.fit_generator(train_generator,
                                   steps_per_epoch=train_steps_per_epoch,
                                   epochs=train_epocs,
                                   validation_data=val_generator,
                                   validation_steps=val_steps_per_epoch)
    deepranking_model.save_weights(model_path)


if __name__ == "__main__":
    run_model()

En ce qui concerne la génération de triplets, nous ne pouvons utiliser ni la méthode d'Akarash Zingade, ni celle de la publication originale. En effet, l'utilisation de la catégorie de l'image d'origine n'est pas possible dans notre cas. Ce que nous allons faire à la place, est utiliser les résultats obtenus par la méthode ci-dessus (Xception + Ann) pour avoir l'image positive et cela en prenant l'image la plus similaire. Pour l'image négative, on prendra en revanche une image au hasard avec un assez faible taux de similarité (mais pas trop faible non plus!).

Grâce à cette méthode, nous pouvons générer notre fichier de tiplets. Et ce sera ce fichier là que nous allons utiliser pour lancer la fonction get_train_generator de la class DataGenerator dans cette implémentation du Deep Ranking.

Voici des exemples de nos triplets:

Triplet example 1
Triplet example 2
Triplet example 5
Triplet example 3
Triplet example 4
Triplet example 6

Nous pouvons maintenant entrainer notre modèle. Nous avons décidé de le faire tourner sur 100 epoch avec comme première architecture ImageNet (et non VGG comme dans l'implémentation ci-dessus), 9 lignes de triplets pour chaque produit et un batch_size égal à 3 (qu'on multiplie par 3 avant de le faire passer dans le réseau pour une ligne de triplet). Nous pouvons noter en plus qu'une epoch prend pour le nombre d'images que l'on possède, environ 40 minutes, notre réseau à donc mis plus de 66 heures avec une NVIDA GTX 1080, je vous laisse imaginer le temps d'exécution sans GPU...

Résultats

Les résultats de cette méthode (sur le même jeu de données du site bga-vetements) ne sont pas aussi précis que ceux de la première méthode. Même si sur beaucoup d'images nous retrouvons des résultats assez similaires, sur d'autres c'est clairement l'Xception+ANN qui l'emporte.

Prenons le dernière exemple ci-dessus:

Produit original
Produit Original
Résultats des 5 produits les plus similaires (Deep Ranking)
Résultats_DeepRanking

Le modèle nous propose certes des vestes similaires mais au niveau de la matière de la veste, ou encore de son style, la première méthode est bien devant.

Si les résultats ne sont pas aussi bon, plusieurs raisons peuvent expliquer cela : la première est notre nombre de données(bien que conséquent) celui-ci peut être non suffisant pour avoir un bon modèle. N'oublions pas que dans la publication originale le modèle à été entrainé avec beaucoup plus d'images que le notre.

Une autre raison pour expliquer ce résultat peut être la qualité de nos triplets : même si les triplets visualisés au hasard semble être cohérents on ne peut pas tester l'intégralité de nos triplets et donc nous assurer de la similarité entre l'image positive et la query ainsi que la dissimilarité entre l'image négative et l'image query. Pour diminuer cet effet de hasard, nous avons pensé à un moyen alternatif de générer le fichier de triplet, il s'agit de se baser sur la similarité entre les labels (le titre des produits) et non les images pour le générer.

En ce qui concerne les modifications possibles pour le modèle, nous pouvons essayer d'augmenter notre valeur du dropout, ou en ajouter à la fin des 2 petits réseaux, ceci pourra peut-être améliorer les résultats.

Pour finir, dans notre cas nous retiendrons la première méthode ci-dessus. Même si le deep ranking peut encore être amélioré, celui-ci demande beaucoup plus de temps d'exécution et plus d'images sur lesquels s'entrainer or, tous nos clients n'en possèdent pas autant.

Sources