🎓 Parcours D.I.A.M · Intelligence Artificielle & Machine Learning

Systèmes de
Recommandation
Zero → Hero

De Netflix à Spotify, comprendre et implémenter les algorithmes qui personnalisent l'expérience de milliards d'utilisateurs. Cours complets + exercices pratiques.

6
Modules
20+
Exercices
15h
de contenu
1
Projet Final

Votre parcours d'apprentissage

Un chemin structuré du débutant à l'expert en systèmes de recommandation

🌱
Débutant
Module 1 · Fondations
Concepts de base, matrices, similarité
🤝
Intermédiaire
Module 2 · Filtrage Collaboratif
User-Based & Item-Based CF, SVD
📝
Intermédiaire
Module 3 · Filtrage par Contenu
TF-IDF, embeddings, profils utilisateurs
🔀
Avancé
Module 4 · Systèmes Hybrides
Combinaison des approches, cold start
🧠
Avancé
Module 5 · Deep Learning
NCF, AutoEncoders, Transformers
🏆
Expert
Module 6 · Projet Final
Moteur complet de bout en bout

Contenu du cours

Cliquez sur un module pour accéder aux cours et exercices

🌱
Module 01 · Débutant · ~2h
Fondations des Systèmes de Recommandation
Comprendre ce qu'est un système de recommandation, les types de données et les métriques d'évaluation
Débutant Théorie Maths Python

📖 Cours — Fondations

📌 Définition

Un système de recommandation est un filtre d'information qui prédit la "note" ou la "préférence" qu'un utilisateur donnerait à un item. Objectif : présenter les items les plus pertinents à chaque utilisateur.

🏗️ Les 3 grandes familles

🤝 Filtrage Collaboratif

Exploite le comportement collectif des utilisateurs. "Des utilisateurs similaires à toi ont aimé ces items."

📝 Filtrage par Contenu

Analyse les attributs des items. "Tu as aimé ce film d'action avec X, voici d'autres films similaires."

🔀 Systèmes Hybrides

Combine plusieurs approches pour tirer parti de leurs forces et compenser leurs faiblesses.

📊 La Matrice Utilisateur-Item

Le cœur de tout système de recommandation est la matrice R de taille (m × n) où m = utilisateurs, n = items. Les cellules contiennent les notes (explicites) ou les interactions (implicites).

User \ Film 🦁 Lion 🚀 Interstellar 😂 Intouchables 👻 Get Out 🕷️ Spider-Man
Alice 5 4 ? 2 ?
Bob ? 5 1 ? 4
Carol 3 ? 5 4 ?
Dave ? 3 ? 5 3

💡 Les ? sont les valeurs à prédire. La matrice est typiquement très sparse (95-99% de valeurs manquantes).

📏 Métriques d'évaluation

RMSE (Root Mean Square Error)

Mesure l'erreur de prédiction des notes. Pénalise fortement les grandes erreurs.

MAE (Mean Absolute Error)

Erreur absolue moyenne. Plus robuste aux outliers que le RMSE.

Precision@K & Recall@K

Évalue la qualité des Top-K recommandations présentées à l'utilisateur.

NDCG@K

Normalized Discounted Cumulative Gain. Tient compte de l'ordre des recommandations.

📐 Formules clés — Métriques
RMSE = √( (1/n) × Σ(r̂ᵢ - rᵢ)² )

MAE = (1/n) × Σ|r̂ᵢ - rᵢ|

Precision@K = |items pertinents ∩ Top-K| / K

Recall@K = |items pertinents ∩ Top-K| / |items pertinents|
r̂ᵢ = note prédite · rᵢ = note réelle · n = nombre de prédictions
🐍 Python — Métriques de base
import numpy as np
import pandas as pd
from sklearn.metrics import mean_squared_error, mean_absolute_error

# ─── Création d'une matrice utilisateur-item sparse ───────────────
data = {
    'user':   ['Alice', 'Alice', 'Bob',   'Bob',   'Carol', 'Carol'],
    'item':   ['Lion',  'Inter', 'Inter', 'Spider','Lion',  'Intou'],
    'rating': [5,       4,       5,       4,       3,       5      ]
}
df = pd.DataFrame(data)

# ─── Pivot : matrice R (users × items) ───────────────────────────
R = df.pivot(index='user', columns='item', values='rating')
print("Matrice Utilisateur-Item :")
print(R)
print(f"\nSparsité : {R.isna().sum().sum() / R.size * 100:.1f}%")

# ─── Calcul des métriques ─────────────────────────────────────────
def compute_metrics(y_true, y_pred):
    """Calcule RMSE et MAE."""
    rmse = np.sqrt(mean_squared_error(y_true, y_pred))
    mae  = mean_absolute_error(y_true, y_pred)
    return {'RMSE': round(rmse, 4), 'MAE': round(mae, 4)}

# Exemple
y_true = [5, 4, 3, 2, 5]
y_pred = [4.5, 3.8, 3.2, 2.5, 4.7]
metrics = compute_metrics(y_true, y_pred)
print(f"\nRMSE : {metrics['RMSE']} | MAE : {metrics['MAE']}")

# ─── Precision@K et Recall@K ─────────────────────────────────────
def precision_recall_at_k(recommended, relevant, k):
    """
    recommended : liste ordonnée des items recommandés
    relevant    : set des items réellement pertinents
    k           : nombre de recommandations à évaluer
    """
    top_k   = recommended[:k]
    hits    = len(set(top_k) & set(relevant))
    precision = hits / k
    recall    = hits / len(relevant) if relevant else 0
    return precision, recall

recommended = ['Spider-Man', 'Interstellar', 'Get Out', 'Lion']
relevant     = {'Interstellar', 'Get Out'}
p, r = precision_recall_at_k(recommended, relevant, k=3)
print(f"\nPrecision@3 : {p:.2f} | Recall@3 : {r:.2f}")

✏️ Exercices — Module 1

📊

Exercice 1.1 — Construire une matrice utilisateur-item

Facile ⏱️ 15 min

Vous disposez du dataset suivant de notes de films (1 à 5 étoiles). Créez la matrice utilisateur-item, calculez la sparsité et affichez les statistiques descriptives.

🐍 Données de départ
ratings_data = [
    ("User1", "Inception",     5),
    ("User1", "The Matrix",    4),
    ("User1", "Titanic",       2),
    ("User2", "Inception",     4),
    ("User2", "Avengers",      5),
    ("User2", "Titanic",       3),
    ("User3", "The Matrix",    5),
    ("User3", "Avengers",      4),
    ("User3", "Interstellar",  5),
    ("User4", "Titanic",       5),
    ("User4", "Interstellar",  3),
    ("User5", "Inception",     3),
    ("User5", "Avengers",      4),
    ("User5", "Interstellar",  4),
]

