Systèmes de recommandation de cours

Approches content-based, clustering et filtrage collaboratif

Auteur·rice

Mehdi Khamassi

Date de publication

26 août 2026

Introduction

Les systèmes de recommandation cherchent à estimer, pour un utilisateur donné, les éléments les plus susceptibles de correspondre à ses préférences. Dans cette étude, les éléments recommandés sont des cours en ligne et les signaux disponibles combinent des caractéristiques de contenu (genres de cours), des profils utilisateurs et un historique d’interactions utilisateur–cours.

L’étude poursuit deux objectifs complémentaires :

  1. construire plusieurs familles de recommenders et expliciter leur formulation mathématique ;
  2. comparer quantitativement les modèles de filtrage collaboratif sur un protocole expérimental commun.

Les méthodes étudiées sont organisées en deux grandes familles :

  • méthodes basées sur le contenu : profil utilisateur, similarité de Jaccard et clustering de profils ;
  • filtrage collaboratif : KNN, factorisation matricielle NMF et réseau neuronal à embeddings.

La première famille vise principalement la pertinence et la couverture des recommandations. La seconde permet en plus une comparaison prédictive directe à l’aide du RMSE et du MAE.

1. Environnement de calcul et bibliothèques

L’ensemble des dépendances est importé une seule fois au début du document afin de garantir une exécution séquentielle et reproductible du notebook.

Les bibliothèques utilisées couvrent :

  • la manipulation des données avec Pandas et NumPy ;
  • la visualisation avec Matplotlib, Seaborn et wordcloud ;
  • le clustering et les métriques avec scikit-learn ;
  • le filtrage collaboratif KNN/NMF avec Surprise ;
  • le réseau neuronal à embeddings avec TensorFlow/Keras.
# ============================================================
# Core scientific Python stack
# ============================================================

import numpy as np
import pandas as pd

# ============================================================
# Visualization
# ============================================================

import matplotlib.pyplot as plt
import seaborn as sns

from matplotlib.patches import (
    FancyArrowPatch,
    FancyBboxPatch,
)

from wordcloud import (
    STOPWORDS,
    WordCloud,
)

# ============================================================
# scikit-learn
# ============================================================

from sklearn.cluster import KMeans
from sklearn.metrics import (
    mean_absolute_error,
    mean_squared_error,
    silhouette_score,
)
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import (
    LabelEncoder,
    normalize,
)

# ============================================================
# Collaborative filtering with Surprise
# ============================================================

from surprise import (
    Dataset,
    KNNBasic,
    NMF,
    Reader,
    accuracy,
)

# ============================================================
# Neural network / embeddings
# ============================================================

import tensorflow as tf

from tensorflow import keras
from tensorflow.keras import layers

# ============================================================
# Utils
# ============================================================

from typing import cast

Dépendances optionnelles

Si l’environnement Python ne contient pas encore les bibliothèques spécifiques au projet, elles peuvent être installées avec :

pip install pandas numpy matplotlib seaborn scikit-learn wordcloud scikit-surprise tensorflow

Pour la reproductibilité des expériences numériques, une graine aléatoire commune sera utilisée dans les sections concernées :

RANDOM_STATE = 42

np.random.seed(RANDOM_STATE)
tf.random.set_seed(RANDOM_STATE)

2. Cadre mathématique et notations

On note :

  • \(U=\{u_1,\ldots,u_m\}\) l’ensemble des utilisateurs ;
  • \(I=\{i_1,\ldots,i_n\}\) l’ensemble des cours ;
  • \(R=(r_{ui})\in\mathbb{R}^{m\times n}\) la matrice utilisateur–cours ;
  • \(\mathbf g_i\in\{0,1\}^p\) le vecteur de genres du cours \(i\) ;
  • \(I_u\subset I\) l’ensemble des cours observés pour l’utilisateur \(u\).

La matrice \(R\) est généralement creuse : seule une petite partie des couples \((u,i)\) possède une interaction observée. Selon la méthode, on cherche soit à construire un score de recommandation \(s(u,i)\), soit à prédire un rating \(\hat r_{ui}\).

1.1 Deux types d’évaluation

Pour les méthodes content-based, l’analyse porte notamment sur le nombre de cours nouveaux recommandés et sur la fréquence globale des recommandations. Si \(\mathcal R_u\) désigne l’ensemble des cours nouveaux recommandés à \(u\) :

\[ \overline{N}_{rec}=\frac{1}{|U_{test}|}\sum_{u\in U_{test}}|\mathcal R_u|. \]

Pour les modèles prédictifs de filtrage collaboratif, nous utilisons :

\[ RMSE=\sqrt{\frac{1}{N}\sum_{j=1}^{N}(r_j-\hat r_j)^2} \]

et

\[ MAE=\frac{1}{N}\sum_{j=1}^{N}|r_j-\hat r_j|. \]

Le RMSE pénalise davantage les erreurs importantes ; plus ces métriques sont faibles, meilleure est la précision prédictive.

3. Données et analyse exploratoire

Chargement des jeux de données

Le jeu course_genre.csv décrit les cours et leurs catégories, tandis que ratings.csv contient les interactions ou évaluations des utilisateurs.

course_genre_url = (
    "https://cf-courses-data.s3.us.cloud-object-storage.appdomain.cloud/"
    "IBM-ML321EN-SkillsNetwork/labs/datasets/course_genre.csv"
)

ratings_url = (
    "https://cf-courses-data.s3.us.cloud-object-storage.appdomain.cloud/"
    "IBM-ML321EN-SkillsNetwork/labs/datasets/ratings.csv"
)


# ============================================================
# 3. Chargement des datasets
# ============================================================

course_df = pd.read_csv(course_genre_url)
ratings_df = pd.read_csv(ratings_url)


# ============================================================
# 4. Exploration du dataset des cours
# ============================================================

print("Colonnes :")
print(course_df.columns)

print("\nDimensions du dataset :")
print(course_df.shape)

print("\nPremières lignes :")
print(course_df.head())

print("\nTypes des colonnes :")
print(course_df.dtypes)


# ============================================================
# 5. Identification des colonnes de genres
# ============================================================

genres = course_df.columns[2:]

print("\nGenres disponibles :")
print(genres)


# ============================================================
# 6. Comptage du nombre de cours par genre
# ============================================================

genre_counts = course_df[genres].sum(axis=0)

print("\nNombre de cours par genre :")
print(genre_counts)


# ============================================================
# 7. Création et tri du DataFrame des genres
# ============================================================

genre_counts_df = pd.DataFrame(
    genre_counts,
    columns=["Count"]
)

sorted_genre_counts = genre_counts_df.sort_values(
    by="Count",
    ascending=False
)

print("\nGenres triés par fréquence :")
print(sorted_genre_counts)


# ============================================================
# 8. Visualisation
# ============================================================

plt.figure(figsize=(12, 6))

sns.barplot(
    x=sorted_genre_counts.index,
    y="Count",
    data=sorted_genre_counts
)

plt.xticks(rotation=90)
plt.xlabel("Course Genre")
plt.ylabel("Course Count")
plt.title("Course Genre Counts")

plt.tight_layout()
plt.show()

# ============================================================
# 9. Distribution du nombre d'inscriptions par utilisateur
# ============================================================

# Nombre de cours auxquels chaque utilisateur est inscrit
user_enrollments = ratings_df.groupby("user").size()

print(user_enrollments.head())
print(user_enrollments.describe())


# ============================================================
# 10. Histogramme des inscriptions
# ============================================================

plt.figure(figsize=(10, 6))

plt.hist(
    user_enrollments,
    bins=30,
    edgecolor="black"
)

plt.xlabel("Number of courses enrolled")
plt.ylabel("Number of users")
plt.title("Distribution of Course Enrollments per User")

plt.tight_layout()
plt.show()

# ============================================================
# 11. Top 20 des cours les plus populaires
# ============================================================

top_20_courses = (
    ratings_df
    .groupby("item")
    .size()
    .reset_index(name="enrollments")
    .sort_values(
        by="enrollments",
        ascending=False
    )
    .head(20)
)

# ============================================================
# 12. Ajouter les titres aux 20 cours les plus populaires
# ============================================================

top_20_courses_with_title = top_20_courses.merge(
    course_df[["COURSE_ID", "TITLE"]],
    left_on="item",
    right_on="COURSE_ID",
    how="left"
)

top_20_courses_with_title = top_20_courses_with_title[
    ["item", "TITLE", "enrollments"]
]

print(top_20_courses_with_title)

# ============================================================
# 13. Imports pour le word cloud
# ============================================================



# ============================================================
# 14. Récupérer tous les titres dans une seule chaîne de texte
# ============================================================

all_titles = " ".join(course_df["TITLE"].dropna().astype(str))

print(all_titles[:500])  # aperçu optionnel


# ============================================================
# 15. Définir les stopwords
# ============================================================

custom_stopwords = set(STOPWORDS)

# Optionnel : ajouter des mots très fréquents mais peu informatifs
custom_stopwords.update([
    "Introduction",
    "Using",
    "with",
    "and",
    "for",
    "to",
    "in",
    "of"
])


# ============================================================
# 16. Générer le word cloud
# ============================================================

wordcloud = WordCloud(
    width=1200,
    height=600,
    background_color="white",
    stopwords=custom_stopwords,
    collocations=False
).generate(all_titles)


# ============================================================
# 17. Afficher le word cloud
# ============================================================

plt.figure(figsize=(15, 8))
plt.imshow(wordcloud, interpolation="bilinear")
plt.axis("off")
plt.title("Word Cloud of Course Titles")
plt.show()
Colonnes :
Index(['COURSE_ID', 'TITLE', 'Database', 'Python', 'CloudComputing',
       'DataAnalysis', 'Containers', 'MachineLearning', 'ComputerVision',
       'DataScience', 'BigData', 'Chatbot', 'R', 'BackendDev', 'FrontendDev',
       'Blockchain'],
      dtype='str')

