# ============================================================
# 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 castSystèmes de recommandation de cours
Approches content-based, clustering et filtrage collaboratif
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 :
- construire plusieurs familles de recommenders et expliciter leur formulation mathématique ;
- 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.
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 tensorflowPour 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}) \]
où \(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| \]
où \(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 :
- représenter chaque cours par l’ensemble de ses genres ;
- calculer une similarité cours–cours ;
- pour chaque utilisateur, chercher les cours similaires aux cours déjà suivis ;
- exclure les cours déjà vus ;
- 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}) \]
où \(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.20Cela 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 :
- identifier son cluster ;
- récupérer les cours populaires dans ce cluster ;
- exclure les cours déjà suivis ;
- 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| \]
où \(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) \]
où \(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}), \]
où \(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)|}, \]
où \(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_resultsRMSE: 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_resultsRMSE: 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.