# TODO :
# 1. Créer un DataFrame pandas
# 2. Générer la matrice pivot (users × items)
# 3. Calculer et afficher la sparsité
# 4. Afficher : note moyenne par user, note moyenne par film
# 5. Trouver l'utilisateur le plus actif et le film le plus noté
💡 Indice

Utilisez pd.pivot_table() avec aggfunc='mean'. La sparsité = proportion de NaN dans la matrice.

✅ Solution — Exercice 1.1
import pandas as pd
import numpy as np

ratings_data = [
    ("User1", "Inception",    5),
    ("User1", "The Matrix",   4),
    ("User1", "Titanic",      2),
    ("User2", "Inception",    4),
    ("User2", "Avengers",     5),
    ("User2", "Titanic",      3),
    ("User3", "The Matrix",   5),
    ("User3", "Avengers",     4),
    ("User3", "Interstellar", 5),
    ("User4", "Titanic",      5),
    ("User4", "Interstellar", 3),
    ("User5", "Inception",    3),
    ("User5", "Avengers",     4),
    ("User5", "Interstellar", 4),
]

# 1. DataFrame
df = pd.DataFrame(ratings_data, columns=['user', 'item', 'rating'])

# 2. Matrice pivot
R = df.pivot_table(index='user', columns='item', values='rating')

# 3. Sparsité
n_total   = R.size
n_missing = R.isna().sum().sum()
sparsity  = n_missing / n_total * 100
print(f"📊 Matrice {R.shape[0]} users × {R.shape[1]} films")
print(f"🕳️  Sparsité : {sparsity:.1f}% ({n_missing}/{n_total} valeurs manquantes)\n")
print(R.to_string())

# 4. Statistiques
print("\n📈 Note moyenne par utilisateur :")
print(df.groupby('user')['rating'].mean().round(2))

print("\n🎬 Note moyenne par film :")
print(df.groupby('item')['rating'].mean().round(2))

# 5. Records
most_active = df['user'].value_counts().idxmax()
most_rated  = df['item'].value_counts().idxmax()
print(f"\n🏆 Utilisateur le plus actif : {most_active}")
print(f"🎬 Film le plus noté : {most_rated}")
📏

Exercice 1.2 — Calcul manuel des métriques

Moyen ⏱️ 20 min

Implémentez from scratch (sans sklearn) les fonctions RMSE, MAE, Precision@K, Recall@K et NDCG@K. Validez vos résultats en les comparant à sklearn.

💡 NDCG@K

DCG@K = Σ(rᵢ / log₂(i+1)) pour i=1..K. NDCG@K = DCG@K / IDCG@K où IDCG est le DCG idéal (items triés par pertinence décroissante).

✅ Solution — Exercice 1.2
import numpy as np

# ─── RMSE & MAE from scratch ─────────────────────────────────────
def rmse(y_true, y_pred):
    y_true, y_pred = np.array(y_true), np.array(y_pred)
    return np.sqrt(np.mean((y_true - y_pred) ** 2))

def mae(y_true, y_pred):
    y_true, y_pred = np.array(y_true), np.array(y_pred)
    return np.mean(np.abs(y_true - y_pred))

# ─── Precision@K & Recall@K ──────────────────────────────────────
def precision_at_k(recommended, relevant, k):
    top_k = recommended[:k]
    hits  = len(set(top_k) & set(relevant))
    return hits / k

def recall_at_k(recommended, relevant, k):
    top_k = recommended[:k]
    hits  = len(set(top_k) & set(relevant))
    return hits / len(relevant) if relevant else 0

# ─── NDCG@K ──────────────────────────────────────────────────────
def dcg_at_k(scores, k):
    scores = np.array(scores[:k], dtype=float)
    gains  = scores / np.log2(np.arange(2, len(scores) + 2))
    return np.sum(gains)

def ndcg_at_k(recommended_scores, k):
    """
    recommended_scores : pertinence des items dans l'ordre recommandé
    ex: [1, 0, 1, 0, 1] -> 1=pertinent, 0=non pertinent
    """
    dcg  = dcg_at_k(recommended_scores, k)
    idcg = dcg_at_k(sorted(recommended_scores, reverse=True), k)
    return dcg / idcg if idcg > 0 else 0

# ─── Tests ───────────────────────────────────────────────────────
y_true = [5, 3, 4, 2, 5]
y_pred = [4.5, 3.2, 4.1, 1.8, 4.9]

print(f"RMSE : {rmse(y_true, y_pred):.4f}")
print(f"MAE  : {mae(y_true, y_pred):.4f}")

recommended = ['A', 'B', 'C', 'D', 'E']
relevant     = {'A', 'C', 'E'}
print(f"\nPrecision@3 : {precision_at_k(recommended, relevant, 3):.3f}")
print(f"Recall@3    : {recall_at_k(recommended, relevant, 3):.3f}")

scores = [1, 0, 1, 0, 1]
print(f"NDCG@5      : {ndcg_at_k(scores, 5):.4f}")

🧠 Quiz — Module 1

1. Une matrice utilisateur-item avec 90% de valeurs manquantes est dite...

A. Dense
B. Sparse
C. Complète
D. Saturée

2. Quelle métrique pénalise davantage les grandes erreurs de prédiction ?

A. MAE
B. RMSE
C. Precision@K
D. NDCG
🤝
Module 02 · Intermédiaire · ~3h
Filtrage Collaboratif (CF)
User-Based CF, Item-Based CF, SVD et Factorisation de Matrices
Intermédiaire Similarité SVD Surprise

📖 Cours — Filtrage Collaboratif

💡 Principe fondamental

Le filtrage collaboratif repose sur l'hypothèse : "Des utilisateurs qui ont eu des goûts similaires dans le passé auront des goûts similaires à l'avenir." Aucune information sur le contenu des items n'est nécessaire.

📐 Mesures de similarité

📐 Similarité Cosinus
cos(u, v) = (u · v) / (||u|| × ||v||)

cos(u, v) = Σᵢ(uᵢ × vᵢ) / √(Σᵢuᵢ²) × √(Σᵢvᵢ²)
Valeur entre -1 et 1. 1 = parfaitement similaire · 0 = orthogonaux · -1 = opposés
📐 Corrélation de Pearson (meilleure pour CF)
sim(u, v) = Σᵢ∈Iᵤᵥ (rᵤᵢ - r̄ᵤ)(rᵥᵢ - r̄ᵥ)
─────────────────────────────────────────────
√[Σᵢ∈Iᵤᵥ (rᵤᵢ - r̄ᵤ)²] × √[Σᵢ∈Iᵤᵥ (rᵥᵢ - r̄ᵥ)²]
Iᵤᵥ = items co-notés par u et v · r̄ᵤ = note moyenne de l'utilisateur u

🧮 Prédiction User-Based CF

📐 Prédiction de note — User-Based avec biais
r̂ᵤᵢ = r̄ᵤ + Σₙ∈N sim(u,n) × (rₙᵢ - r̄ₙ)
────────────────────────────────────────────
Σₙ∈N |sim(u,n)|
N = top-K voisins de u · r̄ᵤ = note moyenne de u · sim(u,n) = similarité entre u et n