Dimensions du dataset :
(307, 16)

Premières lignes :
    COURSE_ID                                              TITLE  Database  \
0    ML0201EN  robots are coming  build iot apps with watson ...         0   
1    ML0122EN                accelerating deep learning with gpu         0   
2  GPXX0ZG0EN  consuming restful services using the reactive ...         0   
3    RP0105EN         analyzing big data in r using apache spark         1   
4  GPXX0Z2PEN  containerizing  packaging  and running a sprin...         0   

   Python  CloudComputing  DataAnalysis  Containers  MachineLearning  \
0       0               0             0           0                0   
1       1               0             0           0                1   
2       0               0             0           0                0   
3       0               0             1           0                0   
4       0               0             0           1                0   

   ComputerVision  DataScience  BigData  Chatbot  R  BackendDev  FrontendDev  \
0               0            0        0        0  0           1            1   
1               0            1        0        0  0           0            0   
2               0            0        0        0  0           1            1   
3               0            0        1        0  1           0            0   
4               0            0        0        0  0           1            0   

   Blockchain  
0           0  
1           0  
2           0  
3           0  
4           0  

Types des colonnes :
COURSE_ID            str
TITLE                str
Database           int64
Python             int64
CloudComputing     int64
DataAnalysis       int64
Containers         int64
MachineLearning    int64
ComputerVision     int64
DataScience        int64
BigData            int64
Chatbot            int64
R                  int64
BackendDev         int64
FrontendDev        int64
Blockchain         int64
dtype: object

Genres disponibles :
Index(['Database', 'Python', 'CloudComputing', 'DataAnalysis', 'Containers',
       'MachineLearning', 'ComputerVision', 'DataScience', 'BigData',
       'Chatbot', 'R', 'BackendDev', 'FrontendDev', 'Blockchain'],
      dtype='str')

Nombre de cours par genre :
Database           60
Python             28
CloudComputing     37
DataAnalysis       56
Containers         17
MachineLearning    69
ComputerVision     10
DataScience        38
BigData            36
Chatbot             4
R                  17
BackendDev         78
FrontendDev        19
Blockchain          4
dtype: int64

Genres triés par fréquence :
                 Count
BackendDev          78
MachineLearning     69
Database            60
DataAnalysis        56
DataScience         38
CloudComputing      37
BigData             36
Python              28
FrontendDev         19
Containers          17
R                   17
ComputerVision      10
Chatbot              4
Blockchain           4

user
2    61
4    44
5    59
7     1
8     3
dtype: int64
count    33901.000000
mean         6.881980
std          5.823548
min          1.000000
25%          2.000000
50%          6.000000
75%          9.000000
max         61.000000
dtype: float64

          item                                         TITLE  enrollments
0     PY0101EN                       python for data science        14936
1     DS0101EN                  introduction to data science        14477
2     BD0101EN                                  big data 101        13291
3     BD0111EN                                    hadoop 101        10599
4     DA0101EN                     data analysis with python         8303
5     DS0103EN                      data science methodology         7719
6   ML0101ENv3                  machine learning with python         7644
7     BD0211EN                          spark fundamentals i         7551
8     DS0105EN  data science hands on with open source tools         7199
9     BC0101EN                         blockchain essentials         6719
10    DV0101EN                data visualization with python         6709
11    ML0115EN                             deep learning 101         6323
12    CB0103EN                        build your own chatbot         5512
13    RP0101EN                            r for data science         5237
14    ST0101EN                                statistics 101         5015
15    CC0101EN                         introduction to cloud         4983
16    CO0101EN   docker essentials  a developer introduction         4480
17    DB0101EN              sql and relational databases 101         3697
18    BD0115EN                            mapreduce and yarn         3670
19    DS0301EN                     data privacy fundamentals         3624
robots are coming  build iot apps with watson  swift  and node red accelerating deep learning with gpu consuming restful services using the reactive jax rs client analyzing big data in r using apache spark containerizing  packaging  and running a spring boot application cloud native security conference  data security data science bootcamp with r for university proffesors learn how to use docker containers for iterative development scorm test course create your first mongodb database testing micr

Représentation des genres

Chaque cours peut être représenté par un vecteur binaire :

\[ \mathbf{g}_i = (g_{i1}, g_{i2}, \ldots, g_{ip}) \]

\(g_{ij} = 1\) si le cours \(i\) appartient au genre \(j\), et \(0\) sinon.

Cette représentation permet de comparer les cours ou de construire des profils utilisateurs.

Distribution des inscriptions

Pour chaque utilisateur \(u\), le nombre de cours suivis est :

\[ n_u = \sum_i \mathbb{1}(u \text{ est inscrit au cours } i) \]

L’histogramme des \(n_u\) permet d’étudier le niveau d’engagement des utilisateurs et de vérifier si la majorité suit peu ou beaucoup de cours.

Cours les plus populaires

La popularité brute d’un cours \(i\) est calculée comme :

\[ P_i = \sum_u \mathbb{1}(u \text{ est inscrit au cours } i) \]

Les vingt cours ayant les plus grandes valeurs de \(P_i\) sont considérés comme les plus populaires dans les données.

Nuage de mots

Le nuage de mots est construit à partir des titres de cours. La taille d’un mot est liée à sa fréquence d’apparition dans l’ensemble des titres.

3. Synthèse exploratoire

L’analyse exploratoire précédente fournit trois informations importantes pour la modélisation :

  • la représentation binaire des genres rend naturelles les mesures ensemblistes telles que Jaccard ;
  • le nombre d’interactions varie d’un utilisateur à l’autre, ce qui motive la normalisation des profils avant clustering ;
  • la forte popularité de certains cours doit être gardée à l’esprit lors de l’interprétation des recommandations, car un modèle peut obtenir une forte couverture en recommandant souvent les mêmes éléments.

4. Méthodes basées sur le contenu et les profils

Ces méthodes utilisent explicitement les caractéristiques des cours ou des utilisateurs. Elles ne nécessitent pas de prédire directement une note numérique pour chaque couple utilisateur–cours.

4.1 Recommandation basée sur le profil utilisateur

Principe

Cette première approche construit pour chaque utilisateur un vecteur de profil représentant son niveau d’intérêt pour les différents genres.

Si l’utilisateur \(u\) a évalué plusieurs cours, son profil peut être construit par une combinaison pondérée des vecteurs de genres :

\[ \mathbf{p}_u = \sum_{i \in I_u} r_{ui}\,\mathbf{g}_i \]

où :

  • \(I_u\) est l’ensemble des cours associés à l’utilisateur \(u\) ;
  • \(r_{ui}\) est la note ou le poids de l’interaction entre \(u\) et le cours \(i\) ;
  • \(\mathbf{g}_i\) est le vecteur de genres du cours.

Une fois le profil obtenu, le score d’un cours candidat \(j\) peut être calculé par produit scalaire :

\[ s(u,j) = \mathbf{p}_u^\top \mathbf{g}_j \]

Plus le score est élevé, plus le cours est compatible avec les préférences estimées de l’utilisateur.

Flowchart

fig, ax = plt.subplots(figsize=(12, 10))

ax.set_xlim(0, 10)
ax.set_ylim(0, 12)
ax.axis("off")


# ------------------------------------------------------------
# Fonction pour créer une boîte
# ------------------------------------------------------------

def add_box(x, y, width, height, text):
    box = FancyBboxPatch(
        (x, y),
        width,
        height,
        boxstyle="round,pad=0.03",
        linewidth=1.5,
        edgecolor="black",
        facecolor="white"
    )

    ax.add_patch(box)

    ax.text(
        x + width / 2,
        y + height / 2,
        text,
        ha="center",
        va="center",
        fontsize=11
    )


# ------------------------------------------------------------
# Fonction pour créer une flèche
# ------------------------------------------------------------

def add_arrow(x1, y1, x2, y2):
    arrow = FancyArrowPatch(
        (x1, y1),
        (x2, y2),
        arrowstyle="->",
        mutation_scale=15,
        linewidth=1.5
    )

    ax.add_patch(arrow)


# ------------------------------------------------------------
# Étape 1 : données d'entrée
# ------------------------------------------------------------

add_box(
    0.8, 10,
    3.2, 1,
    "User Course Ratings"
)

add_box(
    6, 10,
    3.2, 1,
    "Course Genre Vectors"
)


# ------------------------------------------------------------
# Étape 2 : construction du profil utilisateur
# ------------------------------------------------------------

add_box(
    3.4, 7.8,
    3.2, 1.2,
    "Build User Profile Vector\n"
    "(ratings × course genres)"
)

add_arrow(2.4, 10, 4.3, 9)
add_arrow(7.6, 10, 5.7, 9)


# ------------------------------------------------------------
# Étape 3 : calcul des scores
# ------------------------------------------------------------

add_box(
    3.4, 5.7,
    3.2, 1.2,
    "Compute Recommendation Scores\n"
    "(user profile · course genres)"
)

add_arrow(5, 7.8, 5, 6.9)


# ------------------------------------------------------------
# Étape 4 : suppression des cours déjà suivis
# ------------------------------------------------------------

add_box(
    3.4, 3.7,
    3.2, 1,
    "Remove Already-Enrolled Courses"
)

add_arrow(5, 5.7, 5, 4.7)


# ------------------------------------------------------------
# Étape 5 : classement
# ------------------------------------------------------------

add_box(
    3.4, 1.9,
    3.2, 1,
    "Sort Courses by Score"
)

add_arrow(5, 3.7, 5, 2.9)


# ------------------------------------------------------------
# Étape 6 : recommandations finales
# ------------------------------------------------------------

