Files
silo-server/internal/recommendations/cowatch.go

192 lines
5.1 KiB
Go

package recommendations
import "sort"
// Default co-watch matrix parameters.
const (
DefaultMinWatchers = 5
DefaultMinShared = 3
DefaultTopN = 50
DefaultMaxWatchesPerUser = 500
)
// computeCowatchMatrix builds a co-watch similarity matrix from per-item watcher
// lists. For every pair of items that each have at least minWatchers viewers,
// it computes the Jaccard similarity of their watcher sets. Pairs with fewer
// than minShared shared watchers are discarded. For each item only the topN
// most similar neighbours (by Jaccard score) are retained.
func computeCowatchMatrix(watchers map[string][]string, minWatchers, minShared, topN int) []CowatchPair {
// Collect item IDs that meet the minimum-watchers threshold.
eligible := make([]string, 0, len(watchers))
for itemID, users := range watchers {
if len(users) >= minWatchers {
eligible = append(eligible, itemID)
}
}
// Sort for deterministic iteration order.
sort.Strings(eligible)
// Pre-build watcher sets for O(1) membership tests.
watcherSets := make(map[string]map[string]struct{}, len(eligible))
for _, itemID := range eligible {
set := make(map[string]struct{}, len(watchers[itemID]))
for _, uid := range watchers[itemID] {
set[uid] = struct{}{}
}
watcherSets[itemID] = set
}
// Per-item neighbour lists, keyed by itemID.
type neighbour struct {
similarID string
score float64
shared int
}
neighbours := make(map[string][]neighbour, len(eligible))
// Compare every pair once (i < j), then record from both sides.
for i := 0; i < len(eligible); i++ {
a := eligible[i]
setA := watcherSets[a]
for j := i + 1; j < len(eligible); j++ {
b := eligible[j]
setB := watcherSets[b]
// Count intersection.
shared := 0
// Iterate over the smaller set for efficiency.
small, big := setA, setB
if len(setA) > len(setB) {
small, big = setB, setA
}
for uid := range small {
if _, ok := big[uid]; ok {
shared++
}
}
if shared < minShared {
continue
}
union := len(setA) + len(setB) - shared
if union == 0 {
continue
}
jaccard := float64(shared) / float64(union)
neighbours[a] = append(neighbours[a], neighbour{similarID: b, score: jaccard, shared: shared})
neighbours[b] = append(neighbours[b], neighbour{similarID: a, score: jaccard, shared: shared})
}
}
// For each item, keep only the topN neighbours by Jaccard score descending.
var result []CowatchPair
for _, itemID := range eligible {
nbrs := neighbours[itemID]
if len(nbrs) == 0 {
continue
}
sort.Slice(nbrs, func(i, j int) bool {
if nbrs[i].score != nbrs[j].score {
return nbrs[i].score > nbrs[j].score
}
return nbrs[i].similarID < nbrs[j].similarID
})
limit := topN
if limit > len(nbrs) {
limit = len(nbrs)
}
for _, n := range nbrs[:limit] {
result = append(result, CowatchPair{
ItemID: itemID,
SimilarItemID: n.similarID,
JaccardScore: n.score,
CowatchCount: n.shared,
})
}
}
return result
}
// blendScores merges embedding-based item scores with co-watch Jaccard scores
// using the given weights (typically 0.7 embedding, 0.3 co-watch). Items that
// appear in only one source still surface with their single weighted score.
// The result is sorted by blended score descending.
func blendScores(embeddingItems []ScoredItem, cowatchItems map[string]float64, embeddingWeight, cowatchWeight float64) []ScoredItem {
blended := make(map[string]ScoredItem, len(embeddingItems)+len(cowatchItems))
// Start with embedding items.
for _, item := range embeddingItems {
score := embeddingWeight * item.Score
if jaccardScore, ok := cowatchItems[item.MediaItemID]; ok {
score += cowatchWeight * jaccardScore
}
blended[item.MediaItemID] = ScoredItem{
MediaItemID: item.MediaItemID,
Score: score,
Reason: item.Reason,
}
}
// Add co-watch-only items that were not in the embedding set.
for itemID, jaccardScore := range cowatchItems {
if _, exists := blended[itemID]; exists {
continue
}
blended[itemID] = ScoredItem{
MediaItemID: itemID,
Score: cowatchWeight * jaccardScore,
Reason: "cowatch",
}
}
// Flatten to a slice and sort by score descending.
result := make([]ScoredItem, 0, len(blended))
for _, item := range blended {
result = append(result, item)
}
sort.Slice(result, func(i, j int) bool {
if result[i].Score != result[j].Score {
return result[i].Score > result[j].Score
}
return result[i].MediaItemID < result[j].MediaItemID
})
return result
}
// jaccardSimilarity computes the Jaccard index between two string slices,
// defined as |intersection| / |union|. Returns 0 if both slices are empty.
func jaccardSimilarity(setA, setB []string) float64 {
if len(setA) == 0 && len(setB) == 0 {
return 0
}
members := make(map[string]struct{}, len(setA))
for _, v := range setA {
members[v] = struct{}{}
}
intersection := 0
for _, v := range setB {
if _, ok := members[v]; ok {
intersection++
}
}
union := len(members) + len(setB) - intersection
if union == 0 {
return 0
}
return float64(intersection) / float64(union)
}