Le moteur de recommandation est au cœur de la stratégie commerciale de tous les géants du e-commerce. Pour preuve, d’après une étude de McKinsey, 35 % des revenus de la branche e-commerce d’Amazon sont générés par son moteur de recommandation. Nous voyons tous les jours les carrousels de produits que nous recommandent les sites sur lesquels nous naviguons.

Nous nous proposons de vous aider à comprendre ce qu’il y a derrière et à fabriquer votre propre moteur. Par essence, un moteur de recommandation fonctionne dans le cadre d’un jeu de données en grande dimension. Nous utiliserons donc un langage adapté à ce type de jeu de données, spark dans sa version pythonesque.

1. Le jeu de données

Nous utiliserons un jeu de données disponible online dans le cadre d’un Kaggle : https://www.kaggle.com/retailrocket/ecommerce-dataset

Ces données illustrent l’activité une plateforme e-commerce sur une période de 4 mois et demi :  propriétés des articles disponibles, catégories des articles, activités des clients…

Le jeu de données est (pour des raisons évidentes) anonymisé tant en termes d’individu qu’en termes de produit. A savoir qu’il est divisé en quatre parties :

Commençons par lire ces fichiers pour effectuer une première analyse :

import pyspark.sql.functions as f

events=spark.read.csv('/events.csv',  header= True, inferSchema = True)

La colonne « timestamp » étant de type long nous allons la convertir en « date ».

events = events.withColumn('date', f.from_unixtime((events.timestamp.cast('bigint')/1000)).cast('timestamp'))

category_tree=spark.read.csv(' /category_tree.csv',  header= True, inferSchema = True)

item_properties_part1=spark.read.csv(' /item_properties_part1.csv', header= True,
                                     inferSchema = True)

item_properties_part2=spark.read.csv('/ item_properties_part2.csv', header= True,
                                     inferSchema = True)

Nous regroupons les item_properties dans le même dataframe :

item_properties = item_properties_part1.union(item_properties_part2)

Il est important d’étudier le jeu de données à notre disposition avant de pouvoir choisir la méthodologie et le type de moteur de recommandation que nous souhaitons.

Quelques statistiques

Nous avons trois types d’events :

  • View quand le client clique sur l’item
  • Addtocart quand l’item est ajouté au panier
  • Transaction quand le client achète l’item

En tout, nous avons 2 756 101 events dont 2 664 312 view, 69 332 addtocart et 22 457 transactions.

Ces events sont réalisés par 1 407 580 visiteurs sur 235 061 items uniques.

Les données s’étendent sur une durée de 4 mois et demi (du 2015-05-03 au 2015-09-18).

Les item_properties contiennent 20 275 902 propriétés uniques décrivant 417 053 items distincts. (Tous les items ne sont donc pas présents dans events).

La présence de la colonne timestamp indique que certaines propriétés changent de valeurs en fonction du temps (exemple prix de l’item).

Categories_tree contient au total 1 669 catégories uniques.

Afin d’avoir un jeu de données utilisable, nous supposons que le chaînage des événements est cohérent. Tous les items achetés ont bien été mis dans le panier à une date antérieure. Et, tous les items mis dans le panier ont bien été vus. Et ceci pour tous les couples visitor_id/item_id.

Ce travail ne sera pas effectué dans cet article mais libre à vous de le faire.

Si on regarde la répartition des visiteurs en nombre de « view » event, on se rend compte que presque la moitié des visiteurs (1 118 109) n’ont visité qu’un seul item.

2. Moteur de recommandation

L’objectif d’un moteur de recommandation est d’augmenter les ventes qui n’auraient pas été effectuées sans ces recommandations. Pourtant, il est très difficile de calculer la performance d’un moteur de recommandation. Si nous recommandons le tome 2 d’Harry Potter à un acheteur du tome 1 sur le carrousel, et que cet acheteur clique et achète le tome 2, cela veut-il dire que le moteur de recommandation fonctionne ? Le client concerné aurait surement acheté l’item seul sans aucune proposition. N’importe quelle métrique de performance comptera cette action comme un cas bien prédit. Mais, dans ce cas précis, les choses ne sont pas toujours évidentes ! Nous discuterons du choix de la métrique de performance à la fin de cet article.