🎯 SVD — Factorisation de Matrices

🚀 Pourquoi la factorisation ?

Au lieu de chercher des voisins, on décompose R ≈ P × Qᵀ où P représente les profils latents utilisateurs et Q les profils latents items dans un espace de k dimensions.

📐 Objectif d'optimisation SVD (Simon Funk)
min Σ(u,i)∈K (rᵤᵢ - pᵤᵀqᵢ)² + λ(||pᵤ||² + ||qᵢ||²)
pᵤ ∈ ℝᵏ = vecteur latent utilisateur · qᵢ ∈ ℝᵏ = vecteur latent item · λ = régularisation
🐍 Python — User-Based CF from scratch
import numpy as np
import pandas as pd
from sklearn.metrics.pairwise import cosine_similarity
from scipy.stats import pearsonr

class UserBasedCF:
    """
    Filtrage Collaboratif basé Utilisateur.
    Utilise la corrélation de Pearson + biais utilisateur.
    """
    def __init__(self, n_neighbors=5):
        self.n_neighbors   = n_neighbors
        self.rating_matrix = None
        self.similarity    = None
        self.user_means    = None

    def fit(self, rating_matrix):
        """Entraîne le modèle sur la matrice de notes."""
        self.rating_matrix = rating_matrix.copy()
        self.user_means    = rating_matrix.mean(axis=1)

        # Centrer la matrice (soustraction de la moyenne par user)
        R_centered = rating_matrix.sub(self.user_means, axis=0).fillna(0)

        # Matrice de similarité utilisateurs (Pearson via cosinus centré)
        sim_matrix = cosine_similarity(R_centered)
        self.similarity = pd.DataFrame(
            sim_matrix,
            index=rating_matrix.index,
            columns=rating_matrix.index
        )
        print(f"✅ Modèle entraîné : {len(rating_matrix)} users, {rating_matrix.shape[1]} items")
        return self

    def get_neighbors(self, user, item=None):
        """Retourne les k voisins les plus similaires ayant noté l'item."""
        sims = self.similarity[user].drop(user).sort_values(ascending=False)

        if item is not None:
            # Garder seulement les voisins ayant noté l'item
            has_rated = self.rating_matrix[item].dropna().index
            sims      = sims[sims.index.isin(has_rated)]

        return sims.head(self.n_neighbors)

    def predict(self, user, item):
        """Prédit la note de l'utilisateur pour un item."""
        if item not in self.rating_matrix.columns:
            return self.user_means[user]  # fallback

        neighbors = self.get_neighbors(user, item)

        if len(neighbors) == 0:
            return self.user_means[user]

        numerator   = 0
        denominator = 0

        for neighbor, sim_score in neighbors.items():
            r_ni = self.rating_matrix.loc[neighbor, item]
            r_n_mean = self.user_means[neighbor]
            numerator   += sim_score * (r_ni - r_n_mean)
            denominator += abs(sim_score)

        pred = self.user_means[user] + (numerator / denominator if denominator > 0 else 0)
        return float(np.clip(pred, 1, 5))

    def recommend(self, user, n=5):
        """Retourne les n meilleures recommandations pour un utilisateur."""
        rated_items    = self.rating_matrix.loc[user].dropna().index
        unrated_items  = [i for i in self.rating_matrix.columns if i not in rated_items]

        predictions = {}
        for item in unrated_items:
            predictions[item] = self.predict(user, item)

        top_n = sorted(predictions.items(), key=lambda x: x[1], reverse=True)[:n]
        return top_n

# ─── Exemple d'utilisation ───────────────────────────────────────
data = {
    'Alice': {'Item1':5, 'Item2':3, 'Item3':4, 'Item4':None, 'Item5':1},
    'Bob':   {'Item1':4, 'Item2':None, 'Item3':4, 'Item4':1, 'Item5':1},
    'Carol': {'Item1':None, 'Item2':2, 'Item3':None, 'Item4':4, 'Item5':5},
    'Dave':  {'Item1':1, 'Item2':1, 'Item3':None, 'Item4':5, 'Item5':4},
    'Eve':   {'Item1':3, 'Item2':3, 'Item3':5, 'Item4':None, 'Item5':None},
}

R = pd.DataFrame(data).T

model = UserBasedCF(n_neighbors=3)
model.fit(R)

# Prédiction
pred = model.predict('Alice', 'Item4')
print(f"\n🔮 Prédiction Alice → Item4 : {pred:.2f}/5")

# Top recommandations
recs = model.recommend('Alice', n=3)
print("\n🎯 Top recommandations pour Alice :")
for item, score in recs:
    print(f"   {item} : {score:.2f}⭐")
🐍 Python — SVD avec la librairie Surprise
from surprise import SVD, Dataset, Reader, accuracy
from surprise.model_selection import cross_validate, train_test_split
import pandas as pd

# ─── Chargement des données ───────────────────────────────────────
# pip install scikit-surprise
reader = Reader(rating_scale=(1, 5))
data   = Dataset.load_builtin('ml-100k')  # MovieLens 100K

# ─── Split train/test ─────────────────────────────────────────────
trainset, testset = train_test_split(data, test_size=0.2, random_state=42)

# ─── Modèle SVD ──────────────────────────────────────────────────
algo = SVD(
    n_factors=50,       # nombre de facteurs latents
    n_epochs=20,        # itérations de descente de gradient
    lr_all=0.005,       # learning rate
    reg_all=0.02,       # régularisation L2
    biased=True,        # inclure les biais user/item
    random_state=42
)

# ─── Entraînement ────────────────────────────────────────────────
algo.fit(trainset)

# ─── Évaluation ──────────────────────────────────────────────────
predictions = algo.test(testset)
print(f"RMSE : {accuracy.rmse(predictions, verbose=False):.4f}")
print(f"MAE  : {accuracy.mae(predictions,  verbose=False):.4f}")

# ─── Cross-validation 5-fold ─────────────────────────────────────
results = cross_validate(
    SVD(), data,
    measures=['RMSE', 'MAE'],
    cv=5, verbose=True
)

# ─── Prédiction pour un utilisateur ──────────────────────────────
uid    = "196"   # user ID (string pour Surprise)
iid    = "302"   # item ID
pred   = algo.predict(uid, iid)
print(f"\n🔮 Prédiction User {uid} → Item {iid} : {pred.est:.2f}/5")

# ─── Top-N recommandations ────────────────────────────────────────
from collections import defaultdict

def get_top_n(predictions, n=10):
    """Construit le Top-N pour chaque utilisateur."""
    top_n = defaultdict(list)
    for uid, iid, true_r, est, _ in predictions:
        top_n[uid].append((iid, est))
    for uid, user_ratings in top_n.items():
        user_ratings.sort(key=lambda x: x[1], reverse=True)
        top_n[uid] = user_ratings[:n]
    return top_n

