Comme je l’expliquais dans mon précédent article, Kejja est un produit que j’ai pris le temps de construire. Et ce n’est pas un problème : c’est aussi un terrain d’expérimentation pour comprendre comment améliorer la pertinence des recommandations de films et de séries.
Dès le départ, je voulais intégrer du machine learning avec Rubix ML, en m’appuyant sur une approche par plus proches voisins, ou KNN. L’idée : exploiter les interactions des utilisateurs pour identifier des goûts similaires et proposer des contenus susceptibles de leur plaire. Une approche de filtrage collaboratif qui soulève cependant une difficulté très concrète : comment obtenir des recommandations pertinentes lorsque ces interactions sont encore peu nombreuses ?
Le problème ne vient pas du KNN en lui-même, qui peut fonctionner sur différentes représentations des données. Il vient ici du signal que l’on souhaite lui faire exploiter. Les embeddings permettent déjà de rapprocher des contenus selon leurs caractéristiques, mais ils ne remplacent pas les préférences exprimées par les utilisateurs. Et un film qu’une personne n’a jamais consulté n’est pas nécessairement un film qu’elle n’aime pas.
Il faut donc composer avec ce manque de données, souvent appelé problème de démarrage à froid. Comment proposer des recommandations utiles dès les premières utilisations, puis donner progressivement davantage de place au filtrage collaboratif ? C’est le problème technique que nous allons explorer dans cet article, à partir du cas concret de Kejja.
Faire du ML en PHP, c’est possible
Je l’ai déjà illustré à plusieurs reprises : pour certains besoins bien définis, faire du machine learning ne nécessite pas d’ajouter un service Python à son architecture. Il faut d’abord se poser les bonnes questions : quelles données avons-nous, quel problème cherchons-nous à résoudre et quel volume devons-nous traiter ?
PHP dispose notamment de Rubix ML, une bibliothèque qui propose des algorithmes d’apprentissage supervisé et non supervisé. On peut utiliser les plus proches voisins pour de la classification ou de la régression, ou encore K-means pour regrouper des observations similaires. Ces méthodes répondent à des objectifs différents, mais elles sont accessibles directement dans notre environnement PHP.
FrankenPHP s’inscrit dans cette approche, mais il faut distinguer les performances de l’application de celles des calculs. Accélérer le traitement des requêtes ne supprime pas le coût d’une recherche de voisins sur un grand jeu de données. Le volume, la dimension des vecteurs et la fréquence des calculs restent des paramètres à mesurer.
Rubix ML recommande d’ailleurs l’extension Tensor pour accélérer les opérations sur les matrices et les vecteurs. Swoole est proposé en option pour le traitement multiprocessus. Ces deux leviers répondent à des besoins distincts : accélérer les opérations numériques et répartir certains traitements compatibles entre plusieurs processus. Leur bénéfice dépend donc de l’algorithme et de la charge de travail.
Pour Kejja, rester en PHP permet de travailler dans la continuité de l’application Symfony. Mais disposer des bons outils ne suffit pas : avant d’optimiser les calculs, il faut déterminer ce que l’on souhaite comparer et vérifier que les interactions disponibles fournissent un signal exploitable.
Le cold start : comprendre les goûts avant de recommander
Maintenant que les bases sont posées, nous arrivons au véritable problème : que recommander à un utilisateur qui vient de créer son compte ? Nous ne connaissons pas encore ses goûts, ses habitudes ni ses envies. Difficile, dans ces conditions, de lui proposer une sélection réellement personnalisée.
C’est le problème du cold start, ou démarrage à froid. Dans une approche collaborative, un nouvel utilisateur ne possède pas encore suffisamment d’interactions pour être rapproché de profils aux goûts similaires. Et lorsque la plateforme elle-même démarre, le manque d’historique concerne aussi l’ensemble de la communauté.
Ce n’est pas uniquement une question de volume. Il faut surtout des interactions pertinentes et suffisamment de points communs entre les utilisateurs pour établir des rapprochements utiles. Des milliers de consultations isolées ne garantissent pas de comprendre les préférences de chacun. Et ajouter le mot « IA » ne résout rien : ChatGPT ne peut pas deviner que Francis aime Dragon Ball Z si personne ne le lui a indiqué.
Pour Kejja, le premier levier consiste donc à construire un parcours d’accueil centré sur les contenus. L’objectif est de recueillir quelques préférences explicites, sans transformer l’inscription en questionnaire interminable : choisir des films ou des séries appréciés, indiquer certains genres favoris, ou signaler ce que l’on préfère éviter.
Cette approche content first permet de partir d’éléments concrets. Choisir plusieurs œuvres donne un premier signal à exploiter pour rechercher des contenus proches, avant même de disposer d’un historique collaboratif suffisant. Une absence de sélection doit toutefois rester une inconnue : elle ne signifie pas nécessairement que l’utilisateur n’aime pas une œuvre.
Il faut aussi distinguer les goûts durables de l’envie du moment. Aimer les thrillers sombres ne signifie pas vouloir en regarder un ce soir. Le parcours initial pose les premières bases ; les interactions suivantes permettent de les affiner.
Le but n’est donc pas de supprimer le cold start, mais de le prendre en charge : proposer une première sélection à partir des préférences déclarées, puis enrichir progressivement les recommandations à mesure que des signaux exploitables deviennent disponibles.
Représenter les utilisateurs et les contenus
Maintenant que le problème du démarrage à froid est posé, il faut traduire les informations disponibles en recommandations. Côté utilisateur, nous avons ses interactions, ses préférences et les contraintes de sa demande. Côté contenu, nous disposons notamment des genres, des embeddings et de la disponibilité sur les plateformes. Le signal collaboratif cherche à exploiter les préférences communes entre utilisateurs ; les embeddings apportent une proximité entre les contenus.
Dans Kejja, cette logique prend la forme d’une stratégie hybride. Avant de solliciter le provider ML, elle sélectionne un ensemble d’au maximum 100 contenus candidats. Cette sélection exclut notamment les titres déjà vus et tient compte des plateformes de l’utilisateur, des genres refusés et de la durée maximale souhaitée. Le modèle intervient donc sur un périmètre déjà adapté à la demande.
Le provider attribue ensuite un score aux candidats. Lorsqu’un score issu des embeddings est disponible, le classement combine trois signaux : 60 % pour le score du provider, 30 % pour les embeddings et 10 % pour la note moyenne du contenu. Ces pondérations constituent un choix de départ à évaluer, pas une garantie de pertinence.
La classe ci-dessous orchestre ce traitement : sélection des candidats, appel au provider, combinaison des scores et construction du résultat. Le calcul des voisins et la représentation utilisée par le modèle sont délégués au provider ML.
Pourquoi cette stratégie reste désactivée pour le moment
Les tests internes de non-régression ont orienté mon choix : sur les scénarios simulés évalués, la stratégie ML donnait des résultats moins pertinents que l’approche reposant sur les agrégations de signaux — les roll-ups — et le recours au LLM en repli.
À ce stade de Kejja, le faible nombre d’utilisateurs et le manque d’interactions communes limitent l’intérêt du filtrage collaboratif. Le seuil de cinq interactions qualifiées présent dans le code permet de rendre un utilisateur éligible à la stratégie ; il ne suffit pas à garantir l’existence de voisins pertinents.
Ce constat reste propre aux données et aux scénarios testés. Il ne signifie pas que le KNN est une mauvaise approche, mais que son apport n’est pas encore suffisant dans les conditions actuelles du produit.
J’ai donc conservé cette stratégie derrière un indicateur d’activation. Elle reste appelable directement pour les mesures, tout en pouvant être exclue du routage automatique. Cela permet de poursuivre les comparaisons à mesure que les données s’enrichissent, puis de l’activer lorsqu’elle apporte un gain mesurable.
Voici la classe qui matérialise cette approche :
<?php
declare(strict_types=1);
namespace App\Infrastructure\Recommendation;
use App\Domain\Recommendation\Provider\MLProviderInterface;
use App\Domain\Recommendation\GenreEquivalence;
use App\Domain\Recommendation\RecommendationRequest;
use App\Domain\Recommendation\RecommendationResult;
use App\Domain\Recommendation\RecommendationStrategyInterface;
use App\Domain\Recommendation\RecommendedContent;
use App\Domain\Recommendation\StrategyType;
use App\Entity\Content;
use App\Enum\ContentType;
use App\Enum\StreamingAvailabilityType;
use App\Enum\SupportedCountry;
use App\Repository\ContentRepository;
use App\Service\Recommendation\RuntimeRule;
use Psr\Log\LoggerInterface;
/**
* Stratégie de recommandation basée sur le Machine Learning
* Utilisée pour les utilisateurs avec suffisamment d'historique (≥ 5 interactions)
*/
class MLRecommendationStrategy implements RecommendationStrategyInterface
{
private const MIN_INTERACTIONS_FOR_ML = 5;
private const PRIORITY = 90; // Haute priorité, mais inférieure au cold start pour les nouveaux users
private const CANDIDATE_POOL_SIZE = 100; // Nombre de contenus candidats à scorer
private const RERANK_POOL_MULTIPLIER = 3;
private const CF_WEIGHT = 0.6;
private const EMBEDDING_WEIGHT = 0.3;
private const POPULARITY_WEIGHT = 0.1;
public function __construct(
private readonly MLProviderInterface $mlProvider,
private readonly ContentRepository $contentRepository,
private readonly EmbeddingReranker $embeddingReranker,
private readonly LoggerInterface $logger,
// Flag ML_STRATEGY_ENABLED (JEA-44) : OFF = la stratégie sort du routage automatique
// (supports() false) mais recommend() reste appelable en direct (outils de mesure).
private readonly bool $enabled,
// Règle de durée maximale (JEA-181) : sans dépendance, instanciée par défaut.
private readonly RuntimeRule $runtimeRule = new RuntimeRule(),
) {
}
public function recommend(RecommendationRequest $request): RecommendationResult
{
$this->logger->info('Using ML strategy for user', [
'user_id' => $request->getUserId(),
'qualified_interactions' => $request->getQualifiedInteractionCount(),
'provider' => $this->mlProvider->getName(),
]);
// Verifier que le provider ML est disponible
if (!$this->mlProvider->isHealthy()) {
throw new \RuntimeException('ML provider is not healthy');
}
try {
// Recuperer les contenus candidats (pool de contenus a scorer)
$candidateContents = $this->getCandidateContents($request);
if (empty($candidateContents)) {
throw new \RuntimeException('No candidate contents found for ML recommendations');
}
$candidateIds = array_values(array_filter(array_map(static fn (Content $content): ?int => $content->getId(), $candidateContents)));
// Appeler le provider ML pour scorer les contenus
$providerTopN = $request->getLimit();
// Choix film/série du jour (JEA-152) : on demande un classement plus profond au
// provider pour que la sélection de TOUS les titres du type choisi (JEA-183, imposée par
// RecommendationService) ait de quoi choisir, même si le ML classe l'autre type devant.
$contentTypeChoice = $request->getEffectiveContentType();
if ($contentTypeChoice !== null) {
$providerTopN = max($providerTopN, $request->getLimit() * self::RERANK_POOL_MULTIPLIER);
}
if ($this->embeddingReranker->isEnabled()) {
$providerTopN = min(
self::CANDIDATE_POOL_SIZE,
max($providerTopN, $request->getLimit() * self::RERANK_POOL_MULTIPLIER)
);
}
$mlResult = $this->mlProvider->getRecommendations(
userId: $request->getUserId(),
candidateContentIds: $candidateIds,
topN: $providerTopN
);
if (!$mlResult->isSuccess()) {
throw new \RuntimeException('ML provider failed: ' . $mlResult->getErrorMessage());
}
if ($mlResult->isEmpty()) {
throw new \RuntimeException('ML provider returned no recommendations');
}
// Convertir les resultats ML en RecommendedContent
$recommendations = $this->buildRecommendations(
userId: $request->getUserId(),
mlResults: $mlResult->getRecommendations(),
limit: $contentTypeChoice !== null ? $providerTopN : $request->getLimit(),
);
$this->logger->info('ML recommendations generated', [
'count' => count($recommendations),
'provider' => $this->mlProvider->getName(),
]);
return new RecommendationResult(
recommendations: $recommendations,
strategyUsed: StrategyType::ML,
explanation: 'Recommandations personnalisees par ' . $this->mlProvider->getName(),
candidateContentIds: $candidateIds,
);
} catch (\Exception $e) {
$this->logger->error('ML strategy failed', [
'error' => $e->getMessage(),
'user_id' => $request->getUserId(),
]);
throw new \RuntimeException('Failed to generate ML recommendations: ' . $e->getMessage(), 0, $e);
}
}
public function supports(RecommendationRequest $request): bool
{
// Désactivée par défaut (JEA-44) : données de co-visionnage insuffisantes, le repli fait mieux.
if (!$this->enabled) {
return false;
}
// Support uniquement si :
// 1. L'utilisateur a au moins 5 interactions qualifiées
// 2. Le provider ML est disponible et le modele est entraine
return $request->getQualifiedInteractionCount() >= self::MIN_INTERACTIONS_FOR_ML
&& $this->mlProvider->isHealthy()
&& $this->mlProvider->isModelTrained();
}
public function getType(): StrategyType
{
return StrategyType::ML;
}
public function getPriority(): int
{
return self::PRIORITY;
}
public function isAvailable(): bool
{
return $this->mlProvider->isHealthy() && $this->mlProvider->isModelTrained();
}
/**
* Récupère les contenus candidats à scorer par le ML
*
* @return array<Content>
*/
private function getCandidateContents(RecommendationRequest $request): array
{
$choice = $request->getEffectiveContentType();
if ($choice === null) {
return $this->queryCandidateContents($request, self::CANDIDATE_POOL_SIZE, null);
}
// Choix film/série du jour : pool composé — l'essentiel du type demandé, plus quelques
// candidats de l'autre type pour le slot « découverte » (JEA-152).
$otherSize = (int) max(1, round(self::CANDIDATE_POOL_SIZE * 0.2));
$other = $choice === ContentType::Movie ? ContentType::TvShow : ContentType::Movie;
return array_merge(
$this->queryCandidateContents($request, self::CANDIDATE_POOL_SIZE - $otherSize, $choice),
$this->queryCandidateContents($request, $otherSize, $other),
);
}
/**
* @return array<Content>
*/
private function queryCandidateContents(RecommendationRequest $request, int $poolSize, ?ContentType $onlyType): array
{
// Récupérer les contenus populaires et bien notés
// En excluant ceux déjà vus par l'utilisateur
$userWatchedIds = [];
foreach ($request->getUser()->getContentHistory() as $history) {
$userWatchedIds[] = $history->getContent()->getId();
}
// Récupérer un pool de contenus candidats
$qb = $this->contentRepository->createQueryBuilder('c');
// DISTINCT : les jointures plateforme et genre plus bas multiplient chaque
// titre, et `setMaxResults` compte des lignes — le pool sortait plus
// petit que CANDIDATE_POOL_SIZE (même défaut que le repli, 15/09/2026).
$qb->distinct()
->where('c.voteAverage >= :minRating')
->setParameter('minRating', 6.0)
->orderBy('c.voteAverage', 'DESC')
->addOrderBy('c.voteCount', 'DESC')
->setMaxResults($poolSize);
// Pool typé (JEA-152) : le pool brut, trié par note, était à dominante d'un seul type et
// le ML ne voyait jamais assez de candidats du type demandé.
if ($onlyType !== null) {
$qb->andWhere('c.type = :poolType')->setParameter('poolType', $onlyType);
}
if (!empty($userWatchedIds)) {
$qb->andWhere('c.id NOT IN (:watchedIds)')
->setParameter('watchedIds', $userWatchedIds);
}
// Exclure les contenus deja presentes (refresh Premium)
$excludedContentIds = $request->getExcludedContentIds();
if ($excludedContentIds !== []) {
$qb->andWhere('c.id NOT IN (:excludedContentIds)')
->setParameter('excludedContentIds', $excludedContentIds);
}
// Filtrer par plateformes si spécifié
$userPlatforms = $request->getUserStreamingPlatforms();
if ($userPlatforms === []) {
return [];
}
$qb->innerJoin('c.streamingPlatforms', 'csp')
->innerJoin('csp.streamingPlatform', 'sp')
->andWhere('sp.tmdbProviderId IN (:platforms)')
->setParameter('platforms', $userPlatforms)
->andWhere('sp.isActive = :platformActive')
->setParameter('platformActive', true)
->andWhere('csp.country = :country')
->setParameter('country', SupportedCountry::default()->value)
->andWhere('csp.availabilityType = :subscriptionType')
->setParameter('subscriptionType', StreamingAvailabilityType::Subscription)
->andWhere('(csp.expiresAt IS NULL OR csp.expiresAt > :availabilityNow)')
->setParameter('availabilityNow', new \DateTimeImmutable());
// Durée maximale (JEA-181) : retirée du pool AVANT le top-N, sinon des films trop longs
// prennent des places que le service devrait ensuite combler hors personnalisation.
$this->runtimeRule->applyToQueryBuilder($qb, $request);
// Genres refusés (JEA-151) : retirés du pool AVANT scoring pour garder un pool de qualité.
GenreEquivalence::applyNotExistsFilter($qb, 'c', $request->getExcludedGenreTmdbIds());
// Filtrer par genres d'humeur si un mood est actif
$moodGenreIds = $request->getMoodGenreIds();
if ($moodGenreIds !== []) {
$qb->innerJoin('c.genres', 'cg_mood')
->innerJoin('cg_mood.genre', 'g_mood')
->andWhere('g_mood.tmdbId IN (:moodGenreIds)')
->setParameter('moodGenreIds', $moodGenreIds);
}
return $qb->getQuery()->getResult();
}
/**
* Construit les objets RecommendedContent à partir des résultats ML
*
* @param array<array{content_id: int, score: float, cf_score?: float}> $mlResults
* @return array<RecommendedContent>
*/
private function buildRecommendations(int $userId, array $mlResults, int $limit): array
{
$recommendations = [];
$rankedRows = [];
$contentIds = array_values(array_unique(array_map(
static fn (array $row): int => (int) ($row['content_id'] ?? 0),
$mlResults
)));
$embeddingScores = $this->embeddingReranker->scoreCandidatesForUser($userId, $contentIds);
foreach ($mlResults as $result) {
$content = $this->contentRepository->find($result['content_id']);
if (!$content) {
$this->logger->warning('Content not found for ML recommendation', [
'content_id' => $result['content_id'],
]);
continue;
}
$baseScore = $this->normalizeScore((float) $result['score']);
$embeddingScore = $embeddingScores[$content->getId()] ?? null;
$finalScore = $baseScore;
if ($embeddingScore !== null) {
$popularityScore = $this->normalizePopularity((float) ($content->getVoteAverage() ?? 0.0));
$finalScore = (self::CF_WEIGHT * $baseScore)
+ (self::EMBEDDING_WEIGHT * $embeddingScore)
+ (self::POPULARITY_WEIGHT * $popularityScore);
$finalScore = max(0.0, min(1.0, $finalScore));
}
$reason = $this->generateReason([
'score' => $finalScore,
'cf_score' => $baseScore,
]);
$rankedRows[] = [
'score' => $finalScore,
'recommendation' => new RecommendedContent(
content: $content,
score: $finalScore,
reason: $reason
),
];
}
usort(
$rankedRows,
static fn (array $a, array $b): int => $b['score'] <=> $a['score']
);
foreach ($rankedRows as $row) {
$recommendations[] = $row['recommendation'];
if (count($recommendations) >= $limit) {
break;
}
}
return $recommendations;
}
/**
* Génère une raison personnalisée basée sur le score ML
*/
private function generateReason(array $result): string
{
$score = $result['score'];
$cfScore = $result['cf_score'] ?? null;
if ($score >= 0.9) {
return 'Excellent match avec vos goûts';
}
if ($score >= 0.8) {
return 'Fortement recommandé pour vous';
}
if ($score >= 0.7) {
return 'Correspond à vos préférences';
}
if ($cfScore !== null && $cfScore >= 0.8) {
return 'Aimé par des utilisateurs similaires';
}
return 'Suggéré pour vous';
}
private function normalizeScore(float $score): float
{
if ($score <= 1.0) {
return max(0.0, $score);
}
if ($score <= 5.0) {
return max(0.0, min(1.0, $score / 5.0));
}
return max(0.0, min(1.0, $score / 10.0));
}
private function normalizePopularity(float $voteAverage): float
{
return max(0.0, min(1.0, $voteAverage / 10.0));
}
}Évaluer et corriger
Si recommander un film se résumait à décrire une envie à GPT-6 Astra pour obtenir le bon titre, le problème serait vite réglé. Mais une réponse convaincante à la lecture ne garantit pas une recommandation pertinente pour la personne qui la reçoit.
J’ai d’ailleurs fait l’exercice en comparant les propositions de ChatGPT avec celles de Kejja pour mes propres envies. Malgré mon historique d’utilisation de ChatGPT, les suggestions de Kejja correspondaient davantage à mes attentes. Cela reste une comparaison personnelle, pas un benchmark : elle illustre mon expérience, sans démontrer une supériorité générale du moteur.
Pour faire évoluer Kejja, il faut aller plus loin que cette impression. Une recommandation doit respecter les contraintes de la demande, mais aussi proposer un contenu qui intéresse réellement l’utilisateur. Ces deux dimensions se contrôlent différemment : vérifier la disponibilité d’un film sur une plateforme ne permet pas de savoir s’il va plaire.
Les tests de non-régression permettent de vérifier qu’une modification ne dégrade pas les scénarios déjà couverts. Les retours des utilisateurs apportent un autre éclairage sur la pertinence des propositions. Un clic reste toutefois un signal d’intérêt, pas la preuve qu’un film a été apprécié.
Cette logique de contrôle se prolonge dans le fonctionnement de Kejja, avec des traitements différés destinés à vérifier les données, actualiser les résultats et corriger les incohérences détectées. Le moteur doit pouvoir évoluer sans faire porter l’ensemble de ces opérations sur la requête de l’utilisateur.
Symfony Messenger et Symfony Workflow structurent cette organisation. Messenger prend en charge l’exécution différée des traitements ; Workflow encadre les états et les transitions des processus concernés. Leur rôle est complémentaire : organiser le travail à exécuter et définir les conditions dans lesquelles un processus peut avancer.
Ces outils ne garantissent pas, à eux seuls, la qualité des recommandations. Ils fournissent un cadre pour exécuter les contrôles et appliquer les corrections. La fiabilité dépend ensuite des règles métier, des mesures et de la gestion des échecs que l’on met en place.
C’est cette boucle qui compte : recommander, observer les résultats, identifier les limites, puis ajuster. L’ajout d’un modèle ML n’a de valeur que si l’on peut vérifier ce qu’il améliore réellement.
Conclusion : un projet incrémental
Kejja dépasse aujourd’hui le cadre du POC. Le temps consacré au développement, aux tests et aux itérations reste conséquent, même avec l’aide de Claude. La difficulté consiste à proposer des contenus qui correspondent aux préférences d’une personne, à son envie du jour et aux plateformes auxquelles elle a accès, tout en composant avec un historique encore limité.
Cela mobilise plusieurs briques : des règles métier, de la recherche vectorielle, des agrégations de signaux, du machine learning et, pour certains traitements, un LLM. La qualité du résultat dépend de leur articulation et de la qualité des données disponibles.
La prochaine étape sera de faire évoluer la séparation déjà présente entre la sélection des candidats et leur classement, puis de tester un classement appris avant tout déploiement.
Apprendre à classer avec les retours disponibles
Je souhaite commencer par une approche pointwise : attribuer un score à chaque couple utilisateur-contenu, en tenant compte du contexte, puis trier les candidats selon ce score.
Une régression logistique constitue une première piste accessible avec Rubix ML. Elle permet d’estimer la probabilité d’une réaction positive à partir des caractéristiques fournies au modèle. Utiliser cette probabilité pour ordonner les candidats en fait une première approche de classement appris.
Les réactions explicites, comme « like », « wouah » ou « dislike », pourront servir à construire les exemples d’apprentissage. En revanche, l’absence de réaction restera une information inconnue : elle ne devra pas être automatiquement transformée en avis négatif.
C’est cette progression qui guide Kejja : partir d’une solution utile, recueillir des signaux exploitables et ajouter de la complexité lorsqu’elle apporte un bénéfice mesurable. Le prochain modèle restera une hypothèse à tester avant de devenir une fonctionnalité en production.
Envie de découvrir ce que cette approche donne concrètement ? Testez Kejja pour trouver votre prochain film ou votre prochaine série. Vos retours m’aideront à faire évoluer les recommandations, merci .