De nombreuses méthodologies existent afin de couvrir toutes les possibilités quand un utilisateur se connecte au site :

  • Il est considéré comme un nouvel utilisateur, soit car c’est un utilisateur dont nous n’avons pas l’identité (sans cookie, sans connexion à son compte, etc), soit c’est un vrai nouvel utilisateur. Nous lui proposerons le top 5 des achats de tous les utilisateurs. (cf 2.1)
  • Il est un utilisateur reconnu mais son historique n’est pas suffisant pour pouvoir déterminer des recommandations pertinentes. Nous allons nous baser sur ces informations personnelles pour lui proposer les items les plus pertinents. Nous ne pouvons pas implémenter ce moteur dans notre cas (par manque d’informations sur les visiteurs). Mais il est important de noter la faisabilité d’une telle approche.
  • Il est un utilisateur identifié qui a un historique suffisant (à définir) pour implémenter une version pertinente d’un moteur de recommandation. Deux choix des plus classiques s’offrent à nous : content based (cf 2.2) ou user based (cf 2.3)

Revenons à nos données. Nous avons à peu près 200 000 items qui ne peuvent pas être recommandés dans le cadre de la méthode user_based (leur page n’a jamais été consultée) alors qu’ils peuvent l’être dans le cas de item_based.

En pratique, on ne peut pas se contenter d’implémenter l’une ou l’autre mais bien de combiner les deux. Nous n’explorerons pas cette voie par la suite.

2.1 « Nouvel » utilisateur

Dans le cas d’un nouvel utilisateur, nous pouvons lui proposer les produits les plus populaires. Plusieurs approches sont possibles :

  • Recommander les items les plus vendus sur toute la durée disponible
  • Recommander les items les plus vendus récemment (dernier mois par exemple)

Passons tout de suite à la pratique et commençons par la première approche.

items_sold = events.where(f.col("event")=="transaction").groupby('itemid').count()
items_sold.select('count').count()
items_sold.sort(f.desc("count"))

Les 10 items les plus vendus sur toute l’étendue du timestamp sont :

Passons maintenant à la deuxième approche. Calculons les 10 items les plus vendus durant le dernier mois.

#top 10 des items les plus vendus le mois dernier :

import datetime

last_month = (datetime. datetime(2015, 9, 18) - datetime.timedelta(days=30)).strftime(format='%Y-%m-%d')
items_recently_sold = events.where(f.col("timestamp")>= last_month).\
            where(f.col("event") == "transaction").groupby('itemid').count()
items_recently_sold.select('count').count()
items_recently_sold.sort(f.desc("count"))

On obtient les items suivants :

Les données étant anonymisées, nous ne pouvons pas réellement évaluer qualitativement ces recommandations.

On constate que les 5 premiers items proposés via la deuxième approche font partis des items les plus achetés sur toute la durée du timestamp. On voit l’apparition de 5 nouveaux items faisant partie des plus grosses ventes sur le dernier mois.

Une autre approche serait de recommander les items qui ont la plus forte croissance de vente.

2.2 Content based

Ce type de moteur de recommandation est basé sur les caractéristiques des items (content based). Typiquement, si vous avez un film avec Denzel Washington, vous aimerez surement un autre film avec Denzel Washington. Pour ce faire, il faut s’intéresser aux propriétés de chaque item. A savoir que pour un certain type de produits, comme les films ou les livres, il est plus facile de calculer ces similarités (même auteur, même réalisateur, mêmes acteurs, mêmes styles de livres ou de films, etc.). Enfin, pour d’autres produits, comme l’électroménager, les calculs sont surement moins pertinents car les propriétés sont moins partageables d’un item à l’autre. 

Dans notre situation, l’anonymisation des propriétés rend difficile l’application de cette méthodologie. Cependant, nous pouvons vous donner les étapes principales :

  • Construire une matrice avec pour lignes, l’itemid et comme colonnes, l’ensemble des valeurs possibles pour les propriétés. Les valeurs dans cette matrice sont des 0 et des 1 indiquant si oui ou non l’itemid possède cette propriété
  • Appliquer une distance du type Jaccard ou Cosinus pour obtenir une matrice de distance entre tous les couples itemid/itemid
  • Sélectionner les 5 items similaires à l’item vu par le visiteur considéré ayant les distances les plus petites.

2.3 User based