top_n = get_top_n(predictions, n=5)
print(f"\n🎯 Top-5 pour User 196 :")
for item, score in top_n["196"]:
    print(f"   Item {item} : {score:.2f}⭐")

⚖️ Comparaison CF : User-Based vs Item-Based

Critère User-Based CF Item-Based CF SVD / MF
Scalabilité Lente (m² similarités) Meilleure (items stables) ✓✓ Excellente
Précision Bonne Bonne/Très bonne ✓✓ Très haute
Cold Start User Problème Problème Problème
Explicabilité Facile ✓✓ Très facile Difficile
Mémoire O(m²) O(n²) O((m+n)×k)

✏️ Exercices — Module 2

🤝

Exercice 2.1 — Item-Based CF

Moyen ⏱️ 30 min

Implémentez un système Item-Based CF en calculant la matrice de similarité entre items (cosinus). Pour un utilisateur donné et un item non noté, prédisez la note en utilisant les items similaires que l'utilisateur a déjà notés.

💡 Différence clé

Dans Item-Based CF, on transpose la logique : au lieu de chercher des voisins parmi les utilisateurs, on cherche des items similaires à celui qu'on veut prédire. La similarité se calcule sur les colonnes de R.

✅ Solution — Item-Based CF
import numpy as np
import pandas as pd
from sklearn.metrics.pairwise import cosine_similarity

class ItemBasedCF:
    def __init__(self, n_neighbors=3):
        self.n_neighbors   = n_neighbors
        self.rating_matrix = None
        self.item_sim      = None

    def fit(self, R):
        self.rating_matrix = R.copy()
        R_filled = R.fillna(0)
        # Transposée : similarity entre items (colonnes)
        sim = cosine_similarity(R_filled.T)
        self.item_sim = pd.DataFrame(sim, index=R.columns, columns=R.columns)
        print(f"✅ Matrice similarité items : {sim.shape}")
        return self

    def predict(self, user, item):
        # Items déjà notés par l'utilisateur
        rated   = self.rating_matrix.loc[user].dropna()
        if item not in self.item_sim.columns:
            return rated.mean()

        # Similarité entre l'item cible et les items notés
        sims    = self.item_sim[item].loc[rated.index]
        top_k   = sims.nlargest(self.n_neighbors)

        numerator   = (top_k * rated.loc[top_k.index]).sum()
        denominator = top_k.abs().sum()

        return float(numerator / denominator) if denominator > 0 else rated.mean()

    def recommend(self, user, n=5):
        rated_items   = self.rating_matrix.loc[user].dropna().index
        unrated_items = [i for i in self.rating_matrix.columns if i not in rated_items]
        preds = {item: self.predict(user, item) for item in unrated_items}
        return sorted(preds.items(), key=lambda x: x[1], reverse=True)[:n]

# Test
data = {
    'Alice': {'A':5, 'B':3, 'C':4, 'D':None},
    'Bob':   {'A':4, 'B':None, 'C':4, 'D':1},
    'Carol': {'A':None, 'B':2, 'C':None, 'D':5},
}
R = pd.DataFrame(data).T
model = ItemBasedCF(n_neighbors=2)
model.fit(R)
print(f"Alice → D : {model.predict('Alice', 'D'):.2f}")
print(f"Recs Alice : {model.recommend('Alice')}")
🎯

Exercice 2.2 — Factorisation Matricielle ALS

Difficile ⏱️ 45 min

Implémentez la factorisation matricielle par ALS (Alternating Least Squares). Fixez alternativement P et optimisez Q, puis fixez Q et optimisez P, jusqu'à convergence. Visualisez la courbe de perte (RMSE) à chaque itération.

💡 Update ALS

Pᵤ = (QᵀQ + λI)⁻¹ Qᵀrᵤ · Qᵢ = (PᵀP + λI)⁻¹ Pᵀrᵢ

✅ Solution — ALS Matrix Factorization
import numpy as np
import matplotlib.pyplot as plt

class ALSRecommender:
    """Matrix Factorization via Alternating Least Squares."""

    def __init__(self, n_factors=10, n_epochs=20, reg=0.1, seed=42):
        self.k       = n_factors
        self.epochs  = n_epochs
        self.reg     = reg
        np.random.seed(seed)

    def fit(self, R):
        """
        R : numpy array (m x n) avec np.nan pour les valeurs manquantes
        """
        self.R     = R.copy()
        self.mask  = ~np.isnan(R)   # True = valeur observée
        m, n       = R.shape

        # Initialisation aléatoire
        self.P = np.random.normal(0, 0.1, (m, self.k))   # users × k
        self.Q = np.random.normal(0, 0.1, (n, self.k))   # items × k

        self.loss_history = []

        for epoch in range(self.epochs):
            # ── Fix Q, Optimiser P ───────────────────────
            for u in range(m):
                rated_i = self.mask[u]
                if rated_i.sum() == 0: continue
                Q_u  = self.Q[rated_i]           # items notés par u
                r_u  = R[u, rated_i]             # notes de u
                A    = Q_u.T @ Q_u + self.reg * np.eye(self.k)
                b    = Q_u.T @ r_u
                self.P[u] = np.linalg.solve(A, b)

            # ── Fix P, Optimiser Q ───────────────────────
            for i in range(n):
                rated_u = self.mask[:, i]
                if rated_u.sum() == 0: continue
                P_i  = self.P[rated_u]
                r_i  = R[rated_u, i]
                A    = P_i.T @ P_i + self.reg * np.eye(self.k)
                b    = P_i.T @ r_i
                self.Q[i] = np.linalg.solve(A, b)

            # ── Calcul RMSE ──────────────────────────────
            R_pred = self.P @ self.Q.T
            errors = (R_pred - np.nan_to_num(R, nan=0)) * self.mask
            rmse   = np.sqrt((errors**2).sum() / self.mask.sum())
            self.loss_history.append(rmse)

            if (epoch + 1) % 5 == 0:
                print(f"Epoch {epoch+1:3d}/{self.epochs} | RMSE: {rmse:.4f}")

        return self

    def predict(self, u, i):
        return float(np.clip(self.P[u] @ self.Q[i], 1, 5))

    def predict_full_matrix(self):
        return np.clip(self.P @ self.Q.T, 1, 5)

    def plot_loss(self):
        plt.figure(figsize=(8, 4))
        plt.plot(self.loss_history, color='#6C63FF', linewidth=2)
        plt.fill_between(range(len(self.loss_history)), self.loss_history, alpha=0.2, color='#6C63FF')
        plt.xlabel('Epoch'); plt.ylabel('RMSE')
        plt.title('Courbe d\'apprentissage ALS')
        plt.grid(alpha=0.3)
        plt.tight_layout()
        plt.show()

