ML-7 (Python) : Systèmes de recommandation par factorisation de matrice (NMF)

Navigation : Index | << ML-6-ONNX | Jumeau .NET (ML.NET) | Suivant >> ML-8-Clustering

Ce notebook est le jumeau Python (scikit-learn) de ML-7-Recommendation.ipynb (ML.NET). Il couvre la même pédagogie (filtrage collaboratif par factorisation de matrice) avec sklearn.decomposition.NMF.

Objectifs d’apprentissage

À la fin de ce notebook, vous saurez :

  1. Distinguer les grandes familles de systèmes de recommandation (collaboratif, contenu, hybride)
  2. Construire et remplir une matrice utilisateur-item
  3. Appliquer la factorisation de matrice (NMF) pour découvrir des facteurs latents
  4. Prédire les notes manquantes et générer des Top-N recommandations
  5. Évaluer un système par le RMSE sur les notes connues
  6. Diagnostiquer le cold start problem (nouvel utilisateur / nouvel item)

Qu’est-ce qu’un système de recommandation ?

Un système de recommandation suggère des items (films, produits, articles) pertinents à un utilisateur. Trois grandes familles :

  • Collaborative Filtering (CF) — « les utilisateurs qui ont aimé les mêmes choses que vous ont aussi aimé… ». N’utilise que la matrice des notes passées ; aucune connaissance du contenu des items.
  • Content-Based Filtering — recommande des items similaires à ceux que l’utilisateur a aimés, en s’appuyant sur des features des items (genre, acteurs, mots-clés).
  • Hybride — combine les deux.

Ce notebook se concentre sur le filtrage collaboratif par factorisation de matrice, l’approche canonique (Koren, Bell & Volinsky, 2009 — prix Netflix).

Le cas d’usage : une plateforme de films dispose des notes (1 à 5) attribuées par des utilisateurs à des films. Dans les vrais systèmes, la matrice utilisateur-item est clairsemée (chaque utilisateur ne note qu’une petite fraction des films). Notre exemple jouet ci-dessous est volontairement presque complet (23 notes sur 25, 92 % — cf. cellule 4) pour rester lisible ; il contient malgré tout deux notes manquantes à prédire. On veut prédire les notes manquantes pour recommander les films que l’utilisateur noterait haut.

import numpy as np
import pandas as pd
from sklearn.decomposition import NMF
print(f"numpy {np.__version__} | pandas {pd.__version__} | sklearn NMF charge")
numpy 2.4.4 | pandas 3.0.2 | sklearn NMF charge

Exemple : notes de films

On construit un petit jeu de données réeliste : 5 utilisateurs notent 5 films sur l’échelle 1-5. Les profils de goût sont nets pour exercer le détecteur de facteurs latents :

  • U1, U4 aiment les films Sci-Fi/Action (Matrix, Inception, Interstellar) et détestent les romances.
  • U2, U5 aiment les romances (Titanic, Le Notebook) et les notent haut.
  • U3 a des goûts variés.

Les cellules 0 représentent les notes manquantes (film non vu) — c’est ce qu’on veut prédire.

Film Genre
Matrix Sci-Fi / Action
Inception Sci-Fi
Titanic Romance
Interstellar Sci-Fi
Le Notebook Romance
movies = ["Matrix", "Inception", "Titanic", "Interstellar", "Le Notebook"]
users = ["U1", "U2", "U3", "U4", "U5"]

# Matrice utilisateur-item : lignes = utilisateurs, colonnes = films. 0 = note manquante (film non vu).
R = np.array([
    [5, 4, 1, 5, 0],   # U1 : Sci-Fi fan (n'a pas vu Le Notebook)
    [2, 1, 5, 0, 5],   # U2 : Romance fan (n'a pas vu Interstellar)
    [4, 3, 4, 4, 3],   # U3 : goûts variés
    [5, 4, 2, 4, 1],   # U4 : Action fan
    [1, 2, 5, 1, 5],   # U5 : Romance fan
], dtype=float)

