feat(recommendation): add pure Similarity function with weighted Jaccard
This commit is contained in:
@@ -0,0 +1,76 @@
|
|||||||
|
package recommendation
|
||||||
|
|
||||||
|
// SimilarityWeights balances the per-axis contribution to the weighted Jaccard
|
||||||
|
// score. v1 hardcodes the defaults — operators cannot tune via YAML. If
|
||||||
|
// telemetry justifies it, expose under recommendation.similarity.* later.
|
||||||
|
type SimilarityWeights struct {
|
||||||
|
TagsWeight float64
|
||||||
|
ArtistsWeight float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultSimilarityWeights is the v1 axis balance per the M3 design.
|
||||||
|
// Tags carry more signal than artists because a session's "vibe" tracks
|
||||||
|
// genre more directly than artist identity (a session can mix artists
|
||||||
|
// within a genre but rarely mixes genres).
|
||||||
|
var DefaultSimilarityWeights = SimilarityWeights{
|
||||||
|
TagsWeight: 0.7,
|
||||||
|
ArtistsWeight: 0.3,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Similarity returns weighted-Jaccard similarity in [0, 1] between two
|
||||||
|
// session vectors. Returns 0 if either input is Seed=true (low-confidence
|
||||||
|
// vectors don't contribute to scoring).
|
||||||
|
func Similarity(a, b SessionVector, w SimilarityWeights) float64 {
|
||||||
|
if a.Seed || b.Seed {
|
||||||
|
return 0.0
|
||||||
|
}
|
||||||
|
tagJ := setJaccardKeys(a.Tags, b.Tags)
|
||||||
|
artistJ := setJaccardSlice(a.Artists, b.Artists)
|
||||||
|
return tagJ*w.TagsWeight + artistJ*w.ArtistsWeight
|
||||||
|
}
|
||||||
|
|
||||||
|
// setJaccardKeys collapses two map keysets to sets and returns
|
||||||
|
// |A ∩ B| / |A ∪ B|. Both empty → 0 (not NaN).
|
||||||
|
func setJaccardKeys(a, b map[string]int) float64 {
|
||||||
|
if len(a) == 0 && len(b) == 0 {
|
||||||
|
return 0.0
|
||||||
|
}
|
||||||
|
intersect := 0
|
||||||
|
for k := range a {
|
||||||
|
if _, ok := b[k]; ok {
|
||||||
|
intersect++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
union := len(a) + len(b) - intersect
|
||||||
|
if union == 0 {
|
||||||
|
return 0.0
|
||||||
|
}
|
||||||
|
return float64(intersect) / float64(union)
|
||||||
|
}
|
||||||
|
|
||||||
|
// setJaccardSlice deduplicates each input slice into a set and returns
|
||||||
|
// |A ∩ B| / |A ∪ B|. Both empty → 0 (not NaN).
|
||||||
|
func setJaccardSlice(a, b []string) float64 {
|
||||||
|
if len(a) == 0 && len(b) == 0 {
|
||||||
|
return 0.0
|
||||||
|
}
|
||||||
|
aset := make(map[string]struct{}, len(a))
|
||||||
|
for _, x := range a {
|
||||||
|
aset[x] = struct{}{}
|
||||||
|
}
|
||||||
|
bset := make(map[string]struct{}, len(b))
|
||||||
|
for _, x := range b {
|
||||||
|
bset[x] = struct{}{}
|
||||||
|
}
|
||||||
|
intersect := 0
|
||||||
|
for k := range aset {
|
||||||
|
if _, ok := bset[k]; ok {
|
||||||
|
intersect++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
union := len(aset) + len(bset) - intersect
|
||||||
|
if union == 0 {
|
||||||
|
return 0.0
|
||||||
|
}
|
||||||
|
return float64(intersect) / float64(union)
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package recommendation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func approxEq(a, b float64) bool { return math.Abs(a-b) < 1e-9 }
|
||||||
|
|
||||||
|
func TestSimilarity_IdenticalVectors_Returns1(t *testing.T) {
|
||||||
|
v := SessionVector{
|
||||||
|
Artists: []string{"a1", "a2"},
|
||||||
|
Tags: map[string]int{"rock": 2, "indie": 1},
|
||||||
|
}
|
||||||
|
got := Similarity(v, v, DefaultSimilarityWeights)
|
||||||
|
if !approxEq(got, 1.0) {
|
||||||
|
t.Errorf("Similarity(v,v) = %v, want 1.0", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSimilarity_FullyDisjoint_Returns0(t *testing.T) {
|
||||||
|
a := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}}
|
||||||
|
b := SessionVector{Artists: []string{"a2"}, Tags: map[string]int{"jazz": 1}}
|
||||||
|
got := Similarity(a, b, DefaultSimilarityWeights)
|
||||||
|
if !approxEq(got, 0.0) {
|
||||||
|
t.Errorf("disjoint = %v, want 0.0", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSimilarity_TagsOnlyShared_AppliesTagsWeight(t *testing.T) {
|
||||||
|
a := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}}
|
||||||
|
b := SessionVector{Artists: []string{"a2"}, Tags: map[string]int{"rock": 5}}
|
||||||
|
got := Similarity(a, b, DefaultSimilarityWeights)
|
||||||
|
if !approxEq(got, 0.7) {
|
||||||
|
t.Errorf("tags-only = %v, want 0.7", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSimilarity_ArtistsOnlyShared_AppliesArtistsWeight(t *testing.T) {
|
||||||
|
a := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}}
|
||||||
|
b := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"jazz": 1}}
|
||||||
|
got := Similarity(a, b, DefaultSimilarityWeights)
|
||||||
|
if !approxEq(got, 0.3) {
|
||||||
|
t.Errorf("artists-only = %v, want 0.3", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSimilarity_EitherSeed_Returns0(t *testing.T) {
|
||||||
|
v := SessionVector{Artists: []string{"a"}, Tags: map[string]int{"rock": 1}}
|
||||||
|
seed := SessionVector{Seed: true, Artists: []string{"a"}, Tags: map[string]int{"rock": 1}}
|
||||||
|
if got := Similarity(v, seed, DefaultSimilarityWeights); !approxEq(got, 0.0) {
|
||||||
|
t.Errorf("v vs seed = %v, want 0.0", got)
|
||||||
|
}
|
||||||
|
if got := Similarity(seed, v, DefaultSimilarityWeights); !approxEq(got, 0.0) {
|
||||||
|
t.Errorf("seed vs v = %v, want 0.0", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSimilarity_BothEmpty_Returns0NotNaN(t *testing.T) {
|
||||||
|
a := SessionVector{}
|
||||||
|
b := SessionVector{}
|
||||||
|
got := Similarity(a, b, DefaultSimilarityWeights)
|
||||||
|
if math.IsNaN(got) || !approxEq(got, 0.0) {
|
||||||
|
t.Errorf("empty = %v, want 0.0 (not NaN)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSimilarity_OneAxisEmptyOneSide_AxisContributesZero(t *testing.T) {
|
||||||
|
a := SessionVector{Tags: map[string]int{"rock": 1}}
|
||||||
|
b := SessionVector{Artists: []string{"a1"}}
|
||||||
|
got := Similarity(a, b, DefaultSimilarityWeights)
|
||||||
|
if !approxEq(got, 0.0) {
|
||||||
|
t.Errorf("one-axis-each = %v, want 0.0", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSimilarity_PartialTagsOverlap(t *testing.T) {
|
||||||
|
a := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1, "indie": 1}}
|
||||||
|
b := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1, "jazz": 1}}
|
||||||
|
got := Similarity(a, b, DefaultSimilarityWeights)
|
||||||
|
want := 0.7*(1.0/3.0) + 0.3*1.0
|
||||||
|
if !approxEq(got, want) {
|
||||||
|
t.Errorf("partial = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSimilarity_BagOfCountsCollapsesToSet(t *testing.T) {
|
||||||
|
a := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 2, "indie": 1}}
|
||||||
|
b := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 5, "indie": 3}}
|
||||||
|
got := Similarity(a, b, DefaultSimilarityWeights)
|
||||||
|
if !approxEq(got, 1.0) {
|
||||||
|
t.Errorf("set-collapse = %v, want 1.0", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user