add_box(
    3.4, 0.2,
    3.2, 1,
    "Top-N Recommended Courses"
)

add_arrow(5, 1.9, 5, 1.2)


plt.title(
    "Content-Based Course Recommendation System",
    fontsize=16,
    pad=20
)

plt.tight_layout()
plt.show()

Génération des recommandations

Les cours déjà suivis sont exclus. Un seuil de score permet ensuite de ne conserver que les recommandations suffisamment pertinentes.

profile_genre_url = (
    "https://cf-courses-data.s3.us.cloud-object-storage.appdomain.cloud/"
    "IBM-ML321EN-SkillsNetwork/labs/datasets/user_profile.csv"
)

test_users_url = (
    "https://cf-courses-data.s3.us.cloud-object-storage.appdomain.cloud/"
    "IBM-ML321EN-SkillsNetwork/labs/datasets/rs_content_test.csv"
)

profile_df = pd.read_csv(profile_genre_url)
test_users_df = pd.read_csv(test_users_url)
course_genres_df = pd.read_csv(course_genre_url)


# ============================================================
# 26. Paramètres et préparation
# ============================================================

score_threshold = 10.0

# Liste des utilisateurs à évaluer
test_user_ids = test_users_df["user"].unique()

# Ensemble de tous les cours disponibles
all_courses = set(course_genres_df["COURSE_ID"])


# Colonnes correspondant aux genres
genre_columns = course_genres_df.columns[2:]


# ============================================================
# 27. Génération des scores de recommandation
# ============================================================

def generate_recommendation_scores():

    recommendations = []

    for user_id in test_user_ids:

        # ----------------------------------------------------
        # Récupérer le profil de l'utilisateur
        # ----------------------------------------------------

        user_profile = profile_df[
            profile_df["user"] == user_id
        ]

        if user_profile.empty:
            continue

        # Vecteur des préférences de l'utilisateur
        user_vector = user_profile[
            genre_columns
        ].to_numpy().flatten()


        # ----------------------------------------------------
        # Identifier les cours déjà suivis
        # ----------------------------------------------------

        enrolled_courses = set(
            test_users_df.loc[
                test_users_df["user"] == user_id,
                "item"
            ].tolist() # type: ignore
        )


        # ----------------------------------------------------
        # Identifier les cours inconnus
        # ----------------------------------------------------

        unknown_courses = all_courses.difference(
            enrolled_courses
        )

        unknown_course_df = course_genres_df[
            course_genres_df["COURSE_ID"].isin(
                unknown_courses
            )
        ].copy()


        if unknown_course_df.empty:
            continue


        # ----------------------------------------------------
        # Extraire les vecteurs de genres des cours
        # ----------------------------------------------------

        course_vectors = unknown_course_df[
            genre_columns
        ].to_numpy()


        # ----------------------------------------------------
        # Calculer les scores de recommandation
        # ----------------------------------------------------

        recommendation_scores = np.dot(
            course_vectors,
            user_vector
        )


        # ----------------------------------------------------
        # Conserver uniquement les scores >= seuil
        # ----------------------------------------------------

        for course_id, score in zip(
            unknown_course_df["COURSE_ID"],
            recommendation_scores
        ):

            if score >= score_threshold:

                recommendations.append({
                    "USER": user_id,
                    "COURSE_ID": course_id,
                    "SCORE": score
                })


    return pd.DataFrame(recommendations)


# ============================================================
# 28. Générer les recommandations
# ============================================================

res_df = generate_recommendation_scores()


# ============================================================
# 29. Trier les recommandations
# ============================================================

res_df = res_df.sort_values(
    by=["USER", "SCORE"],
    ascending=[True, False]
).reset_index(drop=True)


res_df.head(20)

# ============================================================
# 30. Average number of unseen courses recommended per user
# ============================================================

recommendations_per_user = (
    res_df
    .groupby("USER")
    .size()
    .reindex(test_user_ids, fill_value=0)
)

average_recommendations = recommendations_per_user.mean()

print(
    f"Average number of new/unseen courses recommended per user: "
    f"{average_recommendations:.2f}"
)

# ============================================================
# 31. Top 10 most frequently recommended courses
# ============================================================

top_10_recommended = (
    res_df
    .groupby("COURSE_ID")
    .size()
    .reset_index(name="RECOMMENDATION_COUNT")
    .sort_values(
        by="RECOMMENDATION_COUNT",
        ascending=False
    )
    .head(10)
)

print(top_10_recommended)
Average number of new/unseen courses recommended per user: 53.41
      COURSE_ID  RECOMMENDATION_COUNT
200    TA0106EN                   608
109   GPXX0IBEN                   548
228  excourse22                   547
227  excourse21                   547
171    ML0122EN                   544
212  excourse06                   533
210  excourse04                   533
137  GPXX0TY1EN                   533
237  excourse31                   524
278  excourse73                   516

Évaluation

Deux indicateurs sont étudiés :

  • le nombre moyen de nouveaux cours recommandés par utilisateur ;
  • les dix cours les plus fréquemment recommandés.

Si \(R_u\) désigne l’ensemble des nouveaux cours recommandés à l’utilisateur \(u\), alors :

\[ \overline{R} = \frac{1}{N} \sum_{u=1}^N |R_u| \]

\(N\) est le nombre total d’utilisateurs du jeu de test.

4.2 Recommandation basée sur la similarité entre cours

Principe général

Au lieu de comparer directement un cours au profil d’un utilisateur, cette approche compare les cours entre eux.

L’idée est la suivante :

  1. représenter chaque cours par l’ensemble de ses genres ;
  2. calculer une similarité cours–cours ;
  3. pour chaque utilisateur, chercher les cours similaires aux cours déjà suivis ;
  4. exclure les cours déjà vus ;
  5. agréger les scores puis classer les candidats.

Flowchart

fig, ax = plt.subplots(figsize=(12, 12))

ax.set_xlim(0, 10)
ax.set_ylim(0, 14)
ax.axis("off")


# ------------------------------------------------------------
# Input data
# ------------------------------------------------------------

add_box(
    1.2, 12.2,
    3.0, 1.0,
    "Course Genre\nVectors"
)

add_box(
    5.8, 12.2,
    3.0, 1.0,
    "User Enrolled\nCourses"
)


# ------------------------------------------------------------
# Build features
# ------------------------------------------------------------

add_box(
    1.2, 10.3,
    3.0, 1.1,
    "Convert Genres to Sets"
)

add_arrow(2.7, 12.2, 2.7, 11.4)


# ------------------------------------------------------------
# Similarity matrix
# ------------------------------------------------------------

add_box(
    1.2, 8.2,
    3.0, 1.2,
    "Compute Jaccard\nSimilarity Matrix"
)

add_arrow(2.7, 10.3, 2.7, 9.4)


# ------------------------------------------------------------
# Candidate retrieval
# ------------------------------------------------------------

add_box(
    5.8, 10.3,
    3.0, 1.2,
    "Get Courses Already\nTaken by User"
)

add_arrow(7.3, 12.2, 7.3, 11.5)


add_box(
    3.5, 6.2,
    3.2, 1.2,
    "Find Similar Courses\nfor Each Taken Course"
)

add_arrow(2.7, 8.2, 4.5, 7.4)
add_arrow(7.3, 10.3, 5.8, 7.4)


# ------------------------------------------------------------
# Aggregate scores
# ------------------------------------------------------------

add_box(
    3.5, 4.4,
    3.2, 1.0,
    "Aggregate Similarity Scores"
)

add_arrow(5.1, 6.2, 5.1, 5.4)


# ------------------------------------------------------------
# Filter enrolled courses
# ------------------------------------------------------------

add_box(
    3.5, 2.8,
    3.2, 1.0,
    "Remove Already-Enrolled Courses"
)

add_arrow(5.1, 4.4, 5.1, 3.8)


# ------------------------------------------------------------
# Rank
# ------------------------------------------------------------

add_box(
    3.5, 1.3,
    3.2, 1.0,
    "Rank Courses by Similarity Score"
)

add_arrow(5.1, 2.8, 5.1, 2.3)


# ------------------------------------------------------------
# Final output
# ------------------------------------------------------------

add_box(
    3.5, 0.1,
    3.2, 0.9,
    "Top-N Recommended Courses"
)

add_arrow(5.1, 1.3, 5.1, 1.0)


plt.title(
    "Course Similarity-Based Recommender System",
    fontsize=16,
    pad=20
)

plt.tight_layout()
plt.show()

Similarité de Jaccard

Pour deux cours \(A\) et \(B\), représentés par leurs ensembles de genres \(G_A\) et \(G_B\), la similarité de Jaccard est définie par :

\[ J(A,B) = \frac{|G_A \cap G_B|}{|G_A \cup G_B|} \]

avec :

\[ 0 \leq J(A,B) \leq 1 \]

  • \(J(A,B)=1\) signifie que les deux cours possèdent exactement les mêmes genres ;
  • \(J(A,B)=0\) signifie qu’ils ne partagent aucun genre.

La distance de Jaccard associée est :

\[ d_J(A,B)=1-J(A,B) \]

Construction de la matrice de similarité

Pour \(m\) cours, on construit une matrice :

\[ S \in \mathbb{R}^{m \times m} \]

telle que :

\[ S_{ij} = J(i,j) \]

La matrice est symétrique et sa diagonale vaut \(1\).

genre_columns = course_genres_df.columns[2:]

print(genre_columns)

# ============================================================
# Build genre sets for each course
# ============================================================

course_genre_sets = {}

for _, row in course_genres_df.iterrows():

    course_id = row["COURSE_ID"]

    genres = {
        genre
        for genre in genre_columns
        if row[genre] == 1
    }

    course_genre_sets[course_id] = genres


