Улучшить ранжирование рекомендаций

Добавлены семантические теги вкуса для фильмов, штраф за слабое совпадение с явно понравившимися фильмами и проверка кейса, где реальные оценки должны быть важнее широких onboarding-предпочтений.
This commit is contained in:
skettiks
2026-05-23 00:14:18 +03:00
parent ef1a11404e
commit eb3f0cd206
2 changed files with 82 additions and 5 deletions
@@ -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") }
} }
} }