ratings_df = pd.DataFrame(R, index=users, columns=movies)
print("=== Matrice Utilisateur-Film (0 = non vu) ===")
print(ratings_df)
print(f"\nNotes observées : {int(np.count_nonzero(R))} / {R.size} ({100*np.count_nonzero(R)/R.size:.0f}% rempli)")
=== Matrice Utilisateur-Film (0 = non vu) ===
    Matrix  Inception  Titanic  Interstellar  Le Notebook
U1     5.0        4.0      1.0           5.0          0.0
U2     2.0        1.0      5.0           0.0          5.0
U3     4.0        3.0      4.0           4.0          3.0
U4     5.0        4.0      2.0           4.0          1.0
U5     1.0        2.0      5.0           1.0          5.0

Notes observées : 23 / 25 (92% rempli)

Factorisation de matrice (NMF)

La factorisation de matrice (Koren, Bell & Volinsky, 2009) décompose la matrice utilisateur-item \(R\) (\(U \times I\)) en deux matrices de plus faible rang :

\[R \approx W \cdot H\]

où \(W\) est \(U \times K\) (profils latents des utilisateurs) et \(H\) est \(K \times I\) (profils latents des items). K est le nombre de facteurs latents (features cachées apprises).

Interprétation : avec \(K=2\), l’algorithme peut découvrir automatiquement deux axes latents — par exemple « affinité Sci-Fi/Action » et « affinité Romance » — sans qu’on lui ait jamais dit quels films sont quel genre. C’est la puissance du filtrage collaboratif : les goûts émergent des notes, pas des métadonnées.

On utilise NMF (Non-negative Matrix Factorization) de scikit-learn : elle contraint \(W\) et \(H\) à être non-négatifs, ce qui convient bien aux notes (1-5) et rend les facteurs latents interprétables (chaque facteur est une « composante de goût » additive). C’est l’équivalent Python idiomatique du trainer MatrixFactorization de ML.NET, qui apprend lui aussi des facteurs latents utilisateur/item par descente de gradient.

Note sur les zéros. NMF traite les 0 (notes manquantes) comme des valeurs à reconstruire. C’est une approximation (le 0 « non vu » est confondu avec le 0 « noté 0 ») ; sur une petite matrice clairement structurée, elle reste très pédagogique. Les vrais systèmes gèrent le caractère creux différemment (SGD sur les notes observées seulement).

# Factorisation : K=2 facteurs latents (la matrice a 2 axes de goût dominants : Sci-Fi vs Romance)
K = 2
nmf = NMF(n_components=K, init="nndsvda", solver="mu", max_iter=500, random_state=42)
W = nmf.fit_transform(R)      # (5 utilisateurs, K) : profils latents utilisateurs
H = nmf.components_           # (K, 5 films) : profils latents films
R_hat = W @ H                 # matrice reconstruite (prédictions de toutes les notes)

print(f"NMF ajustée (K={K} facteurs latents) | erreur de reconstruction : {nmf.reconstruction_err_:.3f}")
print("\n=== Matrice reconstruite R_hat (notes prédites) ===")
print(pd.DataFrame(R_hat.round(2), index=users, columns=movies))
NMF ajustée (K=2 facteurs latents) | erreur de reconstruction : 1.493

=== Matrice reconstruite R_hat (notes prédites) ===
    Matrix  Inception  Titanic  Interstellar  Le Notebook
U1    5.07       3.97     1.11          4.93         0.05
U2    1.33       1.28     5.06          0.45         5.00
U3    4.17       3.41     3.81          3.54         3.08
U4    4.75       3.77     2.02          4.44         1.07
U5    1.62       1.51     5.05          0.74         4.93

Interpréter les facteurs latents

