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

452 lines
11 KiB
Go

package recommendations
import (
"math"
"math/rand"
"sort"
"strings"
"time"
)
// clusterItem represents a single item to be clustered, carrying its embedding,
// signal weight, and genre metadata.
type clusterItem struct {
itemID string
embedding []float32
weight float64
genres []string
}
// kmeansMaxIterations is the maximum number of k-means refinement iterations.
const kmeansMaxIterations = 50
// kmeansConvergenceThreshold is the minimum centroid movement to continue iterating.
const kmeansConvergenceThreshold = 1e-6
// kmeansMinClusterSize is the minimum number of members a cluster must have
// before it gets merged into a neighbor.
const kmeansMinClusterSize = 3
// kmeansCluster partitions items into k groups using k-means with k-means++
// initialization. Centroids are computed as weighted averages using each item's
// signal weight and are L2-normalized after each update. Returns a slice of
// length len(items) mapping each item index to its assigned cluster index.
func kmeansCluster(items []clusterItem, k int) []int {
n := len(items)
if n == 0 || k <= 0 {
return nil
}
if k > n {
k = n
}
dims := len(items[0].embedding)
rng := rand.New(rand.NewSource(kmeansSeed(items, k)))
// --- k-means++ initialization ---
// Select the first centroid uniformly at random.
centroids := make([][]float64, 0, k)
firstIdx := rng.Intn(n)
centroids = append(centroids, float32ToFloat64(items[firstIdx].embedding))
// Select remaining centroids with probability proportional to D(x)^2.
distSq := make([]float64, n)
for c := 1; c < k; c++ {
totalDist := 0.0
for i := range items {
minD := math.MaxFloat64
for _, cent := range centroids {
d := l2DistSq(items[i].embedding, cent)
if d < minD {
minD = d
}
}
distSq[i] = minD
totalDist += minD
}
// Weighted random selection.
threshold := rng.Float64() * totalDist
cumulative := 0.0
chosen := 0
for i, d := range distSq {
cumulative += d
if cumulative >= threshold {
chosen = i
break
}
}
centroids = append(centroids, float32ToFloat64(items[chosen].embedding))
}
assignments := make([]int, n)
// --- Main k-means loop ---
for iter := 0; iter < kmeansMaxIterations; iter++ {
// Assignment step: assign each item to the nearest centroid.
for i := range items {
bestCluster := 0
bestDist := math.MaxFloat64
for c := range centroids {
d := l2DistSq(items[i].embedding, centroids[c])
if d < bestDist {
bestDist = d
bestCluster = c
}
}
assignments[i] = bestCluster
}
// Update step: recompute centroids as weighted averages, then L2-normalize.
newCentroids := make([][]float64, k)
for c := 0; c < k; c++ {
newCentroids[c] = make([]float64, dims)
}
for i, ci := range assignments {
w := items[i].weight
if w <= 0 {
w = 0.01 // small floor so every item contributes
}
for d := 0; d < dims; d++ {
newCentroids[ci][d] += float64(items[i].embedding[d]) * w
}
}
// L2-normalize each centroid.
for c := 0; c < k; c++ {
l2NormalizeFloat64(newCentroids[c])
}
// Check convergence: if all centroids moved less than the threshold, stop.
maxMovement := 0.0
for c := 0; c < k; c++ {
movement := 0.0
for d := 0; d < dims; d++ {
diff := newCentroids[c][d] - centroids[c][d]
movement += diff * diff
}
movement = math.Sqrt(movement)
if movement > maxMovement {
maxMovement = movement
}
}
centroids = newCentroids
if maxMovement < kmeansConvergenceThreshold {
break
}
}
// Final assignment pass to ensure consistency with final centroids.
for i := range items {
bestCluster := 0
bestDist := math.MaxFloat64
for c := range centroids {
d := l2DistSq(items[i].embedding, centroids[c])
if d < bestDist {
bestDist = d
bestCluster = c
}
}
assignments[i] = bestCluster
}
return assignments
}
func kmeansSeed(items []clusterItem, k int) int64 {
seed := int64(1469598103934665603)
seed = seed*1099511628211 + int64(k)
for _, item := range items {
for _, b := range []byte(item.itemID) {
seed ^= int64(b)
seed *= 1099511628211
}
seed ^= int64(math.Float64bits(item.weight))
seed *= 1099511628211
}
if seed == 0 {
return 1
}
return seed
}
// determinClusterCount maps the number of items to a suitable cluster count.
func determinClusterCount(itemCount int) int {
switch {
case itemCount < 10:
return 1
case itemCount < 20:
return 2
case itemCount <= 30:
return 3
case itemCount < 60:
return 3
case itemCount <= 100:
return 4
case itemCount < 200:
return 4
default:
return 5
}
}
// mergSmallClusters reassigns items in clusters with fewer than kmeansMinClusterSize
// members to the nearest non-small cluster. Returns updated assignments with
// cluster indices compacted to be contiguous starting from 0.
func mergSmallClusters(items []clusterItem, assignments []int, k int) []int {
if len(items) == 0 {
return assignments
}
// Count members per cluster.
clusterSize := make(map[int]int)
for _, c := range assignments {
clusterSize[c]++
}
// Compute centroid of each cluster (weighted).
dims := len(items[0].embedding)
centroids := make(map[int][]float64)
for c := range clusterSize {
centroids[c] = make([]float64, dims)
}
for i, c := range assignments {
w := items[i].weight
if w <= 0 {
w = 0.01
}
for d := 0; d < dims; d++ {
centroids[c][d] += float64(items[i].embedding[d]) * w
}
}
for c := range centroids {
l2NormalizeFloat64(centroids[c])
}
// Identify small clusters and valid (non-small) clusters.
smallClusters := make(map[int]bool)
for c, size := range clusterSize {
if size < kmeansMinClusterSize {
smallClusters[c] = true
}
}
// If all clusters are small, skip merging to avoid losing all data.
nonSmallCount := 0
for c := range clusterSize {
if !smallClusters[c] {
nonSmallCount++
}
}
if nonSmallCount == 0 {
return compactAssignments(assignments)
}
// Reassign items from small clusters to the nearest non-small cluster.
for i, c := range assignments {
if !smallClusters[c] {
continue
}
bestCluster := -1
bestDist := math.MaxFloat64
for target, cent := range centroids {
if smallClusters[target] {
continue
}
d := l2DistSq(items[i].embedding, cent)
if d < bestDist {
bestDist = d
bestCluster = target
}
}
if bestCluster >= 0 {
assignments[i] = bestCluster
}
}
return compactAssignments(assignments)
}
// compactAssignments remaps cluster indices to be contiguous starting from 0.
func compactAssignments(assignments []int) []int {
seen := make(map[int]int)
next := 0
result := make([]int, len(assignments))
for i, c := range assignments {
if _, ok := seen[c]; !ok {
seen[c] = next
next++
}
result[i] = seen[c]
}
return result
}
// buildTasteClusters is the main entry point for computing taste sub-profiles.
// It determines the appropriate cluster count, runs weighted k-means clustering,
// merges undersized clusters, and returns labeled TasteCluster values with
// computed embeddings, dominant genres, and aggregate statistics.
func buildTasteClusters(items []clusterItem) []TasteCluster {
if len(items) == 0 {
return nil
}
// Filter out items without embeddings.
valid := make([]clusterItem, 0, len(items))
for _, item := range items {
if len(item.embedding) > 0 {
valid = append(valid, item)
}
}
if len(valid) == 0 {
return nil
}
// Step 1: Determine how many clusters to target.
k := determinClusterCount(len(valid))
// Step 2: Run k-means clustering.
assignments := kmeansCluster(valid, k)
// Step 3: Merge clusters that are too small.
assignments = mergSmallClusters(valid, assignments, k)
// Step 4: Build the output TasteCluster for each cluster.
clusterMap := make(map[int][]int) // cluster index -> item indices
for i, c := range assignments {
clusterMap[c] = append(clusterMap[c], i)
}
// Collect cluster indices and sort for deterministic output order.
clusterIndices := make([]int, 0, len(clusterMap))
for c := range clusterMap {
clusterIndices = append(clusterIndices, c)
}
sort.Ints(clusterIndices)
clusters := make([]TasteCluster, 0, len(clusterIndices))
for _, ci := range clusterIndices {
memberIndices := clusterMap[ci]
// Gather embeddings and weights for the weighted average.
vecs := make([][]float32, len(memberIndices))
weights := make([]float64, len(memberIndices))
genreCounts := make(map[string]int)
totalWeight := 0.0
for j, idx := range memberIndices {
vecs[j] = valid[idx].embedding
weights[j] = valid[idx].weight
totalWeight += valid[idx].weight
for _, g := range valid[idx].genres {
genreCounts[g]++
}
}
// Compute L2-normalized weighted average embedding.
embedding := weightedAverage(vecs, weights)
// Determine dominant genres (top 3 by frequency).
dominantGenres := topNGenres(genreCounts, 3)
// Build a human-readable label from the dominant genres.
label := buildClusterLabel(dominantGenres)
clusters = append(clusters, TasteCluster{
ClusterIdx: ci,
Embedding: embedding,
DominantGenres: dominantGenres,
Label: label,
MemberCount: len(memberIndices),
TotalWeight: totalWeight,
UpdatedAt: time.Now(),
})
}
return clusters
}
// topNGenres returns up to n genre names sorted by descending frequency.
func topNGenres(counts map[string]int, n int) []string {
type genreCount struct {
genre string
count int
}
pairs := make([]genreCount, 0, len(counts))
for g, c := range counts {
pairs = append(pairs, genreCount{g, c})
}
sort.Slice(pairs, func(i, j int) bool {
if pairs[i].count != pairs[j].count {
return pairs[i].count > pairs[j].count
}
return pairs[i].genre < pairs[j].genre // stable tie-breaking
})
end := n
if end > len(pairs) {
end = len(pairs)
}
result := make([]string, end)
for i := 0; i < end; i++ {
result[i] = pairs[i].genre
}
return result
}
// buildClusterLabel creates a display label by joining genre names with " & ".
// Returns "Mixed" if no genres are available.
func buildClusterLabel(genres []string) string {
if len(genres) == 0 {
return "Mixed"
}
return strings.Join(genres, " & ")
}
// --- Vector math helpers ---
// float32ToFloat64 converts a float32 slice to float64.
func float32ToFloat64(v []float32) []float64 {
out := make([]float64, len(v))
for i, val := range v {
out[i] = float64(val)
}
return out
}
// l2DistSq computes the squared L2 distance between a float32 vector and a
// float64 centroid. If either vector is nil, returns 0.
func l2DistSq(a []float32, b []float64) float64 {
if a == nil || b == nil {
return 0
}
sum := 0.0
for i := range a {
diff := float64(a[i]) - b[i]
sum += diff * diff
}
return sum
}
// l2NormalizeFloat64 normalizes a float64 vector in-place to unit length.
// If the vector has zero magnitude, it is left unchanged.
func l2NormalizeFloat64(v []float64) {
var norm float64
for _, val := range v {
norm += val * val
}
norm = math.Sqrt(norm)
if norm == 0 {
return
}
for i := range v {
v[i] /= norm
}
}