Ce type de moteur de recommandation est basé sur les goûts des utilisateurs (collaborative filtering. Typiquement, si le client A aime les items 1, 2, 3 et le client B aime les items 2, 3, 4, alors on va proposer l’item 4 au client A et l’item 1 au client B. Il est important de noter que l’algorithme s’intéresse uniquement au passé et non au contexte.

Plusieurs algorithmes permettent de prédire quels items les clients vont aimer.

Parmi ces algorithmes, la factorisation de matrice (matrix factorization) est une des techniques les plus utilisées.

Cette famille d’algorithmes est devenue largement connue à travers le défi du prix Netflix (https://www.netflixprize.com/) grâce à son efficacité.

L’idée principale est de décomposer la matrice d’observation (user-item rating matrix) en deux matrices de rang plus bas : une représente des caractéristiques de l’utilisateur et l’autre de l’item.

Le produit de ces deux matrices permet de retrouver une bonne approximation de la matrice d’observation user-item au départ.

Principe de factorisation de matrice dans les systèmes de recommandations

Dans la suite, nous utiliserons l’ALS (Alternating Least Squares), une méthode de factorisation de matrice.

Alternating Least Squares (ALS)

Soit R la matrice de notation des items tel que Rij est égale à la note de l’item J attribué par le visiteur I.

R contiendra des éléments vides et le but sera d’estimer la notation de chaque visiteur pour tous les items en se basant sur les notations existantes. ALS tente d’estimer la matrice de R comme le produit de deux matrices de rang inférieur, X et Y, tel que R = X * Yt.

Généralement, ces approximations sont appelées matrices de « facteur ».

L’approche générale est itérative. À chaque itération, l’une des matrices de facteurs est maintenue constante, tandis que l’autre est résolue. La matrice de facteurs nouvellement résolue est ensuite maintenue constante lors de la résolution pour l’autre matrice de facteurs.

Le choix de faire les recommandations à la catégorie ou à l’item dépend des ressources machine dont nous disposons. Appliquons cette méthodologie à nos datasets en recodant un certain nombre de colonnes. Nous présentons une première approche qui peut être améliorée dans le cadre d’un projet d’industrialisation.

  • Tout d’abord, commençons par filtrer les visitors dont l’historique d’events se réduit à la visite d’un item ( 1 118 109 visitors). Ces visitors sont considérés comme nouveaux clients et nous pouvons donc les supprimer de notre dataframe.
  • Dans events, on va transformer les types d’events en score équivalent à l’appréciation de l’item par le visiteur :
    • view : 1
    • addtocart : 10
    • transaction : 30

Les scores ont été choisis de façon arbitraire. Vous pouvez choisir vos propres scores. Pour chaque couple (visitorid, itemid), on garde le max de la valeur.

from pyspark.sql.types import IntegerType
from pyspark.sql import Window
df_nbview = events.where(f.col("event") =="view").\ 
groupby("visitorid").agg(f.countDistinct("itemid")).\
withColumnRenamed("count(DISTINCT itemid)", "nb_views")
df_visitors_to_drop = df_nbview.where(f.col("nb_views")==1).select("visitorid")
events = events.join(df_visitors_to_drop, 
[events_converted.visitorid == df_visitors_to_drop.visitorid], 
how='left_anti')
def convert_event_value(event):
    if event == 'transaction':
        return 30
    if event == 'addtocart':
        return 10
    if event == 'view':
        return 1
    return 0
udf_convert_event = f.udf(convert_event_value, IntegerType())
events = events.withColumn("event", udf_convert_event(f.col("event")))
w = Window.partitionBy(events["visitorid"], events["itemid"])
events = events.withColumn('maxEvent', f.max(f.col("event")).over(w)). \ 
where(f.col("event")==f.col("maxEvent")).\ 
drop(f.col("maxEvent"))
events = events.select('timestamp','itemid', 'visitorid', 'event')
df_final = events.\
    select(f.col('timestamp'),
           f.col('visitorid').cast('int'),
           f.col('itemid').cast('int'),
           f.col('event').cast('int')
          )
  • Maintenant qu’on a notre dataset event prêt, nous allons le splutter en train et en test. Nous splittons avec les proportions 80 % / 20 % en tenant compte du timestamp. Le test contiendra des events ultérieurs à ceux du train.

Un travail supplémentaire est ensuite effectué sur le test set :

  • Les visitors vus dans test et non présents dans le train seront exclus du test et nous leur proposerons les items les plus populaires
  • Les items vus dans test et non présents dans le train seront exclus du test et traités par le Content-Based algorithme

Si on n’exclut pas ces visitors et ces items, nous nous retrouverons avec des Nan lors de la prédiction car ils seront non reconnus par notre modèle.

df_final = df_final.withColumn("rank", f.percent_rank().over(Window.partitionBy().orderBy("timestamp")))

train = df_final.where("rank <= .8").drop("rank")
test = df_final.where("rank > .8").drop("rank")
test= test.\
    join(train.select('visitorid').distinct(), 'visitorid', how='inner').\
    join(train.select('itemid').distinct(), 'itemid', how='inner')

train.cache()
test.cache()
  • On peut maintenant lancer l’algorithme d’ALS (Alternating Least squares) disponible dans pyspark.ml.recommendation.

Une limitation de l’implémentation ALS de Spark MLlib est qu’elle nécessite que les ID des utilisateurs et des éléments soient des entiers 32 bits non négatifs.

Cela signifie que les ID supérieurs à Integer.MAX_VALUE ou 2147483647 ne peuvent pas être utilisés.

Nous devons donc vérifier si cet ensemble de données est conforme à cette condition.

from pyspark.ml.recommendation import ALS

def initModel(inputData):

    als = ALS(maxIter=10, regParam=0.01, userCol="visitorid", itemCol="itemid", ratingCol="event", nonnegative=True)

    return  als.fit(inputData)
    
def predict(model, toPredict):
    return model.transform(toPredict).withColumn('prediction', f.round('prediction'))

model = initModel(training)

predictions = predict(model, test)
predictions.cache()

Nous allons recode la colonne « prediction » pour pouvoir faire une comparaison avec notre test :

  • Si prediction < 1 alors prediction = 1
  • Si prediction < 10 alors prediction = 1
  • Si prediction < 20 alors prediction = 10
  • Sinon prediction = 30
from pyspark.sql.types import FloatType

def convert_predict_value(score):
    
    if score <1.0:
        return 0.0
    
    if score<10.0:
        return 1.0
    
    if score<20.0:
        return 10.0
    return 30.0

convert_score = f.udf(convert_predict_value, FloatType())

predictions = predictions.withColumn("score", convert_score(f.col("prediction")))

Passons à la partie la plus intéressante mais sûrement la plus difficile, l’analyse des résultats.

En pratique, effectuer un A/B testing live permettant à vos utilisateurs d’expérimenter votre moteur et, à vous, d’évaluer votre moteur de recommandation. A travers le Taux De Clics (CTR) et le Taux de Conversion Rate (CR) des recommandations effectuées, vous pouvez déterminer si l’apport de votre moteur est substantiel.

Malheureusement, nous ne sommes pas dans ce cas et nous devons trouver des méthodes pour évaluer notre moteur. Commençons par décrire nos résultats :

  • 68.19 % de bonnes recommandations.
  • 94 % de bonnes prédictions sur tous les events de type « addtocart » et « transaction »

Pouvons-nous considérer que notre moteur performe de manière satisfaisante ?

A première vue, oui, le taux de bonnes recommandations est plutôt satisfaisant, même si le déséquilibre majeur entre nos différents events est problématique.

D’autres mesures de performances, telle que le Recall ou le RMSE (si nous avions laissé les valeurs prédites telles qu’elles) pourraient nous permettre de mieux comprendre ces résultats.

Pour finir, il nous parait important de proposer un certain nombre de pistes d’améliorations soit du côté construction du modèle soit du côté métier :

  • Nous avons choisi de garder les différents types d’événements. Nous aurions pu garder uniquement les views et construire un moteur de recommandation sur ce type d’événements.
  • En gardant tous les événements, nous aurions pu oversampler la classe minoritaire (les événements « transaction ») ou undersampler la classe majoritaire (les événements « view ») nous permettant de mieux différencier ces types d’événements
  • Nous avons construit notre ensemble de test et de train sous une contrainte temporelle. Le moteur de recommandation doit pourtant continuer à apprendre au cours du temps. Les informations récoltées pour chaque utilisateur au cours du temps doivent servir à mieux prédire les futures actions. Ici, nous traitons toutes les actions dans l’ensemble de test de la même façon. Nous devrions mettre à jour le moteur pour les utilisateurs ayant effectué de nouvelles actions pour prédire les prochaines actions. De cette façon, nous augmenterons nos chances de bonnes recommandations.

Conclusion

Pour conclure, il est toujours difficile d’estimer la réussite dans le domaine de la mesure de performance (ou dans la publicité, par exemple), car les actions que nous ayons essayé de provoquer n’ont pas toujours des conséquences directes.