Regardons les profils latents des films (\(H\)) : chaque film est décrit par ses coordonnées sur les \(K\) facteurs. Si l’algorithme a bien capturé la structure, un facteur devrait opposer les Sci-Fi aux romances.

print("=== Profils latents des films (H) ===")
print(pd.DataFrame(H, index=[f"Facteur {i+1}" for i in range(K)], columns=movies).round(3))
print("\n=> Un facteur 'pèse' fort sur Matrix/Inception/Interstellar (Sci-Fi), l'autre sur Titanic/Le Notebook (Romance).")
=== Profils latents des films (H) ===
           Matrix  Inception  Titanic  Interstellar  Le Notebook
Facteur 1   0.873      0.883    4.154         0.143        4.158
Facteur 2   3.880      3.035    0.841         3.773        0.029

=> Un facteur 'pèse' fort sur Matrix/Inception/Interstellar (Sci-Fi), l'autre sur Titanic/Le Notebook (Romance).

Prédire les notes manquantes

Les cellules 0 (films non vus) sont maintenant reconstruites par \(R\_hat\). Ce sont nos prédictions : la note qu’on s’attendrait à ce que l’utilisateur attribue s’il voyait le film.

# Prédictions pour les notes manquantes (cellules où R == 0)
print("=== Notes prédites pour les films non vus ===")
for u in range(len(users)):
    for i in range(len(movies)):
        if R[u, i] == 0:
            print(f"  {users[u]} -> {movies[i]:<12} : note prédite {R_hat[u, i]:.2f}")

print("\nLecture :")
print("  U1 (Sci-Fi) n'a pas vu 'Le Notebook' (Romance) -> note prédite basse (cohérent).")
print("  U2 (Romance) n'a pas vu 'Interstellar' (Sci-Fi) -> note prédite basse (cohérent).")
=== Notes prédites pour les films non vus ===
  U1 -> Le Notebook  : note prédite 0.05
  U2 -> Interstellar : note prédite 0.45

Lecture :
  U1 (Sci-Fi) n'a pas vu 'Le Notebook' (Romance) -> note prédite basse (cohérent).
  U2 (Romance) n'a pas vu 'Interstellar' (Sci-Fi) -> note prédite basse (cohérent).

Générer les Top-N recommandations

Pour chaque utilisateur, on recommande les N films non vus dont la note prédite est la plus élevée.

def recommend_top_n(user_idx, R, R_hat, movie_names, top_n=3):
    """Renvoie les top_n films non vus (note prédite la plus élevée) pour un utilisateur."""
    unseen = np.where(R[user_idx] == 0)[0]
    scored = [(movie_names[i], float(R_hat[user_idx, i])) for i in unseen]
    scored.sort(key=lambda x: -x[1])
    return scored[:top_n]

print("=== Top-3 recommandations par utilisateur ===")
for u in range(len(users)):
    recs = recommend_top_n(u, R, R_hat, movies, top_n=3)
    if recs:
        recs_str = ", ".join(f"{m} ({s:.2f})" for m, s in recs)
        print(f"  {users[u]} : {recs_str}")
    else:
        print(f"  {users[u]} : (a tout vu)")
=== Top-3 recommandations par utilisateur ===
  U1 : Le Notebook (0.05)
  U2 : Interstellar (0.45)
  U3 : (a tout vu)
  U4 : (a tout vu)
  U5 : (a tout vu)

Évaluation : RMSE sur les notes connues

Un système de recommandation se laisse évaluer dès lors qu’on a des notes de référence. On compare les notes prédites aux notes réelles sur les cellules observées (non nulles) via le RMSE (root mean squared error) : plus il est faible, mieux la factorisation reconstruit les goûts.