# ============================================================
# Jaccard similarity
# ============================================================

def jaccard_similarity(set_a, set_b):

    union = set_a | set_b

    if len(union) == 0:
        return 0.0

    intersection = set_a & set_b

    return len(intersection) / len(union)

# ============================================================
# Build course-course Jaccard similarity matrix
# ============================================================

course_ids = course_genres_df["COURSE_ID"].tolist()

jaccard_matrix = np.zeros(
    (len(course_ids), len(course_ids))
)

for i, course_a in enumerate(course_ids):

    genres_a = course_genre_sets[course_a]

    for j, course_b in enumerate(course_ids):

        genres_b = course_genre_sets[course_b]

        jaccard_matrix[i, j] = jaccard_similarity(
            genres_a,
            genres_b
        )

jaccard_sim_df = pd.DataFrame(
    jaccard_matrix,
    index=course_ids,
    columns=course_ids
)

jaccard_sim_df.head()

course_a = "PY0101EN"
course_b = "ML0101ENv3"

print(course_genre_sets[course_a])
print(course_genre_sets[course_b])

print(
    "Jaccard similarity:",
    jaccard_sim_df.loc[course_a, course_b]
)

# ============================================================
# 1. Generate recommendations from Jaccard similarity matrix
# ============================================================

JACCARD_THRESHOLD = 0.5

test_user_ids = test_users_df["user"].unique()

recommendations = []

for user_id in test_user_ids:

    # --------------------------------------------------------
    # Courses already taken by this user
    # --------------------------------------------------------

    enrolled_courses = set(
        pd.Series(
            test_users_df.loc[
                test_users_df["user"] == user_id,
                "item"
            ]
        )
        .astype(str)
        .tolist()
    )

    # Stores candidate course -> aggregated similarity score
    candidate_scores = {}

    # --------------------------------------------------------
    # Find courses similar to each enrolled course
    # --------------------------------------------------------

    for enrolled_course in enrolled_courses:

        if enrolled_course not in jaccard_sim_df.index:
            continue

        similarities = jaccard_sim_df.loc[enrolled_course]


        similarities = cast(
            pd.Series,
            jaccard_sim_df.loc[enrolled_course]
        )

        similar_courses = similarities[
            similarities >= JACCARD_THRESHOLD
        ]

        for course_id, similarity_score in similar_courses.items():

            # Do not recommend courses already taken
            if course_id in enrolled_courses:
                continue

            candidate_scores[course_id] = (
                candidate_scores.get(course_id, 0.0)
                + similarity_score
            )

    # --------------------------------------------------------
    # Store recommendations
    # --------------------------------------------------------

    for course_id, score in candidate_scores.items():

        recommendations.append({
            "USER": user_id,
            "COURSE_ID": course_id,
            "SCORE": score
        })


jaccard_res_df = pd.DataFrame(recommendations)

jaccard_res_df = jaccard_res_df.sort_values(
    by=["USER", "SCORE"],
    ascending=[True, False]
).reset_index(drop=True)

jaccard_res_df.head(20)

# ============================================================
# 2. Average number of unseen recommended courses per user
# ============================================================

recommendations_per_user = (
    jaccard_res_df
    .groupby("USER")
    .size()
    .reindex(test_user_ids, fill_value=0)
)

average_recommendations = recommendations_per_user.mean()

print(
    "Average number of new/unseen courses recommended per user: "
    f"{average_recommendations:.2f}"
)

# ============================================================
# Top 10 most frequently recommended courses
# ============================================================

top_10_jaccard = (
    jaccard_res_df
    .groupby("COURSE_ID")
    .size()
    .reset_index(name="RECOMMENDATION_COUNT")
    .sort_values(
        by="RECOMMENDATION_COUNT",
        ascending=False
    )
    .head(10)
)


# ============================================================
# Add course titles
# ============================================================

top_10_jaccard = top_10_jaccard.merge(
    course_genres_df[["COURSE_ID", "TITLE"]],
    on="COURSE_ID",
    how="left"
)

top_10_jaccard = top_10_jaccard[
    ["COURSE_ID", "TITLE", "RECOMMENDATION_COUNT"]
]

print(top_10_jaccard)

# ============================================================
# Plot Top 10
# ============================================================

plt.figure(figsize=(11, 6))

sns.barplot(
    data=top_10_jaccard,
    x="RECOMMENDATION_COUNT",
    y="TITLE"
)

plt.xlabel("Number of Recommendations")
plt.ylabel("Course")
plt.title(
    "Top 10 Most Frequently Recommended Courses\n"
    "Jaccard Similarity-Based Recommender"
)

plt.tight_layout()
plt.show()
Index(['Database', 'Python', 'CloudComputing', 'DataAnalysis', 'Containers',
       'MachineLearning', 'ComputerVision', 'DataScience', 'BigData',
       'Chatbot', 'R', 'BackendDev', 'FrontendDev', 'Blockchain'],
      dtype='str')
{'DataScience', 'Python'}
{'Python', 'MachineLearning'}
Jaccard similarity: 0.3333333333333333
Average number of new/unseen courses recommended per user: 154.05
    COURSE_ID                                              TITLE  \
0  excourse63                     a crash course in data science   
1  excourse64                          data science in real life   
2  excourse66                    executive data science capstone   
3     TMP0106                          tmp data science bootcamp   
4      DS0107                          data science career talks   
5    DX0107EN  data science bootcamp with python for universi...   
6    DS0110EN                        data science with open data   
7  excourse62             introduction to data science in python   
8      TMP107                  data science bootcamp with python   
9    DX0108EN  data science bootcamp with python for universi...   

   RECOMMENDATION_COUNT  
0                   839  
1                   839  
2                   839  
3                   839  
4                   839  
5                   839  
6                   818  
7                   817  
8                   817  
9                   817  

Agrégation des similarités

Si un cours candidat \(c\) est similaire à plusieurs cours déjà suivis par un utilisateur, un score cumulé peut être utilisé :

\[ score(u,c) = \sum_{i \in I_u} J(i,c) \]

où seules les similarités dépassant le seuil choisi sont retenues.

Cette stratégie favorise les cours proches de plusieurs éléments du parcours de l’utilisateur.

4.3 Recommandation basée sur le clustering des utilisateurs

Vectorisation des utilisateurs

Dans cette approche, nous n’utilisons pas un one-hot encoding de l’identifiant utilisateur. Un tel encodage représenterait uniquement l’identité de l’utilisateur et ne fournirait aucune information sur ses préférences.

Chaque utilisateur est plutôt représenté par son profil sur les différents genres de cours.

Si \(p\) genres sont disponibles, le profil de l’utilisateur \(u\) est :

\[ \mathbf{x}_u = (x_{u1},x_{u2},\ldots,x_{up}) \]

\(x_{uj}\) mesure l’intérêt de l’utilisateur \(u\) pour le genre \(j\).

Dans notre dataset, les dimensions correspondent aux catégories de cours communes à profile_df et course_genres_df.

# ============================================================
# User profile vectorization
# ============================================================

# Keep only genre columns shared by course and user profiles
genre_columns = [
    column
    for column in course_genres_df.columns
    if (
        column not in ["COURSE_ID", "TITLE"]
        and column in profile_df.columns
    )
]

print("Number of genre features:", len(genre_columns))
print(genre_columns)
Number of genre features: 14
['Database', 'Python', 'CloudComputing', 'DataAnalysis', 'Containers', 'MachineLearning', 'ComputerVision', 'DataScience', 'BigData', 'Chatbot', 'R', 'BackendDev', 'FrontendDev', 'Blockchain']

La matrice des profils utilisateurs est ensuite construite :

# User × Genre feature matrix
X_users = profile_df[
    genre_columns
].to_numpy(dtype=float)

print("User profile matrix shape:", X_users.shape)
User profile matrix shape: (33901, 14)

Mathématiquement :

\[ X \in \mathbb{R}^{N \times p} \]

où :

  • \(N\) est le nombre d’utilisateurs ;
  • \(p\) est le nombre de genres utilisés comme caractéristiques.

Normalisation \(L^2\) des profils utilisateurs

Deux utilisateurs peuvent avoir des préférences relatives très similaires tout en ayant des niveaux d’activité différents.

Par exemple :

\[ A=(10,8,2) \]

et

\[ B=(5,4,1) \]

ont exactement la même direction puisque :

\[ A = 2B \]

Pour éviter que le clustering soit dominé par la magnitude des profils, chaque vecteur utilisateur est normalisé avec la norme \(L^2\) :

\[ \tilde{\mathbf{x}}_u = \frac{\mathbf{x}_u} {\|\mathbf{x}_u\|_2} \]

où :

\[ \|\mathbf{x}_u\|_2 = \sqrt{ \sum_{j=1}^{p}x_{uj}^2 } \]

Après normalisation, chaque vecteur non nul possède une norme égale à \(1\).

# ============================================================
# L2 normalization
# ============================================================


X_users_normalized = normalize(
    X_users,
    norm="l2"
)

On peut vérifier numériquement les normes :

user_norms = np.linalg.norm(
    X_users_normalized,
    axis=1
)

print(user_norms[:10])
[1. 1. 1. 1. 1. 1. 1. 1. 1. 1.]

Pourquoi utiliser K-Means ?

K-Means partitionne les utilisateurs en \(K\) groupes en minimisant la somme des distances quadratiques aux centroïdes :

\[ J = \sum_{k=1}^{K} \sum_{\mathbf{x}_u\in C_k} \| \mathbf{x}_u-\boldsymbol{\mu}_k \|_2^2 \]

où :

  • \(C_k\) est le cluster \(k\) ;
  • \(\boldsymbol{\mu}_k\) est son centroïde.

K-Means utilise naturellement la distance euclidienne.

