feat(recommendation): extend Score with SimilarityScore + SimilarityWeight
This commit is contained in:
@@ -73,6 +73,7 @@ type RecommendationConfig struct {
|
|||||||
SkipPenalty float64 `yaml:"skip_penalty"`
|
SkipPenalty float64 `yaml:"skip_penalty"`
|
||||||
JitterMagnitude float64 `yaml:"jitter_magnitude"`
|
JitterMagnitude float64 `yaml:"jitter_magnitude"`
|
||||||
ContextWeight float64 `yaml:"context_weight"`
|
ContextWeight float64 `yaml:"context_weight"`
|
||||||
|
SimilarityWeight float64 `yaml:"similarity_weight"`
|
||||||
RecentlyPlayedHours int `yaml:"recently_played_hours"`
|
RecentlyPlayedHours int `yaml:"recently_played_hours"`
|
||||||
RadioSize int `yaml:"radio_size"`
|
RadioSize int `yaml:"radio_size"`
|
||||||
RadioSizeMax int `yaml:"radio_size_max"`
|
RadioSizeMax int `yaml:"radio_size_max"`
|
||||||
@@ -95,6 +96,7 @@ func Default() Config {
|
|||||||
SkipPenalty: 1.0,
|
SkipPenalty: 1.0,
|
||||||
JitterMagnitude: 0.1,
|
JitterMagnitude: 0.1,
|
||||||
ContextWeight: 2.0,
|
ContextWeight: 2.0,
|
||||||
|
SimilarityWeight: 2.0,
|
||||||
RecentlyPlayedHours: 1,
|
RecentlyPlayedHours: 1,
|
||||||
RadioSize: 50,
|
RadioSize: 50,
|
||||||
RadioSizeMax: 200,
|
RadioSizeMax: 200,
|
||||||
|
|||||||
@@ -11,23 +11,26 @@ import (
|
|||||||
// ContextualMatchScore is in [0, 1] — max similarity between the user's
|
// ContextualMatchScore is in [0, 1] — max similarity between the user's
|
||||||
// current session vector and any non-seed contextual_like row for this
|
// current session vector and any non-seed contextual_like row for this
|
||||||
// track. Set by LoadCandidates after a bulk fetch.
|
// track. Set by LoadCandidates after a bulk fetch.
|
||||||
|
// SimilarityScore is in [0, 1]; 0 when no signal (random fill).
|
||||||
type ScoringInputs struct {
|
type ScoringInputs struct {
|
||||||
IsGeneralLiked bool
|
IsGeneralLiked bool
|
||||||
LastPlayedAt *time.Time // nil = never played
|
LastPlayedAt *time.Time // nil = never played
|
||||||
PlayCount int // total play_events
|
PlayCount int // total play_events
|
||||||
SkipCount int // play_events with was_skipped=true
|
SkipCount int // play_events with was_skipped=true
|
||||||
ContextualMatchScore float64 // [0, 1]; 0 when no signal
|
ContextualMatchScore float64 // [0, 1]; 0 when no signal
|
||||||
|
SimilarityScore float64 // [0, 1]; 0 when no signal (random fill)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ScoringWeights are the operator-tunable knobs. Defaults live in
|
// ScoringWeights are the operator-tunable knobs. Defaults live in
|
||||||
// config.RecommendationConfig and are propagated here per request.
|
// config.RecommendationConfig and are propagated here per request.
|
||||||
type ScoringWeights struct {
|
type ScoringWeights struct {
|
||||||
BaseWeight float64
|
BaseWeight float64
|
||||||
LikeBoost float64
|
LikeBoost float64
|
||||||
RecencyWeight float64
|
RecencyWeight float64
|
||||||
SkipPenalty float64
|
SkipPenalty float64
|
||||||
JitterMagnitude float64
|
JitterMagnitude float64
|
||||||
ContextWeight float64
|
ContextWeight float64
|
||||||
|
SimilarityWeight float64
|
||||||
}
|
}
|
||||||
|
|
||||||
// Score computes the weighted-shuffle score per spec §6:
|
// Score computes the weighted-shuffle score per spec §6:
|
||||||
@@ -37,6 +40,7 @@ type ScoringWeights struct {
|
|||||||
// + recency_decay * RecencyWeight
|
// + recency_decay * RecencyWeight
|
||||||
// - skip_ratio * SkipPenalty
|
// - skip_ratio * SkipPenalty
|
||||||
// + contextual_match_score * ContextWeight
|
// + contextual_match_score * ContextWeight
|
||||||
|
// + similarity_score * SimilarityWeight
|
||||||
// + small_random_jitter
|
// + small_random_jitter
|
||||||
//
|
//
|
||||||
// Higher score = more likely to surface. rng is a function returning a
|
// Higher score = more likely to surface. rng is a function returning a
|
||||||
@@ -50,6 +54,7 @@ func Score(in ScoringInputs, w ScoringWeights, now time.Time, rng func() float64
|
|||||||
s += recencyDecay(in.LastPlayedAt, now) * w.RecencyWeight
|
s += recencyDecay(in.LastPlayedAt, now) * w.RecencyWeight
|
||||||
s -= skipRatio(in.PlayCount, in.SkipCount) * w.SkipPenalty
|
s -= skipRatio(in.PlayCount, in.SkipCount) * w.SkipPenalty
|
||||||
s += in.ContextualMatchScore * w.ContextWeight
|
s += in.ContextualMatchScore * w.ContextWeight
|
||||||
|
s += in.SimilarityScore * w.SimilarityWeight
|
||||||
s += (rng()*2 - 1) * w.JitterMagnitude
|
s += (rng()*2 - 1) * w.JitterMagnitude
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -182,3 +182,37 @@ func TestScore_ContextualMatch_ZeroNoEffect(t *testing.T) {
|
|||||||
t.Errorf("score-with-zero-ctx = %v, score-without = %v; should be equal", withCtx, withoutCtx)
|
t.Errorf("score-with-zero-ctx = %v, score-without = %v; should be equal", withCtx, withoutCtx)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestScore_SimilarityScore_PerfectMatchAtWeight2(t *testing.T) {
|
||||||
|
w := defaultWeights()
|
||||||
|
w.SimilarityWeight = 2.0
|
||||||
|
in := ScoringInputs{SimilarityScore: 1.0}
|
||||||
|
got := Score(in, w, time.Now(), fixedRNG(0.5))
|
||||||
|
// base 1.0 + recency 1.0 (never played) + similarity 2.0 = 4.0
|
||||||
|
want := 4.0
|
||||||
|
if math.Abs(got-want) > 1e-9 {
|
||||||
|
t.Errorf("score = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScore_SimilarityScore_HalfMatchAtWeight2(t *testing.T) {
|
||||||
|
w := defaultWeights()
|
||||||
|
w.SimilarityWeight = 2.0
|
||||||
|
in := ScoringInputs{SimilarityScore: 0.5}
|
||||||
|
got := Score(in, w, time.Now(), fixedRNG(0.5))
|
||||||
|
// base 1.0 + recency 1.0 + similarity 1.0 = 3.0
|
||||||
|
want := 3.0
|
||||||
|
if math.Abs(got-want) > 1e-9 {
|
||||||
|
t.Errorf("score = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScore_SimilarityScore_ZeroNoEffect(t *testing.T) {
|
||||||
|
wWithSim := defaultWeights()
|
||||||
|
wWithSim.SimilarityWeight = 2.0
|
||||||
|
withSim := Score(ScoringInputs{SimilarityScore: 0}, wWithSim, time.Now(), fixedRNG(0.5))
|
||||||
|
withoutSim := Score(ScoringInputs{}, defaultWeights(), time.Now(), fixedRNG(0.5))
|
||||||
|
if math.Abs(withSim-withoutSim) > 1e-9 {
|
||||||
|
t.Errorf("score-with-zero-sim = %v, score-without = %v; should be equal", withSim, withoutSim)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user