# ─── Test ────────────────────────────────────────────────────────
R = np.array([
    [5, 3, np.nan, 1],
    [4, np.nan, 4, 1],
    [1, 1, np.nan, 5],
    [1, np.nan, 2, 4],
], dtype=float)

model = ALSRecommender(n_factors=2, n_epochs=30, reg=0.1)
model.fit(R)
model.plot_loss()

R_pred = model.predict_full_matrix()
print("\n📊 Matrice prédite :\n", np.round(R_pred, 2))
📝
Module 03 · Intermédiaire · ~2.5h
Filtrage par Contenu (Content-Based)
TF-IDF, représentation des items, profils utilisateurs, embeddings
NLP TF-IDF Embeddings Intermédiaire

📖 Cours — Filtrage par Contenu

💡 Principe

Le filtrage par contenu recommande des items similaires à ceux qu'un utilisateur a aimés dans le passé, en analysant les caractéristiques des items (genres, description, acteurs...) et en construisant un profil utilisateur à partir de ses préférences.

📐 TF-IDF — Term Frequency × Inverse Document Frequency
TF(t, d) = (occurrences de t dans d) / (total mots dans d)

IDF(t, D) = log( |D| / |{d ∈ D : t ∈ d}| )

TF-IDF(t, d, D) = TF(t, d) × IDF(t, D)
t = terme · d = document · D = corpus · |D| = taille du corpus
🐍 Python — Content-Based avec TF-IDF
import pandas as pd
import numpy as np
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity

# ─── Dataset de films ─────────────────────────────────────────────
movies = pd.DataFrame({
    'title': [
        'The Matrix', 'Inception', 'Interstellar',
        'The Avengers', 'Iron Man', 'Titanic',
        'Toy Story', 'Finding Nemo'
    ],
    'genres': [
        'action sci-fi thriller',
        'sci-fi thriller mystery',
        'sci-fi drama adventure',
        'action superhero adventure',
        'action superhero comedy',
        'romance drama history',
        'animation comedy adventure family',
        'animation adventure family comedy'
    ],
    'description': [
        'computer hacker discovers reality is a simulation artificial intelligence',
        'thief enters dreams steal secrets subconscious',
        'astronauts travel wormhole save humanity black hole time',
        'superheroes team unite fight alien invasion earth',
        'billionaire genius builds powered armor suit hero',
        'epic romance tragedy ocean ship iceberg',
        'cowboy toy adventures new space ranger friend',
        'clownfish ocean adventure find son reef'
    ]
})

# ─── Feature Engineering : combinaison genres + description ───────
movies['features'] = movies['genres'] + ' ' + movies['description']

# ─── Vectorisation TF-IDF ─────────────────────────────────────────
tfidf = TfidfVectorizer(
    max_features=500,
    stop_words='english',
    ngram_range=(1, 2)  # unigrammes + bigrammes
)
tfidf_matrix = tfidf.fit_transform(movies['features'])
print(f"Matrice TF-IDF : {tfidf_matrix.shape}")

# ─── Matrice de similarité cosinus entre films ────────────────────
cosine_sim = cosine_similarity(tfidf_matrix)
print(f"Matrice similarité : {cosine_sim.shape}")

# ─── Fonction de recommandation ───────────────────────────────────
def content_based_recommend(title, n=3):
    """Recommande n films similaires à 'title'."""
    idx       = movies[movies['title'] == title].index[0]
    sim_scores= list(enumerate(cosine_sim[idx]))
    sim_scores= sorted(sim_scores, key=lambda x: x[1], reverse=True)[1:n+1]

    print(f"\n🎬 Films similaires à '{title}' :")
    for i, score in sim_scores:
        print(f"   {movies['title'].iloc[i]:<20} (similarité: {score:.3f})")

content_based_recommend('The Matrix')
content_based_recommend('Toy Story')

# ─── Profil utilisateur ───────────────────────────────────────────
def build_user_profile(liked_movies, weights=None):
    """
    Construit un vecteur profil utilisateur comme
    moyenne pondérée des vecteurs TF-IDF des films aimés.
    """
    indices = [movies[movies['title'] == m].index[0] for m in liked_movies]
    vectors = tfidf_matrix[indices].toarray()

    if weights:
        weights = np.array(weights)[:, np.newaxis]
        profile = (vectors * weights).sum(axis=0) / weights.sum()
    else:
        profile = vectors.mean(axis=0)

    return profile

def recommend_from_profile(user_profile, seen_movies, n=3):
    """Recommande à partir du profil utilisateur."""
    sims    = cosine_similarity([user_profile], tfidf_matrix.toarray())[0]
    results = pd.Series(sims, index=movies['title'])
    results = results.drop(seen_movies, errors='ignore')
    print("\n👤 Recommandations personnalisées :")
    print(results.nlargest(n).to_string())

# Exemple
liked   = ['The Matrix', 'Inception']
weights = [5, 4]   # notes de l'utilisateur
profile = build_user_profile(liked, weights)
recommend_from_profile(profile, seen_movies=liked)

✏️ Exercices — Module 3

📝

Exercice 3.1 — Recommandation musicale avec embeddings

Moyen ⏱️ 35 min

Créez un système de recommandation musicale. Encodez les chansons avec leurs caractéristiques audio (tempo, énergie, dansabilité, acousticité) et recommandez des chansons similaires à une chanson donnée. Visualisez les chansons dans un espace 2D avec PCA.

💡 Features audio (style Spotify)

Normalisez vos features avec StandardScaler avant de calculer la similarité cosinus. Utilisez PCA(n_components=2) pour la visualisation 2D.

✅ Solution — Recommandation musicale
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.preprocessing import StandardScaler
from sklearn.decomposition import PCA
from sklearn.metrics.pairwise import cosine_similarity

# Dataset synthétique de chansons
np.random.seed(42)
songs = pd.DataFrame({
    'title': [
        'Song A', 'Song B', 'Song C', 'Song D', 'Song E',
        'Song F', 'Song G', 'Song H', 'Song I', 'Song J'
    ],
    'tempo':        [120, 80, 130, 90,  125, 95,  140, 75,  110, 85],
    'energy':       [0.8,0.3,0.9, 0.4, 0.85,0.5, 0.95,0.2, 0.7, 0.35],
    'danceability': [0.7,0.5,0.8, 0.6, 0.75,0.55,0.85,0.4, 0.65,0.5],
    'acousticness': [0.1,0.8,0.05,0.7, 0.15,0.6, 0.02,0.9, 0.2, 0.75],
    'valence':      [0.6,0.4,0.7, 0.5, 0.65,0.45,0.8, 0.3, 0.55,0.42]
})

features = ['tempo', 'energy', 'danceability', 'acousticness', 'valence']
X = songs[features].values

# Normalisation
scaler = StandardScaler()
X_scaled = scaler.fit_transform(X)

# Similarité cosinus
sim_matrix = cosine_similarity(X_scaled)