Cependant, après normalisation \(L^2\), la distance euclidienne est directement liée à la similarité cosinus :

\[ \|\mathbf{x}-\mathbf{y}\|_2^2 = 2\left( 1-\cos(\mathbf{x},\mathbf{y}) \right) \]

lorsque :

\[ \|\mathbf{x}\|_2 = \|\mathbf{y}\|_2 = 1 \]

Ainsi, appliquer K-Means aux profils normalisés revient à favoriser des regroupements d’utilisateurs ayant des orientations de préférences similaires.


Choix du nombre de clusters

Le nombre de clusters \(K\) ne doit pas nécessairement être fixé arbitrairement.

Nous testons plusieurs valeurs de \(K\) et utilisons le silhouette score pour comparer la qualité des partitions.

Le silhouette score mesure à quel point une observation est proche des observations de son propre cluster relativement aux observations du cluster voisin le plus proche.

Une valeur élevée indique généralement des clusters plus compacts et mieux séparés.

# ============================================================
# Find an appropriate number of clusters
# ============================================================


k_values = range(2, 16)

silhouette_scores = []
inertias = []

for k in k_values:

    model = KMeans(
        n_clusters=k,
        random_state=42,
        n_init=10
    )

    labels = model.fit_predict(
        X_users_normalized
    )

    silhouette = silhouette_score(
        X_users_normalized,
        labels
    )

    silhouette_scores.append(
        silhouette
    )

    inertias.append(
        model.inertia_
    )

Les résultats sont regroupés dans un DataFrame :

clustering_evaluation_df = pd.DataFrame({
    "K": list(k_values),
    "SILHOUETTE_SCORE": silhouette_scores,
    "INERTIA": inertias
})

clustering_evaluation_df
K SILHOUETTE_SCORE INERTIA
0 2 0.266546 16377.731863
1 3 0.272967 12650.638229
2 4 0.321970 10770.996267
3 5 0.313227 9457.851971
4 6 0.321274 8477.273393
5 7 0.322837 7630.367993
6 8 0.287095 7066.234354
7 9 0.310177 6705.113028
8 10 0.317834 6336.261671
9 11 0.313765 6036.872031
10 12 0.319717 5776.033294
11 13 0.328923 5466.635824
12 14 0.324359 5259.164219
13 15 0.331283 5066.962480

Visualisation du silhouette score

plt.figure(figsize=(9, 5))

plt.plot(
    clustering_evaluation_df["K"],
    clustering_evaluation_df["SILHOUETTE_SCORE"],
    marker="o"
)

plt.xlabel("Number of Clusters (K)")
plt.ylabel("Silhouette Score")
plt.title("Silhouette Score by Number of Clusters")

plt.xticks(
    clustering_evaluation_df["K"]
)

plt.tight_layout()
plt.show()

Le nombre de clusters associé au meilleur silhouette score est sélectionné automatiquement :

best_k = int(
    clustering_evaluation_df.loc[
        clustering_evaluation_df[
            "SILHOUETTE_SCORE"
        ].idxmax(),
        "K"
    ]
)

best_silhouette = (
    clustering_evaluation_df[
        "SILHOUETTE_SCORE"
    ].max()
)

print("Best K:", best_k)
print(
    "Best silhouette score:",
    round(best_silhouette, 4)
)
Best K: 15
Best silhouette score: 0.3313

Entraînement du modèle K-Means final

Une fois \(K\) choisi, le modèle final est entraîné sur les profils normalisés :

# ============================================================
# Final K-Means model
# ============================================================

kmeans = KMeans(
    n_clusters=best_k,
    random_state=42,
    n_init=10
)

cluster_labels = kmeans.fit_predict(
    X_users_normalized
)

Les labels de cluster sont ensuite ajoutés au DataFrame utilisateur :

clustered_profiles_df = (
    profile_df.copy()
)

clustered_profiles_df[
    "CLUSTER"
] = cluster_labels

clustered_profiles_df[
    ["user", "CLUSTER"]
].head(20)
user CLUSTER
0 2 9
1 4 9
2 5 9
3 7 13
4 8 13
5 9 13
6 12 9
7 16 9
8 17 10
9 19 13
10 20 14
11 21 13
12 22 2
13 23 9
14 25 13
15 26 13
16 27 14
17 28 9
18 29 2
19 30 2

Distribution des utilisateurs dans les clusters

Avant de générer des recommandations, nous observons la taille des clusters.

cluster_distribution = (
    clustered_profiles_df
    .groupby("CLUSTER")
    .size()
    .reset_index(
        name="USER_COUNT"
    )
)

cluster_distribution
CLUSTER USER_COUNT
0 0 404
1 1 1971
2 2 2252
3 3 1413
4 4 1967
5 5 4455
6 6 2170
7 7 2965
8 8 2071
9 9 3490
10 10 1093
11 11 1034
12 12 2756
13 13 5232
14 14 628

Visualisation :

plt.figure(figsize=(9, 5))

sns.barplot(
    data=cluster_distribution,
    x="CLUSTER",
    y="USER_COUNT"
)

plt.xlabel("Cluster")
plt.ylabel("Number of Users")
plt.title("Distribution of Users Across Clusters")

plt.tight_layout()
plt.show()


Association des utilisateurs à leur cluster

On construit une table simple reliant chaque utilisateur à son cluster :

user_clusters_df = clustered_profiles_df[
    ["user", "CLUSTER"]
].copy()

user_clusters_df.head()
user CLUSTER
0 2 9
1 4 9
2 5 9
3 7 13
4 8 13

Association des interactions utilisateur–cours aux clusters

Nous utilisons les interactions utilisateur–cours disponibles dans ratings_df.

Avant la jointure, les doublons éventuels sont supprimés afin qu’un utilisateur ne contribue qu’une seule fois à la popularité d’un même cours.

# ============================================================
# Add cluster labels to user-course interactions
# ============================================================

cluster_interactions_df = (
    ratings_df[
        ["user", "item"]
    ]
    .drop_duplicates()
    .merge(
        user_clusters_df,
        on="user",
        how="inner"
    )
)

cluster_interactions_df.head()
user item CLUSTER
0 1889878 CC0101EN 4
1 1342067 CL0101EN 13
2 1990814 ML0120ENv3 9
3 380098 BD0211EN 2
4 779563 DS0101EN 5

Popularité relative d’un cours dans un cluster

Pour un cours \(i\) et un cluster \(C_k\), nous définissons la popularité relative :

