Улучшить ранжирование рекомендаций
Добавлены семантические теги вкуса для фильмов, штраф за слабое совпадение с явно понравившимися фильмами и проверка кейса, где реальные оценки должны быть важнее широких onboarding-предпочтений.
This commit is contained in:
+79
-3
@@ -354,18 +354,22 @@ class RecommendationService(
|
|||||||
val contextScore = contextScore(film, query, preferences, userProfile, preferenceScore)
|
val contextScore = contextScore(film, query, preferences, userProfile, preferenceScore)
|
||||||
val noveltyScore = if (inLibrary) LIBRARY_NOVELTY_SCORE else CATALOG_NOVELTY_SCORE
|
val noveltyScore = if (inLibrary) LIBRARY_NOVELTY_SCORE else CATALOG_NOVELTY_SCORE
|
||||||
val diversityScore = diversityScore(film, preferences)
|
val diversityScore = diversityScore(film, preferences)
|
||||||
val score =
|
val rawScore =
|
||||||
weights.relevanceWeight * preferenceScore +
|
weights.relevanceWeight * preferenceScore +
|
||||||
weights.qualityWeight * qualityScore +
|
weights.qualityWeight * qualityScore +
|
||||||
weights.contextWeight * contextScore +
|
weights.contextWeight * contextScore +
|
||||||
weights.noveltyWeight * noveltyScore +
|
weights.noveltyWeight * noveltyScore +
|
||||||
weights.diversityWeight * diversityScore
|
weights.diversityWeight * diversityScore
|
||||||
|
val score = rawScore - explicitChoiceMisfitPenalty(userProfile, relevanceBreakdown)
|
||||||
|
|
||||||
if (relevanceBreakdown.positiveSimilarity > EXPLICIT_CHOICE_REASON_THRESHOLD) {
|
if (relevanceBreakdown.positiveSimilarity > EXPLICIT_CHOICE_REASON_THRESHOLD) {
|
||||||
reasons += "Similar to films you rated highly"
|
reasons += "Similar to films you rated highly"
|
||||||
} else if (preferenceScore > STRONG_REASON_THRESHOLD) {
|
} else if (preferenceScore > STRONG_REASON_THRESHOLD) {
|
||||||
reasons += "Similar to user preferences and rating history"
|
reasons += "Similar to user preferences and rating history"
|
||||||
}
|
}
|
||||||
|
matchingPositiveTasteTags(film, userProfile).take(MAX_REASON_ITEMS).forEach { tag ->
|
||||||
|
reasons += "Shares taste signal: ${tag.toReasonLabel()}"
|
||||||
|
}
|
||||||
matchingGenres(film, preferences).take(MAX_REASON_ITEMS).forEach { genre ->
|
matchingGenres(film, preferences).take(MAX_REASON_ITEMS).forEach { genre ->
|
||||||
reasons += "Matches preferred genre: $genre"
|
reasons += "Matches preferred genre: $genre"
|
||||||
}
|
}
|
||||||
@@ -409,11 +413,13 @@ class RecommendationService(
|
|||||||
val normalizedGenres = film.genres.map(::normalize).filter { it.isNotBlank() }
|
val normalizedGenres = film.genres.map(::normalize).filter { it.isNotBlank() }
|
||||||
val plotTokens = tokenize("${film.title} ${film.description}")
|
val plotTokens = tokenize("${film.title} ${film.description}")
|
||||||
val moods = inferredMoods(film)
|
val moods = inferredMoods(film)
|
||||||
|
val semanticTags = semanticTags(film)
|
||||||
val people = (film.directors + film.cast).map(::normalize).filter { it.isNotBlank() }
|
val people = (film.directors + film.cast).map(::normalize).filter { it.isNotBlank() }
|
||||||
|
|
||||||
vector.add(feature("type", film.contentType.name), weights.contentTypeVectorWeight)
|
vector.add(feature("type", film.contentType.name), weights.contentTypeVectorWeight)
|
||||||
distribute(vector, "genre", normalizedGenres, weights.genreVectorWeight)
|
distribute(vector, "genre", normalizedGenres, weights.genreVectorWeight)
|
||||||
distribute(vector, "plot", plotTokens, weights.plotVectorWeight)
|
distribute(vector, "plot", plotTokens, weights.plotVectorWeight)
|
||||||
|
distribute(vector, "tag", semanticTags, weights.plotVectorWeight * SEMANTIC_TAG_VECTOR_WEIGHT_MULTIPLIER)
|
||||||
distribute(vector, "mood", moods, weights.moodVectorWeight)
|
distribute(vector, "mood", moods, weights.moodVectorWeight)
|
||||||
film.releaseYear?.let { vector.add(feature("era", decadeOf(it)), weights.eraVectorWeight) }
|
film.releaseYear?.let { vector.add(feature("era", decadeOf(it)), weights.eraVectorWeight) }
|
||||||
distribute(vector, "person", people, weights.peopleVectorWeight)
|
distribute(vector, "person", people, weights.peopleVectorWeight)
|
||||||
@@ -467,6 +473,23 @@ class RecommendationService(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun explicitChoiceMisfitPenalty(
|
||||||
|
userProfile: UserTasteProfile,
|
||||||
|
relevanceBreakdown: RelevanceBreakdown,
|
||||||
|
): Double {
|
||||||
|
if (!userProfile.hasPositiveChoices) {
|
||||||
|
return 0.0
|
||||||
|
}
|
||||||
|
val fit = relevanceBreakdown.positiveSimilarity
|
||||||
|
if (fit >= POSITIVE_CHOICE_SOFT_FIT_THRESHOLD) {
|
||||||
|
return 0.0
|
||||||
|
}
|
||||||
|
val missingFitRatio =
|
||||||
|
((POSITIVE_CHOICE_SOFT_FIT_THRESHOLD - fit) / POSITIVE_CHOICE_SOFT_FIT_THRESHOLD)
|
||||||
|
.coerceIn(0.0, 1.0)
|
||||||
|
return EXPLICIT_CHOICE_MISFIT_MAX_PENALTY * missingFitRatio
|
||||||
|
}
|
||||||
|
|
||||||
private fun contextScore(
|
private fun contextScore(
|
||||||
film: Film,
|
film: Film,
|
||||||
query: RecommendationQuery,
|
query: RecommendationQuery,
|
||||||
@@ -516,7 +539,7 @@ class RecommendationService(
|
|||||||
film.imdbRating?.let { normalizeRating(it) },
|
film.imdbRating?.let { normalizeRating(it) },
|
||||||
film.platformRating?.let { normalizeRating(it) },
|
film.platformRating?.let { normalizeRating(it) },
|
||||||
)
|
)
|
||||||
return normalizedRatings.averageOrNull() ?: BASE_QUALITY_SCORE
|
return normalizedRatings.averageOrNull() ?: UNKNOWN_QUALITY_SCORE
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun diversityScore(
|
private fun diversityScore(
|
||||||
@@ -546,6 +569,25 @@ class RecommendationService(
|
|||||||
.keys
|
.keys
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun semanticTags(film: Film): Set<String> {
|
||||||
|
val text = normalize("${film.title} ${film.description} ${film.genres.joinToString(" ")}")
|
||||||
|
return semanticTagLexicon
|
||||||
|
.filterValues { keywords -> keywords.any { keyword -> text.contains(keyword) } }
|
||||||
|
.keys
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun matchingPositiveTasteTags(
|
||||||
|
film: Film,
|
||||||
|
userProfile: UserTasteProfile,
|
||||||
|
): List<String> {
|
||||||
|
if (!userProfile.hasPositiveChoices) {
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
return semanticTags(film)
|
||||||
|
.filter { tag -> userProfile.positiveChoices.values.containsKey(feature("tag", tag)) }
|
||||||
|
.sorted()
|
||||||
|
}
|
||||||
|
|
||||||
private fun matchingGenres(
|
private fun matchingGenres(
|
||||||
film: Film,
|
film: Film,
|
||||||
preferences: UserPreferences?,
|
preferences: UserPreferences?,
|
||||||
@@ -622,6 +664,10 @@ class RecommendationService(
|
|||||||
.trim()
|
.trim()
|
||||||
.lowercase(Locale.getDefault())
|
.lowercase(Locale.getDefault())
|
||||||
|
|
||||||
|
private fun String.toReasonLabel(): String =
|
||||||
|
split("-")
|
||||||
|
.joinToString(" ") { token -> token.replaceFirstChar { char -> char.titlecase(Locale.getDefault()) } }
|
||||||
|
|
||||||
private fun cosineSimilarity(
|
private fun cosineSimilarity(
|
||||||
left: SparseVector,
|
left: SparseVector,
|
||||||
right: SparseVector,
|
right: SparseVector,
|
||||||
@@ -736,13 +782,14 @@ class RecommendationService(
|
|||||||
private const val PREFERENCE_CONTENT_TYPE_WEIGHT = 0.5
|
private const val PREFERENCE_CONTENT_TYPE_WEIGHT = 0.5
|
||||||
private const val LIBRARY_SIGNAL_WEIGHT = 0.25
|
private const val LIBRARY_SIGNAL_WEIGHT = 0.25
|
||||||
private const val EXPLICIT_CHOICE_PROFILE_WEIGHT = 1.8
|
private const val EXPLICIT_CHOICE_PROFILE_WEIGHT = 1.8
|
||||||
|
private const val SEMANTIC_TAG_VECTOR_WEIGHT_MULTIPLIER = 0.9
|
||||||
|
|
||||||
private const val LEARNING_RATE = 0.03
|
private const val LEARNING_RATE = 0.03
|
||||||
|
|
||||||
private const val LIBRARY_NOVELTY_SCORE = 0.85
|
private const val LIBRARY_NOVELTY_SCORE = 0.85
|
||||||
private const val CATALOG_NOVELTY_SCORE = 0.65
|
private const val CATALOG_NOVELTY_SCORE = 0.65
|
||||||
private const val BASE_CONTEXT_SCORE = 0.5
|
private const val BASE_CONTEXT_SCORE = 0.5
|
||||||
private const val BASE_QUALITY_SCORE = 0.5
|
private const val UNKNOWN_QUALITY_SCORE = 0.42
|
||||||
private const val BASE_DIVERSITY_SCORE = 0.5
|
private const val BASE_DIVERSITY_SCORE = 0.5
|
||||||
private const val HIGH_DIVERSITY_SCORE = 0.75
|
private const val HIGH_DIVERSITY_SCORE = 0.75
|
||||||
private const val MEDIUM_DIVERSITY_SCORE = 0.45
|
private const val MEDIUM_DIVERSITY_SCORE = 0.45
|
||||||
@@ -761,6 +808,8 @@ class RecommendationService(
|
|||||||
private const val MAX_RELEVANCE_SCORE = 1.0
|
private const val MAX_RELEVANCE_SCORE = 1.0
|
||||||
private const val MIN_CONTEXT_RELEVANCE_GATE = 0.35
|
private const val MIN_CONTEXT_RELEVANCE_GATE = 0.35
|
||||||
private const val MAX_CONTEXT_RELEVANCE_GATE = 1.0
|
private const val MAX_CONTEXT_RELEVANCE_GATE = 1.0
|
||||||
|
private const val POSITIVE_CHOICE_SOFT_FIT_THRESHOLD = 0.10
|
||||||
|
private const val EXPLICIT_CHOICE_MISFIT_MAX_PENALTY = 0.12
|
||||||
|
|
||||||
private val tokenSeparatorRegex = Regex("[^\\p{L}0-9]+")
|
private val tokenSeparatorRegex = Regex("[^\\p{L}0-9]+")
|
||||||
private val stopWords =
|
private val stopWords =
|
||||||
@@ -782,5 +831,32 @@ class RecommendationService(
|
|||||||
"romantic" to listOf("romance", "love", "relationship"),
|
"romantic" to listOf("romance", "love", "relationship"),
|
||||||
"focused" to listOf("science", "mission", "detective", "investigation", "sci-fi"),
|
"focused" to listOf("science", "mission", "detective", "investigation", "sci-fi"),
|
||||||
)
|
)
|
||||||
|
private val semanticTagLexicon =
|
||||||
|
mapOf(
|
||||||
|
"magic-fantasy" to
|
||||||
|
listOf(
|
||||||
|
"magic",
|
||||||
|
"magical",
|
||||||
|
"wizard",
|
||||||
|
"witch",
|
||||||
|
"spell",
|
||||||
|
"sorcer",
|
||||||
|
"fantasy",
|
||||||
|
"enchanted",
|
||||||
|
"dragon",
|
||||||
|
),
|
||||||
|
"wizard-school" to listOf("wizard school", "magic school", "academy", "school of magic"),
|
||||||
|
"young-adult" to listOf("young", "teen", "teenage", "teenager", "student", "coming of age"),
|
||||||
|
"family-adventure" to listOf("family", "friendship", "friends", "adventure", "quest"),
|
||||||
|
"quest-adventure" to listOf("quest", "journey", "treasure", "relic", "map", "kingdom"),
|
||||||
|
"heist-crime" to listOf("heist", "thief", "robbery", "criminal", "crime", "gang"),
|
||||||
|
"space-opera" to listOf("space", "spaceship", "galaxy", "planet", "alien", "starship"),
|
||||||
|
"superhero" to listOf("superhero", "hero", "masked", "powers", "mutant"),
|
||||||
|
"martial-arts" to listOf("martial", "kung fu", "samurai", "ninja", "warrior", "sword"),
|
||||||
|
"war-epic" to listOf("war", "battle", "army", "soldier", "general", "rebel"),
|
||||||
|
"mystery-investigation" to listOf("mystery", "detective", "investigation", "secret", "clue"),
|
||||||
|
"dark-fantasy" to listOf("dark force", "curse", "underworld", "demon", "monster"),
|
||||||
|
"animated-anime" to listOf("animation", "animated", "anime"),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -372,8 +372,8 @@ class RecommendationSmokeTest {
|
|||||||
)
|
)
|
||||||
val magicCandidateId =
|
val magicCandidateId =
|
||||||
createFilm(
|
createFilm(
|
||||||
title = "Academy of Spells",
|
title = "Sorcerer Academy",
|
||||||
description = "A group of friends learns spells at a magic academy while facing a dark wizard.",
|
description = "A teenage student joins an academy with friends and faces an enchanted threat.",
|
||||||
releaseYear = 2005,
|
releaseYear = 2005,
|
||||||
genres = listOf("Fantasy", "Adventure", "Family"),
|
genres = listOf("Fantasy", "Adventure", "Family"),
|
||||||
imdbRating = 7.0,
|
imdbRating = 7.0,
|
||||||
@@ -425,6 +425,7 @@ class RecommendationSmokeTest {
|
|||||||
status { isOk() }
|
status { isOk() }
|
||||||
jsonPath("$[0].filmId") { value(magicCandidateId.toString()) }
|
jsonPath("$[0].filmId") { value(magicCandidateId.toString()) }
|
||||||
jsonPath("$[0].reasons[0]") { value("Similar to films you rated highly") }
|
jsonPath("$[0].reasons[0]") { value("Similar to films you rated highly") }
|
||||||
|
jsonPath("$[0].reasons[1]") { value("Shares taste signal: Family Adventure") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user