def recommend_songs(song_title, n=3):
    idx    = songs[songs['title'] == song_title].index[0]
    sims   = list(enumerate(sim_matrix[idx]))
    sims   = sorted(sims, key=lambda x: x[1], reverse=True)[1:n+1]
    print(f"\n🎵 Similaires à '{song_title}' :")
    for i, score in sims:
        print(f"   {songs['title'].iloc[i]} (score: {score:.3f})")

recommend_songs('Song A')

# Visualisation PCA 2D
pca  = PCA(n_components=2)
X_2d = pca.fit_transform(X_scaled)

plt.figure(figsize=(8, 6))
plt.scatter(X_2d[:, 0], X_2d[:, 1], c=songs['energy'],
           cmap='viridis', s=150, alpha=0.8)
for i, row in songs.iterrows():
    plt.annotate(row['title'], (X_2d[i, 0], X_2d[i, 1]),
                xytext=(5, 5), textcoords='offset points', fontsize=9)
plt.colorbar(label='Energy')
plt.title('Espace musical — PCA 2D')
plt.xlabel(f'PC1 ({pca.explained_variance_ratio_[0]*100:.1f}%)')
plt.ylabel(f'PC2 ({pca.explained_variance_ratio_[1]*100:.1f}%)')
plt.tight_layout()
plt.show()
🔀
Module 04 · Avancé · ~2.5h
Systèmes Hybrides & Cold Start
Combiner CF et CB, gérer le cold start, pondération et switching
Avancé Cold Start Hybride Pondération

📖 Cours — Systèmes Hybrides

🔗 Weighted Hybrid

Combine les scores des deux systèmes avec une pondération. score_final = α × score_CF + (1-α) × score_CB

🔀 Switching

Choisit le système selon le contexte (ex: CF si ≥ K notes, CB sinon → résout le cold start).

🎯 Feature Augmentation

Utilise les prédictions CF comme features supplémentaires du modèle CB.

🤖 Cascade

Le premier système filtre, le second re-rank. Ex: CF → Top-100 → CB → Top-10 final.

⚠️ Le problème du Cold Start

Nouvel utilisateur : Pas d'historique → CF impossible. Solution : CB + onboarding (quelques questions).
Nouvel item : Pas de notes → CF impossible. Solution : CB basé sur les métadonnées de l'item.

🐍 Python — Hybrid Recommender (Weighted + Switching)
class HybridRecommender:
    """
    Système hybride combinant CF et CB.
    - Weighted : somme pondérée des scores
    - Switching : CF si assez de données, CB sinon (cold start)
    """
    def __init__(self, cf_model, cb_model, alpha=0.7, min_ratings=5):
        self.cf          = cf_model
        self.cb          = cb_model
        self.alpha       = alpha       # poids CF
        self.min_ratings = min_ratings # seuil cold start

    def _count_ratings(self, user):
        return self.cf.rating_matrix.loc[user].notna().sum()

    def recommend_weighted(self, user, n=5):
        """Hybrid pondéré : α × CF + (1-α) × CB"""
        cf_recs = dict(self.cf.recommend(user, n=n*2))
        cb_recs = dict(self.cb.get_scores(user, n=n*2))

        all_items = set(cf_recs) | set(cb_recs)
        scores    = {}
        for item in all_items:
            s_cf = cf_recs.get(item, 0)
            s_cb = cb_recs.get(item, 0)
            scores[item] = self.alpha * s_cf + (1 - self.alpha) * s_cb

        return sorted(scores.items(), key=lambda x: x[1], reverse=True)[:n]

    def recommend_switching(self, user, n=5):
        """Switching : CF si assez de notes, sinon CB (cold start)."""
        n_rated = self._count_ratings(user)

        if n_rated >= self.min_ratings:
            print(f"   → Mode CF ({n_rated} notes)")
            return self.cf.recommend(user, n=n)
        else:
            print(f"   → Mode CB (cold start, {n_rated} notes)")
            return self.cb.recommend_for_cold_start(user, n=n)

    def recommend_cascade(self, user, n=5, first_n=50):
        """
        Cascade : CF filtre → Top-first_n → CB re-rank → Top-n final.
        """
        # Étape 1 : CF génère des candidats
        candidates = dict(self.cf.recommend(user, n=first_n))

        # Étape 2 : CB re-rank les candidats
        cb_scores = self.cb.score_items(user, list(candidates.keys()))
        final     = sorted(cb_scores.items(), key=lambda x: x[1], reverse=True)[:n]
        return final

✏️ Exercice — Module 4

🔀

Exercice 4.1 — Comparer Weighted vs Switching Hybrid

Difficile ⏱️ 50 min

Sur le dataset MovieLens-100K, implémentez les deux stratégies hybrides (Weighted et Switching). Évaluez leur RMSE et Precision@10. Tracez la courbe RMSE en fonction de α (Weighted) et du seuil min_ratings (Switching). Quel est le meilleur compromis ?

💡 Conseil

Simulez des "nouveaux utilisateurs" en masquant leurs 80% de notes pour tester le cold start. Comparez les performances sur ces utilisateurs uniquement.

🧠
Module 05 · Avancé · ~3h
Deep Learning pour la Recommandation
Neural CF, AutoEncoders, Transformers-based Recommenders
Neural CF AutoEncoder Avancé PyTorch

📖 Cours — Deep Learning pour la Recommandation

🚀 Pourquoi le Deep Learning ?

Les modèles traditionnels (SVD, CF) modélisent des interactions linéaires. Le deep learning capture des interactions non-linéaires complexes, apprend des représentations riches et peut intégrer des features hétérogènes (texte, images, graphes).

🐍 PyTorch — Neural Collaborative Filtering (NCF)
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import Dataset, DataLoader
import numpy as np
import pandas as pd