\[ P(i,C_k) = \frac{ \#\{ u \in C_k : u \text{ a suivi } i \} }{ |C_k| } \]

Cette normalisation est préférable à un simple comptage brut car les clusters peuvent avoir des tailles très différentes.

Par exemple, \(10\) utilisateurs sur un cluster de \(20\) représentent une popularité plus importante que \(10\) utilisateurs sur un cluster de \(200\).

Taille des clusters

cluster_sizes = (
    user_clusters_df
    .groupby("CLUSTER")["user"]
    .nunique()
    .reset_index(
        name="CLUSTER_SIZE"
    )
)

Nombre d’utilisateurs ayant suivi chaque cours

cluster_course_popularity = (
    cluster_interactions_df
    .groupby(
        ["CLUSTER", "item"]
    )["user"]
    .nunique()
    .reset_index(
        name="USER_COUNT"
    )
)

Ajout de la taille du cluster :

cluster_course_popularity = (
    cluster_course_popularity
    .merge(
        cluster_sizes,
        on="CLUSTER",
        how="left"
    )
)

Calcul de la popularité relative :

cluster_course_popularity[
    "POPULARITY"
] = (
    cluster_course_popularity[
        "USER_COUNT"
    ]
    /
    cluster_course_popularity[
        "CLUSTER_SIZE"
    ]
)

Tri des résultats :

cluster_course_popularity = (
    cluster_course_popularity
    .sort_values(
        by=[
            "CLUSTER",
            "POPULARITY"
        ],
        ascending=[
            True,
            False
        ]
    )
)

cluster_course_popularity.head(20)
CLUSTER item USER_COUNT CLUSTER_SIZE POPULARITY
47 0 ST0101EN 345 404 0.853960
50 0 WA0101EN 43 404 0.106436
34 0 ML0103EN 39 404 0.096535
8 0 CB0103EN 22 404 0.054455
20 0 DA0101EN 21 404 0.051980
30 0 DV0101EN 17 404 0.042079
31 0 DV0151EN 17 404 0.042079
19 0 CP0101EN 14 404 0.034653
41 0 RP0101EN 11 404 0.027228
22 0 DB0101EN 9 404 0.022277
24 0 DP0101EN 9 404 0.022277
25 0 DS0101EN 9 404 0.022277
6 0 BD0153EN 8 404 0.019802
28 0 DS0301EN 8 404 0.019802
0 0 BC0101EN 7 404 0.017327
3 0 BD0101EN 7 404 0.017327
26 0 DS0103EN 7 404 0.017327
4 0 BD0111EN 6 404 0.014851
38 0 PA0101EN 6 404 0.014851
51 0 WA0103EN 6 404 0.014851

Seuil de popularité pour les recommandations

Nous introduisons un hyperparamètre :

\[ \tau = \text{minimum cluster popularity} \]

Un cours ne sera considéré comme candidat que si :

\[ P(i,C_k) \geq \tau \]

Nous commençons avec :

MIN_CLUSTER_POPULARITY = 0.20

Cela signifie qu’un cours doit avoir été suivi par au moins \(20\%\) des utilisateurs du cluster pour être utilisé comme candidat.


Génération des recommandations

Pour chaque utilisateur du jeu de test :

  1. identifier son cluster ;
  2. récupérer les cours populaires dans ce cluster ;
  3. exclure les cours déjà suivis ;
  4. conserver la popularité relative comme score de recommandation.
# ============================================================
# Generate cluster-based recommendations
# ============================================================

test_user_ids = (
    test_users_df["user"]
    .unique()
)

cluster_recommendations = []

for user_id in test_user_ids:

    # --------------------------------------------------------
    # Find user's cluster
    # --------------------------------------------------------

    user_cluster_data = (
        user_clusters_df[
            user_clusters_df["user"]
            == user_id
        ]
    )

    if user_cluster_data.empty:
        continue

    user_cluster = int(
        user_cluster_data[
            "CLUSTER"
        ].iloc[0]
    )

    # --------------------------------------------------------
    # Courses already taken by the user
    # --------------------------------------------------------

    enrolled_courses = set(
        pd.Series(
            test_users_df.loc[
                test_users_df["user"]
                == user_id,
                "item"
            ]
        )
        .astype(str)
        .tolist()
    )

    # --------------------------------------------------------
    # Candidate courses from the user's cluster
    # --------------------------------------------------------

    candidate_courses = (
        cluster_course_popularity[
            (
                cluster_course_popularity[
                    "CLUSTER"
                ]
                == user_cluster
            )
            &
            (
                cluster_course_popularity[
                    "POPULARITY"
                ]
                >= MIN_CLUSTER_POPULARITY
            )
        ]
        .copy()
    )

    # --------------------------------------------------------
    # Remove already seen courses
    # --------------------------------------------------------

    candidate_courses = (
        candidate_courses[
            ~candidate_courses[
                "item"
            ].isin(
                enrolled_courses
            )
        ]
    )

    # --------------------------------------------------------
    # Store recommendations
    # --------------------------------------------------------

    for _, row in (
        candidate_courses.iterrows()
    ):

        cluster_recommendations.append({
            "USER": user_id,
            "COURSE_ID": row["item"],
            "SCORE": row["POPULARITY"],
            "CLUSTER": user_cluster
        })

DataFrame final des recommandations

clustering_res_df = pd.DataFrame(
    cluster_recommendations
)

Les recommandations sont ensuite triées par utilisateur et par score décroissant :

clustering_res_df = (
    clustering_res_df
    .sort_values(
        by=[
            "USER",
            "SCORE"
        ],
        ascending=[
            True,
            False
        ]
    )
    .reset_index(
        drop=True
    )
)

clustering_res_df.head(20)
USER COURSE_ID SCORE CLUSTER
0 37465 BD0141EN 0.400420 13
1 37465 BD0131EN 0.364488 13
2 37465 PY0101EN 0.243119 13
3 37465 BD0121EN 0.222668 13
4 37465 BD0212EN 0.216934 13
5 50348 CL0101EN 0.259908 6
6 52091 PY0101EN 0.399201 2
7 52091 DS0101EN 0.363677 2
8 52091 DS0105EN 0.208259 2
9 52091 DS0103EN 0.207371 2
10 70434 BD0211EN 0.632836 13
11 70434 PY0101EN 0.243119 13
12 70434 DS0101EN 0.235092 13
13 70434 BD0121EN 0.222668 13
14 70434 BD0212EN 0.216934 13
15 85625 BD0101EN 0.780772 13
16 85625 BD0211EN 0.632836 13
17 85625 PY0101EN 0.243119 13
18 85625 DS0101EN 0.235092 13
19 85625 BD0212EN 0.216934 13

Évaluation : nombre moyen de nouveaux cours recommandés

Pour chaque utilisateur \(u\), soit \(R_u\) l’ensemble des nouveaux cours recommandés.

Le nombre moyen de recommandations est :

\[ \overline{R} = \frac{1}{N} \sum_{u=1}^{N} |R_u| \]

\(N\) est le nombre d’utilisateurs du jeu de test.

recommendations_per_user = (
    clustering_res_df
    .groupby("USER")
    .size()
    .reindex(
        test_user_ids,
        fill_value=0
    )
)

Le reindex(..., fill_value=0) permet d’inclure dans le calcul les utilisateurs auxquels aucun cours n’a été recommandé.

average_cluster_recommendations = (
    recommendations_per_user.mean()
)

print(
    "Average number of new/unseen courses "
    "recommended per user: "
    f"{average_cluster_recommendations:.2f}"
)
Average number of new/unseen courses recommended per user: 5.55

Évaluation : Top 10 des cours les plus fréquemment recommandés

Pour chaque cours \(i\), on définit :

\[ F_i = \sum_u \mathbb{1}(i \in R_u) \]

\(F_i\) correspond au nombre d’utilisateurs auxquels le cours \(i\) a été recommandé.

top_10_cluster = (
    clustering_res_df
    .groupby("COURSE_ID")
    .size()
    .reset_index(
        name="RECOMMENDATION_COUNT"
    )
    .sort_values(
        by="RECOMMENDATION_COUNT",
        ascending=False
    )
    .head(10)
)

Ajout des titres des cours :

top_10_cluster = (
    top_10_cluster
    .merge(
        course_genres_df[
            [
                "COURSE_ID",
                "TITLE"
            ]
        ],
        on="COURSE_ID",
        how="left"
    )
)

top_10_cluster = (
    top_10_cluster[
        [
            "COURSE_ID",
            "TITLE",
            "RECOMMENDATION_COUNT"
        ]
    ]
)

top_10_cluster
COURSE_ID TITLE RECOMMENDATION_COUNT
0 DS0101EN introduction to data science 384
1 PY0101EN python for data science 351
2 DS0105EN data science hands on with open source tools 284
3 ST0101EN statistics 101 273
4 DS0103EN data science methodology 255
5 DS0301EN data privacy fundamentals 237
6 ML0115EN deep learning 101 217
7 BC0101EN blockchain essentials 210
8 BD0211EN spark fundamentals i 207
9 DB0101EN sql and relational databases 101 201

Visualisation du Top 10

plt.figure(
    figsize=(11, 6)
)

sns.barplot(
    data=top_10_cluster,
    x="RECOMMENDATION_COUNT",
    y="TITLE"
)

plt.xlabel(
    "Number of Recommendations"
)

plt.ylabel(
    "Course"
)

plt.title(
    "Top 10 Most Frequently Recommended Courses\n"
    "User Profile Clustering-Based Recommender"
)

plt.tight_layout()
plt.show()


Synthèse de la méthode

Le système de recommandation basé sur le clustering suit le pipeline suivant :

User profile vectors
        ↓
L2 normalization
        ↓
Test several K values
        ↓
Silhouette score
        ↓
Select best K
        ↓
K-Means clustering
        ↓
Assign each user to a cluster
        ↓
Compute course popularity within each cluster
        ↓
Apply a minimum popularity threshold
        ↓
Remove already-enrolled courses
        ↓
Rank candidate courses
        ↓
Generate recommendations

Les deux principaux hyperparamètres de cette approche sont :

\[ K = \text{nombre de clusters} \]

et :

\[ \tau = \text{seuil minimal de popularité intra-cluster} \]

Une extension naturelle consiste à comparer plusieurs couples \((K,\tau)\) afin d’étudier leur influence sur le nombre moyen de recommandations, la couverture des utilisateurs et la diversité des cours proposés.

5. Filtrage collaboratif : protocole expérimental commun

Les trois modèles collaboratifs sont évalués sur exactement le même découpage train/test afin de rendre leurs RMSE et MAE directement comparables.

5.1 Données de ratings

rating_url = (
    "https://cf-courses-data.s3.us.cloud-object-storage.appdomain.cloud/"
    "IBM-ML321EN-SkillsNetwork/labs/datasets/ratings.csv"
)

rating_df = pd.read_csv(rating_url)
rating_df = rating_df[["user", "item", "rating"]].dropna().copy()

print(rating_df.shape)
rating_df.head()
(233306, 3)
user item rating
0 1889878 CC0101EN 3.0
1 1342067 CL0101EN 3.0
2 1990814 ML0120ENv3 3.0
3 380098 BD0211EN 3.0
4 779563 DS0101EN 3.0

L’observation élémentaire est le triplet

\[ (u,i,r_{ui}), \]

\(r_{ui}\) représente l’évaluation observée du cours \(i\) par l’utilisateur \(u\).

5.2 Split commun

RANDOM_STATE = 42
TEST_SIZE = 0.20

cf_train_df, cf_test_df = train_test_split(
    rating_df,
    test_size=TEST_SIZE,
    random_state=RANDOM_STATE
)

print("Train interactions:", len(cf_train_df))
print("Test interactions:", len(cf_test_df))
Train interactions: 186644
Test interactions: 46662

Le même cf_test_df est utilisé pour KNN, NMF et le réseau neuronal. Cela évite qu’une différence de RMSE provienne simplement d’un échantillon de test différent.

5.3 Échelle des ratings

min_rating = float(rating_df["rating"].min())
max_rating = float(rating_df["rating"].max())

print("Rating range:", min_rating, "to", max_rating)
Rating range: 2.0 to 3.0

6. KNN Collaborative Filtering

6.1 Principe

Le KNN user-based repose sur l’hypothèse que des utilisateurs ayant évalué les cours de manière similaire dans le passé auront des préférences proches dans le futur.

Pour un utilisateur cible \(u\), on recherche un voisinage \(N_k(u)\) de \(k\) utilisateurs similaires. Une écriture intuitive de la prédiction est :

\[ \hat r_{ui} =\frac{\sum_{v\in N_k(u)}s(u,v)r_{vi}} {\sum_{v\in N_k(u)}|s(u,v)|}, \]

\(s(u,v)\) mesure la similarité entre \(u\) et \(v\).

Nous utilisons la similarité cosinus :

\[ \cos(\mathbf x,\mathbf y)= \frac{\mathbf x^\top\mathbf y}{\|\mathbf x\|_2\|\mathbf y\|_2}. \]

6.2 Flowchart

fig, ax = plt.subplots(figsize=(11, 10))
ax.set_xlim(0, 10); ax.set_ylim(0, 12); ax.axis("off")

def flow_box(x, y, w, h, text):
    box = FancyBboxPatch((x,y), w,h, boxstyle="round,pad=0.03",
                         linewidth=1.5, edgecolor="black", facecolor="white")
    ax.add_patch(box)
    ax.text(x+w/2, y+h/2, text, ha="center", va="center", fontsize=10)

def flow_arrow(x1,y1,x2,y2):
    ax.add_patch(FancyArrowPatch((x1,y1),(x2,y2),arrowstyle="->",
                                mutation_scale=15,linewidth=1.5))

steps = [
    ("User-Course Ratings",10.3),
    ("Build User-Item Matrix",8.5),
    ("Compute User Similarities",6.7),
    ("Find K Nearest Neighbors",4.9),
    ("Predict Missing Ratings",3.1),
    ("Evaluate on Test Set: RMSE / MAE",1.3),
]
for text,y in steps: flow_box(3.1,y,3.8,1.0,text)
for (_,y1),(_,y2) in zip(steps[:-1],steps[1:]): flow_arrow(5,y1,5,y2+1.0)
plt.title("KNN-Based Collaborative Filtering", fontsize=15)
plt.show()

6.3 Préparation Surprise et entraînement

reader = Reader(rating_scale=(min_rating, max_rating))

knn_trainset = Dataset.load_from_df(
    cf_train_df[["user", "item", "rating"]], reader
).build_full_trainset()

knn_testset = list(
    cf_test_df[["user", "item", "rating"]]
    .itertuples(index=False, name=None)
)

K_NEIGHBORS = 40
sim_options = {"name": "cosine", "user_based": True}

knn_model = KNNBasic(
    k=K_NEIGHBORS,
    min_k=1,
    sim_options=sim_options,
    verbose=False
)
knn_model.fit(knn_trainset)
<surprise.prediction_algorithms.knns.KNNBasic at 0x328354920>

6.4 Prédictions et évaluation

knn_predictions = knn_model.test(knn_testset)
knn_rmse = accuracy.rmse(knn_predictions, verbose=True)
knn_mae = accuracy.mae(knn_predictions, verbose=True)

knn_results = pd.DataFrame({
    "MODEL": ["KNN"],
    "RMSE": [knn_rmse],
    "MAE": [knn_mae]
})
knn_results
RMSE: 0.2016
MAE:  0.0482
MODEL RMSE MAE
0 KNN 0.201572 0.048206
knn_prediction_df = pd.DataFrame([
    {
        "USER": p.uid,
        "COURSE_ID": p.iid,
        "TRUE_RATING": p.r_ui,
        "PREDICTED_RATING": p.est,
        "ABS_ERROR": abs(p.r_ui - p.est)
    }
    for p in knn_predictions
])

plt.figure(figsize=(7,6))
plt.scatter(knn_prediction_df["TRUE_RATING"], knn_prediction_df["PREDICTED_RATING"], alpha=.25)
lo=min(knn_prediction_df["TRUE_RATING"].min(),knn_prediction_df["PREDICTED_RATING"].min())
hi=max(knn_prediction_df["TRUE_RATING"].max(),knn_prediction_df["PREDICTED_RATING"].max())
plt.plot([lo,hi],[lo,hi],linestyle="--")
plt.xlabel("True Rating"); plt.ylabel("Predicted Rating")
plt.title("KNN: True vs Predicted Ratings")
plt.tight_layout(); plt.show()

Le KNN fournit une méthode intuitive et interprétable : les prédictions sont construites à partir d’utilisateurs proches dans l’espace des interactions.

7. NMF Collaborative Filtering

7.1 Factorisation matricielle

NMF approxime la matrice des ratings par deux matrices latentes non négatives :

\[ R\approx UV, \]

avec

\[ U\in\mathbb R_+^{m\times k},\qquad V\in\mathbb R_+^{k\times n}. \]

La prédiction est gouvernée par l’interaction des facteurs latents :

\[ \hat r_{ui}\approx \mathbf u_u^\top\mathbf v_i. \]

La contrainte de non-négativité donne une décomposition additive :

\[ R_{ui}\approx\sum_{f=1}^k U_{uf}V_{fi}. \]

7.2 Flowchart

fig, ax = plt.subplots(figsize=(11,10))
ax.set_xlim(0,10); ax.set_ylim(0,12); ax.axis("off")
steps = [
    ("User-Course Rating Matrix R",10.3),
    ("Non-negative Factorization R ≈ UV",8.5),
    ("Learn User Latent Factors U",6.7),
    ("Learn Course Latent Factors V",4.9),
    ("Reconstruct / Predict Missing Ratings",3.1),
    ("Evaluate on Test Set: RMSE / MAE",1.3),
]
for text,y in steps: flow_box(3.1,y,3.8,1.0,text)
for (_,y1),(_,y2) in zip(steps[:-1],steps[1:]): flow_arrow(5,y1,5,y2+1.0)
plt.title("NMF-Based Collaborative Filtering", fontsize=15)
plt.show()

7.3 Entraînement

nmf_trainset = Dataset.load_from_df(
    cf_train_df[["user", "item", "rating"]], reader
).build_full_trainset()

nmf_testset = list(
    cf_test_df[["user", "item", "rating"]]
    .itertuples(index=False, name=None)
)

N_FACTORS = 15
N_EPOCHS = 50

nmf_model = NMF(
    n_factors=N_FACTORS,
    n_epochs=N_EPOCHS,
    random_state=RANDOM_STATE,
    verbose=False
)
nmf_model.fit(nmf_trainset)
<surprise.prediction_algorithms.matrix_factorization.NMF at 0x160296960>

7.4 Prédictions, facteurs latents et métriques

nmf_predictions = nmf_model.test(nmf_testset)
nmf_rmse = accuracy.rmse(nmf_predictions, verbose=True)
nmf_mae = accuracy.mae(nmf_predictions, verbose=True)

nmf_results = pd.DataFrame({
    "MODEL": ["NMF"],
    "RMSE": [nmf_rmse],
    "MAE": [nmf_mae]
})

print("User latent factors:", nmf_model.pu.shape)
print("Course latent factors:", nmf_model.qi.shape)
nmf_results
RMSE: 0.1968
MAE:  0.0671
User latent factors: (32232, 15)
Course latent factors: (126, 15)
MODEL RMSE MAE
0 NMF 0.196844 0.067073
nmf_prediction_df = pd.DataFrame([
    {
        "TRUE_RATING": p.r_ui,
        "PREDICTED_RATING": p.est,
        "ABS_ERROR": abs(p.r_ui-p.est)
    }
    for p in nmf_predictions
])

plt.figure(figsize=(7,6))
plt.scatter(nmf_prediction_df["TRUE_RATING"],nmf_prediction_df["PREDICTED_RATING"],alpha=.25)
lo=min(nmf_prediction_df["TRUE_RATING"].min(),nmf_prediction_df["PREDICTED_RATING"].min())
hi=max(nmf_prediction_df["TRUE_RATING"].max(),nmf_prediction_df["PREDICTED_RATING"].max())
plt.plot([lo,hi],[lo,hi],linestyle="--")
plt.xlabel("True Rating"); plt.ylabel("Predicted Rating")
plt.title("NMF: True vs Predicted Ratings")
plt.tight_layout(); plt.show()

Le paramètre \(k\) contrôle la dimension de l’espace latent. Une extension naturelle consiste à sélectionner

\[ k^*=\arg\min_k RMSE(k) \]

sur un ensemble de valeurs candidates.

8. Neural Network Embedding Recommender

8.1 Représentation latente apprise

Chaque utilisateur et chaque cours sont associés à des embeddings denses :

\[ \mathbf e_u\in\mathbb R^d,\qquad \mathbf e_i\in\mathbb R^d. \]

Le modèle combine ces représentations par produit élément par élément,

\[ \mathbf z_{ui}=\mathbf e_u\odot\mathbf e_i, \]

puis apprend une fonction non linéaire

\[ \hat r_{ui}=f_\theta(\mathbf z_{ui}). \]

Cette approche généralise l’idée de facteurs latents en laissant un réseau neuronal apprendre une fonction d’interaction plus flexible.

8.2 Encodage et données communes

np.random.seed(RANDOM_STATE)
tf.random.set_seed(RANDOM_STATE)

user_encoder = LabelEncoder()
item_encoder = LabelEncoder()

user_encoder.fit(rating_df["user"])
item_encoder.fit(rating_df["item"])

def encode_cf(df):
    out=df.copy()
    out["USER_ENCODED"] = user_encoder.transform(out["user"])
    out["ITEM_ENCODED"] = item_encoder.transform(out["item"])
    return out

nn_train_df=encode_cf(cf_train_df)
nn_test_df=encode_cf(cf_test_df)

n_users=len(user_encoder.classes_)
n_items=len(item_encoder.classes_)

8.3 Architecture

EMBEDDING_DIM=32
DENSE_UNITS=64
DROPOUT_RATE=.20
LEARNING_RATE=.001
BATCH_SIZE=256
EPOCHS=20

user_input=keras.Input(shape=(1,),name="user_input")
item_input=keras.Input(shape=(1,),name="item_input")

user_vector=layers.Flatten()(layers.Embedding(n_users,EMBEDDING_DIM,name="user_embedding")(user_input))
item_vector=layers.Flatten()(layers.Embedding(n_items,EMBEDDING_DIM,name="item_embedding")(item_input))

interaction=layers.Multiply()([user_vector,item_vector])
x=layers.Dense(DENSE_UNITS,activation="relu")(interaction)
x=layers.Dropout(DROPOUT_RATE)(x)
x=layers.Dense(32,activation="relu")(x)
output=layers.Dense(1,activation="linear",name="rating_output")(x)

embedding_model=keras.Model([user_input,item_input],output)
embedding_model.compile(
    optimizer=keras.optimizers.Adam(learning_rate=LEARNING_RATE),
    loss="mse",
    metrics=["mae"]
)
embedding_model.summary()
Model: "functional"
┏━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━┓
┃ Layer (type)         Output Shape          Param #  Connected to      ┃
┡━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━┩
│ user_input          │ (None, 1)         │          0 │ -                 │
│ (InputLayer)        │                   │            │                   │
├─────────────────────┼───────────────────┼────────────┼───────────────────┤
│ item_input          │ (None, 1)         │          0 │ -                 │
│ (InputLayer)        │                   │            │                   │
├─────────────────────┼───────────────────┼────────────┼───────────────────┤
│ user_embedding      │ (None, 1, 32)     │  1,084,832 │ user_input[0][0]  │
│ (Embedding)         │                   │            │                   │
├─────────────────────┼───────────────────┼────────────┼───────────────────┤
│ item_embedding      │ (None, 1, 32)     │      4,032 │ item_input[0][0]  │
│ (Embedding)         │                   │            │                   │
├─────────────────────┼───────────────────┼────────────┼───────────────────┤
│ flatten (Flatten)   │ (None, 32)        │          0 │ user_embedding[0… │
├─────────────────────┼───────────────────┼────────────┼───────────────────┤
│ flatten_1 (Flatten) │ (None, 32)        │          0 │ item_embedding[0… │
├─────────────────────┼───────────────────┼────────────┼───────────────────┤
│ multiply (Multiply) │ (None, 32)        │          0 │ flatten[0][0],    │
│                     │                   │            │ flatten_1[0][0]   │
├─────────────────────┼───────────────────┼────────────┼───────────────────┤
│ dense (Dense)       │ (None, 64)        │      2,112 │ multiply[0][0]    │
├─────────────────────┼───────────────────┼────────────┼───────────────────┤
│ dropout (Dropout)   │ (None, 64)        │          0 │ dense[0][0]       │
├─────────────────────┼───────────────────┼────────────┼───────────────────┤
│ dense_1 (Dense)     │ (None, 32)        │      2,080 │ dropout[0][0]     │
├─────────────────────┼───────────────────┼────────────┼───────────────────┤
│ rating_output       │ (None, 1)         │         33 │ dense_1[0][0]     │
│ (Dense)             │                   │            │                   │
└─────────────────────┴───────────────────┴────────────┴───────────────────┘
 Total params: 1,093,089 (4.17 MB)
 Trainable params: 1,093,089 (4.17 MB)
 Non-trainable params: 0 (0.00 B)

8.4 Entraînement et validation

history=embedding_model.fit(
    [nn_train_df["USER_ENCODED"].to_numpy(), nn_train_df["ITEM_ENCODED"].to_numpy()],
    nn_train_df["rating"].astype(float).to_numpy(),
    validation_split=.20,
    epochs=EPOCHS,
    batch_size=BATCH_SIZE,
    verbose=0
)
plt.figure(figsize=(8,5))
plt.plot(history.history["loss"],label="Training Loss")
plt.plot(history.history["val_loss"],label="Validation Loss")
plt.xlabel("Epoch"); plt.ylabel("MSE")
plt.title("Neural Network: Training and Validation Loss")
plt.legend(); plt.tight_layout(); plt.show()

8.5 Test, RMSE et MAE

nn_predictions=embedding_model.predict(
    [nn_test_df["USER_ENCODED"].to_numpy(),nn_test_df["ITEM_ENCODED"].to_numpy()],
    batch_size=BATCH_SIZE,
    verbose=0
).reshape(-1)

nn_predictions=np.clip(nn_predictions,min_rating,max_rating)
y_test=nn_test_df["rating"].astype(float).to_numpy()

nn_rmse=np.sqrt(mean_squared_error(y_test,nn_predictions))
nn_mae=mean_absolute_error(y_test,nn_predictions)

neural_network_results=pd.DataFrame({
    "MODEL":["Neural Network Embedding"],
    "RMSE":[nn_rmse],
    "MAE":[nn_mae]
})
neural_network_results
MODEL RMSE MAE
0 Neural Network Embedding 0.194642 0.043995

8.6 Analyse des embeddings et des erreurs

user_embedding_matrix=embedding_model.get_layer("user_embedding").get_weights()[0]
item_embedding_matrix=embedding_model.get_layer("item_embedding").get_weights()[0]

print("User embedding matrix:",user_embedding_matrix.shape)
print("Course embedding matrix:",item_embedding_matrix.shape)

nn_prediction_df=pd.DataFrame({
    "TRUE_RATING":y_test,
    "PREDICTED_RATING":nn_predictions
})
nn_prediction_df["ABS_ERROR"]=np.abs(nn_prediction_df["TRUE_RATING"]-nn_prediction_df["PREDICTED_RATING"])

plt.figure(figsize=(7,6))
plt.scatter(nn_prediction_df["TRUE_RATING"],nn_prediction_df["PREDICTED_RATING"],alpha=.25)
lo=min(nn_prediction_df["TRUE_RATING"].min(),nn_prediction_df["PREDICTED_RATING"].min())
hi=max(nn_prediction_df["TRUE_RATING"].max(),nn_prediction_df["PREDICTED_RATING"].max())
plt.plot([lo,hi],[lo,hi],linestyle="--")
plt.xlabel("True Rating"); plt.ylabel("Predicted Rating")
plt.title("Neural Embedding: True vs Predicted Ratings")
plt.tight_layout(); plt.show()
User embedding matrix: (33901, 32)
Course embedding matrix: (126, 32)

9. Comparaison des modèles collaboratifs

Les trois modèles sont désormais évalués sur le même jeu de test. Le tableau synthétique est construit par concaténation :

performance_df=pd.concat(
    [knn_results,nmf_results,neural_network_results],
    ignore_index=True
)
performance_df
MODEL RMSE MAE
0 KNN 0.201572 0.048206
1 NMF 0.196844 0.067073
2 Neural Network Embedding 0.194642 0.043995

9.1 Comparaison RMSE

plt.figure(figsize=(9,5))
bars=plt.bar(performance_df["MODEL"],performance_df["RMSE"])
plt.xlabel("Collaborative-Filtering Model")
plt.ylabel("RMSE")
plt.title("RMSE Comparison of Collaborative-Filtering Models")
for bar,value in zip(bars,performance_df["RMSE"]):
    plt.text(bar.get_x()+bar.get_width()/2,bar.get_height(),f"{value:.4f}",ha="center",va="bottom")
plt.tight_layout(); plt.show()

Interprétation. Le RMSE mesure l’écart quadratique moyen entre ratings observés et prédits. La barre la plus basse correspond donc au meilleur modèle selon ce critère. Cette comparaison doit être lue conjointement avec la complexité, l’interprétabilité et le coût d’entraînement de chaque méthode.

9.2 Comparaison MAE

plt.figure(figsize=(9,5))
bars=plt.bar(performance_df["MODEL"],performance_df["MAE"])
plt.xlabel("Collaborative-Filtering Model")
plt.ylabel("MAE")
plt.title("MAE Comparison of Collaborative-Filtering Models")
for bar,value in zip(bars,performance_df["MAE"]):
    plt.text(bar.get_x()+bar.get_width()/2,bar.get_height(),f"{value:.4f}",ha="center",va="bottom")
plt.tight_layout(); plt.show()

10. Discussion générale

Les méthodes étudiées répondent à des objectifs différents.

Famille Modèle Signal exploité Force principale Limite principale
Content-based Profil utilisateur Genres + profil Interprétable Dépend de la qualité des features
Content-based Jaccard Genres binaires Simple et naturel pour des ensembles Similarité peu expressive
Segmentation K-Means Profils normalisés Découvre des groupes de préférences Dépend de \(K\) et de la géométrie des clusters
Collaborative KNN Ratings Explicable par les voisins Coût de voisinage, sparsité
Collaborative NMF Ratings Représentation latente compacte Modèle essentiellement bilinéaire
Collaborative Neural embeddings Ratings Interaction non linéaire flexible Plus coûteux et moins interprétable

Les métriques de recommandation et les métriques prédictives ne mesurent pas exactement la même chose. Une faible erreur de rating ne garantit pas à elle seule une forte diversité ou une bonne couverture des recommandations. Inversement, un modèle content-based peut fournir de nombreuses recommandations pertinentes sans être formulé comme un problème de régression de ratings.

Conclusion

Cette étude illustre une progression allant de méthodes explicites fondées sur les caractéristiques des cours vers des représentations latentes apprises à partir des interactions.

Les approches content-based permettent de contrôler directement la notion de similarité et offrent une forte interprétabilité. Le clustering ajoute une dimension collective en regroupant les utilisateurs selon la géométrie de leurs profils. Les modèles collaboratifs exploitent quant à eux la structure de la matrice utilisateur–cours : KNN par voisinage, NMF par factorisation et le réseau neuronal par embeddings appris.

La comparaison finale par RMSE et MAE fournit un critère quantitatif commun pour les trois modèles collaboratifs. Le choix d’un système de recommandation en pratique doit toutefois considérer simultanément précision, couverture, diversité, interprétabilité, coût de calcul et capacité à traiter les nouveaux utilisateurs ou nouveaux cours.

Des prolongements naturels seraient l’optimisation systématique des hyperparamètres, l’utilisation de métriques de ranking telles que Precision@K, Recall@K et NDCG@K, ainsi que la construction d’un modèle hybride combinant signaux de contenu et facteurs collaboratifs.