mask = R > 0                                  # notes observées
rmse = np.sqrt(np.mean((R[mask] - R_hat[mask]) ** 2))
mae = np.mean(np.abs(R[mask] - R_hat[mask]))
print(f"=== Qualité de la reconstruction sur les notes observées ===")
print(f"  RMSE : {rmse:.3f}  (échelle des notes : 1-5)")
print(f"  MAE  : {mae:.3f}")
print("\nUn RMSE ~0.3 sur cette petite matrice tres structuree indique une excellente reconstruction (K=2 capture bien l'axe Sci-Fi vs Romance).")
=== Qualité de la reconstruction sur les notes observées ===
  RMSE : 0.297  (échelle des notes : 1-5)
  MAE  : 0.222

Un RMSE ~0.3 sur cette petite matrice tres structuree indique une excellente reconstruction (K=2 capture bien l'axe Sci-Fi vs Romance).

Le Cold Start Problem (Schein et al., 2002)

La factorisation de matrice a une limite structurelle : elle ne sait pas quoi recommander à un nouvel utilisateur (aucune note → pas de profil latent) ni comment noter un nouvel item (aucune note reçue → pas de profil latent non plus). C’est le cold start problem.

  • Nouvel utilisateur : la ligne correspondante dans \(R\) est vide, NMF ne peut pas positionner l’utilisateur dans l’espace latent. Solution hybride : demander quelques notes initiales (« cold start questionnaire ») ou utiliser des features démographiques.
  • Nouvel item : la colonne est vide, NMF ne peut pas le caractériser. Solution : utiliser un filtre content-based (genre, description) en attendant les premières notes.

Les systèmes industriels (Netflix, Spotify) combinent CF + content-based + popularité pour mitiger le cold start.

Exercice 1 : Enrichir le jeu de données

Ajouter un 6ᵉ film et un 6ᵉ utilisateur change la matrice et donc les facteurs latents.

Objectifs : 1. Ajouter un film (colonne) et un utilisateur (ligne) à R, avec un profil de goût cohérent 2. Ré-ajuster la NMF et comparer les facteurs latents des films 3. Constater que la structure Sci-Fi vs Romance émerge toujours

Indice : définis une nouvelle matrice R2 (6×6), ré-invoie NMF(n_components=2).fit_transform(R2), affiche H.

# Exercice 1 : Enrichir le jeu de données
# TODO étudiant : ajoute un film + un utilisateur à R, ré-ajuste la NMF, compare les facteurs latents.
# Indice : R2 = np.vstack/np.column_stack pour étendre ; NMF(n_components=2).fit_transform(R2) ; affiche H.
# Étape 1 : construis R2 (6x6) avec un nouveau film (colonne) et un nouvel utilisateur (ligne)
# Étape 2 : ajuste NMF(K=2) sur R2
# Étape 3 : affiche les profils latents des films et compare à H ci-dessus
result_ex1 = None  # TODO étudiant
print("Exercice 1 à compléter")
Exercice 1 à compléter

Exercice 2 : Similarité entre utilisateurs

Deux utilisateurs aux goûts proches devraient avoir des profils latents (\(W\)) similaires.

Objectifs : 1. Calculer la similarité cosinus entre tous les couples de lignes de \(W\) (profils utilisateurs) 2. Identifier les paires d’utilisateurs les plus similaires 3. Vérifier que U1/U4 (Sci-Fi) et U2/U5 (Romance) sont bien regroupés

Indice : sklearn.metrics.pairwise.cosine_similarity(W) renvoie la matrice 5×5 des similarités.

# Exercice 2 : Similarité entre utilisateurs (sur les facteurs latents W)
# TODO étudiant : similarité cosinus entre les lignes de W, identifier les paires les plus similaires.
# Indice : from sklearn.metrics.pairwise import cosine_similarity ; S = cosine_similarity(W).
# Étape 1 : calcule la matrice de similarité cosinus entre les profils latents utilisateurs (W)
# Étape 2 : identifie les paires les plus similaires (hors diagonale)
# Étape 3 : vérifie que les fans Sci-Fi (U1/U4) et Romance (U2/U5) sont regroupés
result_ex2 = None  # TODO étudiant
print("Exercice 2 à compléter")
Exercice 2 à compléter

Exercice 3 : Effet du nombre de facteurs latents sur le RMSE

Le paramètre \(K\) (nombre de facteurs latents) contrôle le compromis biais/variance : trop petit, le modèle sous-ajuste (ne capture pas assez de goûts) ; trop grand, il sur-ajuste (reconstruit le bruit).

Objectifs : 1. Boucler sur \(K = 1, 2, 3, 4, 5\) 2. Pour chaque \(K\), ajuster une NMF et calculer le RMSE sur les notes observées 3. Identifier le \(K\) optimal et discuter du sur-ajustement quand \(K\) approche le rang de la matrice

Indice : réutilise le calcul de RMSE (np.sqrt(np.mean((R[mask] - R_hat[mask])**2))) dans une boucle sur n_components. Quand \(K\) = nombre de films, NMF peut reconstruire exactement (RMSE → 0) mais perd tout pouvoir de généralisation.

# Exercice 3 : Effet du nombre de facteurs latents (K) sur le RMSE
# TODO étudiant : boucle sur K = 1..5, calcule le RMSE sur les notes observées pour chaque K.
# Indice : NMF(n_components=K) -> R_hat = W @ H -> RMSE sur R[mask>0]. Quand K = nb_films, RMSE ~0 (sur-ajustement).
# Étape 1 : pour K dans {1,2,3,4,5}, ajuste NMF(K) et calcule le RMSE sur les notes observées
# Étape 2 : affiche K | RMSE
# Étape 3 : identifie le K optimal (compromis sous-ajustement / sur-ajustement)
result_ex3 = None  # TODO étudiant
print("Exercice 3 à compléter")
Exercice 3 à compléter

Résumé

Ce notebook a introduit les systèmes de recommandation par factorisation de matrice en Python :

Concept Description
Système de recommandation Suggère des items pertinents (CF, content-based, hybride)
Matrice utilisateur-item Notes \(R\) (\(U \times I\)) ; creuse dans les vrais systèmes (notre jouet est presque complet : 23/25)
sklearn.decomposition.NMF Factorisation non-négative : \(R \approx W \cdot H\) (facteurs latents)
Facteurs latents Axes de goût appris automatiquement (interprétables en NMF)
Top-N Films non vus classés par note prédite décroissante
RMSE Écart entre notes prédites et réelles sur les cellules observées
Cold start Limitation : nouvel utilisateur / nouvel item sans profil latent

Points clés :

  • Le filtrage collaboratif n’utilise que les notes passées ; les goûts (Sci-Fi vs Romance) émergent des données sans métadonnées.
  • NMF contraint les facteurs à être non-négatifs → interprétables comme des composantes de goût additives.
  • Le cold start est la limite structurelle : il faut des notes initiales (ou un hybride content-based) pour un nouvel utilisateur/item.

Équivalence ML.NET : ce notebook est le jumeau Python de ML-7-Recommendation.ipynb. NMF reproduit fidèlement l’esprit du trainer MatrixFactorization de ML.NET (tous deux apprennent des facteurs latents utilisateur/item) ; NMF est l’implémentation scikit-learn idiomatique pour des notes positives.

Références

  • Koren, Y., Bell, R., & Volinsky, C. (2009). Matrix Factorization Techniques for Recommender Systems. IEEE Computer, 42(8). — la référence canonique (prix Netflix).
  • Schein, A. I., Popescul, A., Ungar, L. H., & Pennock, D. M. (2002). Methods and metrics for cold-start recommendations. ACM SIGIR. — le cold start problem.
  • Lee, D. D., & Seung, H. S. (2001). Algorithms for Non-negative Matrix Factorization. NeurIPS. — l’algorithme NMF.
  • Aggarwal, C. C. (2016). Recommender Systems: The Textbook. Springer. — cadre général (CF, content-based, hybride, évaluation).
Retour au sommet