# ─── Architecture NCF ─────────────────────────────────────────────
class NCF(nn.Module):
    """
    Neural Collaborative Filtering.
    Combine GMF (Generalized Matrix Factorization)
    et MLP dans un modèle unifié.
    """
    def __init__(self, n_users, n_items, emb_dim=32, layers=[64, 32, 16]):
        super().__init__()

        # ── GMF : embeddings ─────────────────────────────
        self.gmf_user = nn.Embedding(n_users, emb_dim)
        self.gmf_item = nn.Embedding(n_items, emb_dim)

        # ── MLP : embeddings ─────────────────────────────
        self.mlp_user = nn.Embedding(n_users, layers[0] // 2)
        self.mlp_item = nn.Embedding(n_items, layers[0] // 2)

        # ── MLP : couches cachées ─────────────────────────
        mlp_modules = []
        in_size = layers[0]
        for out_size in layers[1:]:
            mlp_modules += [
                nn.Linear(in_size, out_size),
                nn.BatchNorm1d(out_size),
                nn.ReLU(),
                nn.Dropout(0.2)
            ]
            in_size = out_size
        self.mlp_layers = nn.Sequential(*mlp_modules)

        # ── Couche de prédiction finale ───────────────────
        self.predict_layer = nn.Linear(emb_dim + layers[-1], 1)
        self.sigmoid = nn.Sigmoid()

        # Initialisation des poids
        self._init_weights()

    def _init_weights(self):
        for module in self.modules():
            if isinstance(module, nn.Embedding):
                nn.init.normal_(module.weight, std=0.01)
            elif isinstance(module, nn.Linear):
                nn.init.xavier_uniform_(module.weight)

    def forward(self, user_ids, item_ids):
        # ── GMF ──────────────────────────────────────────
        gmf_u = self.gmf_user(user_ids)
        gmf_i = self.gmf_item(item_ids)
        gmf_out = gmf_u * gmf_i  # element-wise product

        # ── MLP ──────────────────────────────────────────
        mlp_u   = self.mlp_user(user_ids)
        mlp_i   = self.mlp_item(item_ids)
        mlp_in  = torch.cat([mlp_u, mlp_i], dim=-1)
        mlp_out = self.mlp_layers(mlp_in)

        # ── Fusion GMF + MLP ─────────────────────────────
        fused = torch.cat([gmf_out, mlp_out], dim=-1)
        logit = self.predict_layer(fused)
        return self.sigmoid(logit).squeeze()

# ─── Dataset ─────────────────────────────────────────────────────
class InteractionDataset(Dataset):
    def __init__(self, users, items, labels):
        self.users  = torch.LongTensor(users)
        self.items  = torch.LongTensor(items)
        self.labels = torch.FloatTensor(labels)

    def __len__(self):
        return len(self.users)

    def __getitem__(self, idx):
        return self.users[idx], self.items[idx], self.labels[idx]

# ─── Entraînement ────────────────────────────────────────────────
def train_ncf(model, train_loader, n_epochs=10, lr=0.001):
    device    = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
    model     = model.to(device)
    optimizer = optim.Adam(model.parameters(), lr=lr, weight_decay=1e-5)
    criterion = nn.BCELoss()
    scheduler = optim.lr_scheduler.StepLR(optimizer, step_size=5, gamma=0.5)

    loss_history = []

    for epoch in range(n_epochs):
        model.train()
        total_loss = 0

        for users, items, labels in train_loader:
            users, items, labels = users.to(device), items.to(device), labels.to(device)
            optimizer.zero_grad()
            preds = model.forward(users, items)
            loss  = criterion(preds, labels)
            loss.backward()
            optimizer.step()
            total_loss += loss.item()

        avg_loss = total_loss / len(train_loader)
        loss_history.append(avg_loss)
        scheduler.step()

        if (epoch + 1) % 2 == 0:
            print(f"Epoch [{epoch+1:3d}/{n_epochs}] Loss: {avg_loss:.4f}")

    return model, loss_history

# ─── Exemple rapide ──────────────────────────────────────────────
N_USERS, N_ITEMS = 100, 200
n_samples = 5000

users  = np.random.randint(0, N_USERS, n_samples)
items  = np.random.randint(0, N_ITEMS, n_samples)
labels = np.random.randint(0, 2, n_samples).astype(float)

dataset    = InteractionDataset(users, items, labels)
dataloader = DataLoader(dataset, batch_size=256, shuffle=True)

model = NCF(N_USERS, N_ITEMS, emb_dim=16, layers=[32, 16, 8])
model, history = train_ncf(model, dataloader, n_epochs=10)
print("\n✅ NCF entraîné avec succès !")
print(f"Paramètres : {sum(p.numel() for p in model.parameters()):,}")
🐍 PyTorch — AutoEncoder pour Recommandation
class RecAutoEncoder(nn.Module):
    """
    AutoEncoder pour la complétion de matrices.
    Prend en entrée la ligne d'un utilisateur (notes partielles)
    et reconstruit la ligne complète.
    """
    def __init__(self, n_items, hidden_dims=[512, 256, 128], dropout=0.5):
        super().__init__()

        # ── Encodeur ─────────────────────────────────────
        encoder_layers = []
        in_dim = n_items
        for h_dim in hidden_dims:
            encoder_layers += [
                nn.Linear(in_dim, h_dim),
                nn.SELU(),
                nn.Dropout(dropout)
            ]
            in_dim = h_dim
        self.encoder = nn.Sequential(*encoder_layers)

        # ── Décodeur (symétrique) ─────────────────────────
        decoder_layers = []
        for h_dim in hidden_dims[-2::-1]:
            decoder_layers += [
                nn.Linear(in_dim, h_dim),
                nn.SELU(),
                nn.Dropout(dropout)
            ]
            in_dim = h_dim
        decoder_layers.append(nn.Linear(in_dim, n_items))
        self.decoder = nn.Sequential(*decoder_layers)

    def forward(self, x):
        z = self.encoder(x)
        r = self.decoder(z)
        return r

# ─── Loss masquée (ignore NaN) ────────────────────────────────────
def masked_mse_loss(pred, target, mask):
    """MSE calculée uniquement sur les notes observées."""
    loss = ((pred - target) ** 2) * mask
    return loss.sum() / mask.sum()

# ─── Entraînement AE ─────────────────────────────────────────────
def train_autoencoder(R_matrix, epochs=30, lr=0.001, batch_size=64):
    n_users, n_items = R_matrix.shape
    device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')

    # Préparer les tenseurs
    R_tensor    = torch.FloatTensor(np.nan_to_num(R_matrix, nan=0)).to(device)
    mask_tensor = torch.FloatTensor(~np.isnan(R_matrix)).to(device)

    model     = RecAutoEncoder(n_items).to(device)
    optimizer = optim.Adam(model.parameters(), lr=lr)

    for epoch in range(epochs):
        model.train()
        perm    = torch.randperm(n_users)
        ep_loss = 0

        for i in range(0, n_users, batch_size):
            idx   = perm[i:i + batch_size]
            x     = R_tensor[idx]
            m     = mask_tensor[idx]
            optimizer.zero_grad()
            pred  = model(x)
            loss  = masked_mse_loss(pred, x, m)
            loss.backward()
            optimizer.step()
            ep_loss += loss.item()

        if (epoch + 1) % 5 == 0:
            print(f"Epoch {epoch+1:3d} | Loss: {ep_loss:.4f}")

    return model

# Usage
R = np.array([
    [5, 3, np.nan, 1, np.nan],
    [4, np.nan, 4, 1, 2],
    [np.nan, 2, 3, np.nan, 5],
], dtype=float)

ae_model = train_autoencoder(R, epochs=20)
print("✅ AutoEncoder entraîné !")

✏️ Exercices — Module 5

🧠

Exercice 5.1 — Comparer SVD vs NCF sur MovieLens

Difficile ⏱️ 60 min

Sur MovieLens-1M, entraînez SVD (Surprise) et NCF (PyTorch). Comparez RMSE, MAE, temps d'entraînement et Precision@10. Analysez où chaque modèle surpasse l'autre (utilisateurs avec peu/beaucoup de notes).

💡 Negative Sampling pour NCF

Pour NCF en mode implicit feedback (0/1), utilisez le negative sampling : pour chaque interaction positive, échantillonnez 4 items non-vus comme négatifs.

🏆
Module 06 · Expert · ~4h
Projet Final — Moteur de Recommandation Complet
Architecture production-ready : pipeline ML, API Flask, interface utilisateur
Projet Production Flask API MLflow

🏆 Projet Final — CineAI Recommender

🎯 Objectif du projet

Construire un moteur de recommandation de films complet et déployable qui combine filtrage collaboratif + contenu + deep learning dans une architecture modulaire. Le tout exposé via une API REST et avec tracking des expériences MLflow.

📋 Spécifications du système

📊 Data Pipeline

Ingestion MovieLens, feature engineering, train/val/test split temporel

🤖 Multi-modèles

SVD + NCF + Content-Based + Hybride avec selection automatique

⚡ API REST

Flask avec endpoints /recommend, /similar, /explain, /health

📈 MLflow

Tracking des runs, comparaison des métriques, model registry

🔥 Cold Start Handler

Détection automatique et bascule vers CB / popularité

💡 Explicabilité

"Car vous avez aimé X..." avec facteurs de similarité

🐍 Python — Architecture du moteur de recommandation
# ================================================================
# CINEAI RECOMMENDER — Architecture complète
# Structure du projet :
#
# cineai/
# ├── data/
# │   ├── raw/                 # MovieLens dataset
# │   └── processed/           # Features engineerées
# ├── models/
# │   ├── base.py              # Interface abstraite
# │   ├── collaborative.py     # SVD + User/Item CF
# │   ├── content.py           # TF-IDF + Embeddings
# │   ├── neural.py            # NCF + AutoEncoder
# │   └── hybrid.py            # Ensemble
# ├── api/
# │   ├── app.py               # Flask application
# │   └── routes.py            # Endpoints REST
# ├── evaluation/
# │   ├── metrics.py           # RMSE, MAE, P@K, NDCG
# │   └── experiment.py        # MLflow tracking
# └── config.py                # Hyperparamètres
# ================================================================

# ─── base.py : Interface abstraite ───────────────────────────────
from abc import ABC, abstractmethod
from typing import List, Dict, Tuple, Optional
import numpy as np

class BaseRecommender(ABC):
    """Interface commune pour tous les modèles."""

    @abstractmethod
    def fit(self, train_data): pass

    @abstractmethod
    def recommend(self, user_id: int, n: int = 10) -> List[Tuple[int, float]]: pass

    @abstractmethod
    def predict(self, user_id: int, item_id: int) -> float: pass

    def explain(self, user_id: int, item_id: int) -> Dict:
        """Explication de la recommandation (optionnel)."""
        return {"reason": "Based on your preferences"}


# ─── api/app.py : API Flask ───────────────────────────────────────
from flask import Flask, jsonify, request
from functools import wraps
import time

app = Flask(__name__)

# Décorateur de timing
def timer(f):
    @wraps(f)
    def wrapper(*args, **kwargs):
        start    = time.time()
        result   = f(*args, **kwargs)
        elapsed  = round((time.time() - start) * 1000, 2)
        if isinstance(result, tuple):
            resp, code = result
            resp.json['response_time_ms'] = elapsed
            return resp, code
        return result
    return wrapper

@app.route('/api/v1/recommend', methods=['GET'])
def get_recommendations():
    """
    GET /api/v1/recommend?user_id=123&n=10&strategy=hybrid
    """
    user_id  = request.args.get('user_id', type=int)
    n        = request.args.get('n', default=10, type=int)
    strategy = request.args.get('strategy', default='hybrid')

    if not user_id:
        return jsonify({'error': 'user_id required'}), 400

    # Sélection du modèle
    model_map = {
        'collaborative': cf_model,
        'content':       cb_model,
        'neural':        ncf_model,
        'hybrid':        hybrid_model
    }
    model = model_map.get(strategy, hybrid_model)

    try:
        recs = model.recommend(user_id, n=n)
        return jsonify({
            'user_id':   user_id,
            'strategy':  strategy,
            'items':     [
                {'item_id': item, 'score': round(score, 4),
                 'explain': model.explain(user_id, item)}
                for item, score in recs
            ]
        })
    except Exception as e:
        return jsonify({'error': str(e)}), 500


@app.route('/api/v1/similar', methods=['GET'])
def get_similar():
    """GET /api/v1/similar?item_id=42&n=5"""
    item_id = request.args.get('item_id', type=int)
    n       = request.args.get('n', default=5, type=int)
    similar = cb_model.get_similar_items(item_id, n=n)
    return jsonify({'item_id': item_id, 'similar': similar})


@app.route('/health')
def health():
    return jsonify({'status': 'ok', 'models_loaded': True})


# ─── MLflow Tracking ─────────────────────────────────────────────
import mlflow
import mlflow.sklearn

def run_experiment(model, params, train_data, test_data, exp_name='CineAI'):
    mlflow.set_experiment(exp_name)

    with mlflow.start_run():
        # Log hyperparamètres
        mlflow.log_params(params)

        # Entraînement
        model.fit(train_data)

        # Évaluation
        metrics = evaluate_model(model, test_data)
        mlflow.log_metrics(metrics)

        # Sauvegarde
        mlflow.sklearn.log_model(model, "recommender")

        print(f"✅ Run logged | RMSE: {metrics['rmse']:.4f} | P@10: {metrics['precision_at_10']:.4f}")
        return metrics

if __name__ == '__main__':
    app.run(debug=True, host='0.0.0.0', port=5000)
📊 Checklist du projet final
📥 Data pipeline & EDAÉtape 1
🤝 Modèle CF (SVD + User-Based)Étape 2
📝 Modèle Content-Based (TF-IDF)Étape 3
🧠 Modèle Deep Learning (NCF)Étape 4
🔀 Hybride + Cold Start HandlerÉtape 5
⚡ API Flask + MLflowÉtape 6

Pour aller plus loin

Librairies, datasets et lectures recommandées

🎬

MovieLens Dataset

Le dataset de référence. 100K, 1M et 25M ratings de films. Parfait pour tous les TP.

scikit-surprise

Librairie Python pour CF. Implémente SVD, KNN, NMF, SlopeOne avec cross-validation intégrée.

🔥

PyTorch + RecBole

Framework spécialisé pour les systèmes de recommandation deep learning. 70+ algorithmes.

📖

Papers clés

NCF (He et al. 2017), BERT4Rec (Sun et al. 2019), LightGCN (He et al. 2020), SASRec.

🏆

Netflix Prize

Le concours qui a révolutionné le domaine. Lire le rapport de BellKor's Pragmatic Chaos.

📊

Implicit Library

Libraire Python optimisée pour le feedback implicite (ALS, BPR, LMF). GPU-ready.