- ollama/client.go: EmbedTexts accepts []clustering.Item, returns [][]float64 - backends.go: ollamaBackend interface uses clustering.Item - utils/styleProfiles.go: remove duplicate KMeans, normalize, cosineSimilarity - clustering/: new package with Item, Result types and KMeans - config.go: add EmbeddingModel, StyleModel env vars - main.go: pass model config to ollama.New() - go.mod: add github.com/ethereum/go-ethereum for test client
228 lines
4.3 KiB
Go
228 lines
4.3 KiB
Go
package clustering
|
|
|
|
import (
|
|
"errors"
|
|
"math"
|
|
"math/rand"
|
|
)
|
|
|
|
func initializeKMeansPlusPlus(
|
|
vectors [][]float64,
|
|
k int,
|
|
rng *rand.Rand,
|
|
) [][]float64 {
|
|
centroids := make([][]float64, 0, k)
|
|
|
|
first := rng.Intn(len(vectors))
|
|
centroids = append(
|
|
centroids,
|
|
append([]float64(nil), vectors[first]...),
|
|
)
|
|
|
|
distances := make([]float64, len(vectors))
|
|
|
|
for len(centroids) < k {
|
|
var total float64
|
|
|
|
for i, vector := range vectors {
|
|
bestSimilarity := -math.MaxFloat64
|
|
|
|
for _, centroid := range centroids {
|
|
similarity := cosineSimilarity(vector, centroid)
|
|
if similarity > bestSimilarity {
|
|
bestSimilarity = similarity
|
|
}
|
|
}
|
|
|
|
// cosine distance
|
|
distance := 1 - bestSimilarity
|
|
|
|
if distance < 0 {
|
|
distance = 0
|
|
}
|
|
|
|
// k-means++ uses squared distance.
|
|
distances[i] = distance * distance
|
|
total += distances[i]
|
|
}
|
|
|
|
if total == 0 {
|
|
break
|
|
}
|
|
|
|
target := rng.Float64() * total
|
|
|
|
var cumulative float64
|
|
selected := len(vectors) - 1
|
|
|
|
for i, distance := range distances {
|
|
cumulative += distance
|
|
|
|
if cumulative >= target {
|
|
selected = i
|
|
break
|
|
}
|
|
}
|
|
|
|
centroids = append(
|
|
centroids,
|
|
append([]float64(nil), vectors[selected]...),
|
|
)
|
|
}
|
|
|
|
// This can theoretically happen if all vectors are identical.
|
|
for len(centroids) < k {
|
|
index := rng.Intn(len(vectors))
|
|
|
|
centroids = append(
|
|
centroids,
|
|
append([]float64(nil), vectors[index]...),
|
|
)
|
|
}
|
|
|
|
return centroids
|
|
}
|
|
|
|
func KMeans(items []Item, k int, maxIterations int, seed int64) ([]Result, error) {
|
|
if len(items) == 0 {
|
|
return nil, errors.New("no items")
|
|
}
|
|
|
|
if k <= 0 {
|
|
return nil, errors.New("k must be positive")
|
|
}
|
|
|
|
if k > len(items) {
|
|
k = len(items)
|
|
}
|
|
|
|
if maxIterations <= 0 {
|
|
maxIterations = 50
|
|
}
|
|
|
|
dimension := len(items[0].Embedding)
|
|
if dimension == 0 {
|
|
return nil, errors.New("empty embedding")
|
|
}
|
|
|
|
// Normalize embeddings once.
|
|
normalized := make([][]float64, len(items))
|
|
for i, item := range items {
|
|
if len(item.Embedding) != dimension {
|
|
return nil, errors.New("embedding dimensions do not match")
|
|
}
|
|
|
|
normalized[i] = normalize(item.Embedding)
|
|
}
|
|
|
|
rng := rand.New(rand.NewSource(seed))
|
|
|
|
centroids := initializeKMeansPlusPlus(normalized, k, rng)
|
|
|
|
assignments := make([]int, len(items))
|
|
|
|
for iteration := 0; iteration < maxIterations; iteration++ {
|
|
changed := false
|
|
|
|
// Assignment step.
|
|
for i, vector := range normalized {
|
|
bestCluster := 0
|
|
bestSimilarity := -math.MaxFloat64
|
|
|
|
for cluster, centroid := range centroids {
|
|
similarity := cosineSimilarity(vector, centroid)
|
|
|
|
if similarity > bestSimilarity {
|
|
bestSimilarity = similarity
|
|
bestCluster = cluster
|
|
}
|
|
}
|
|
|
|
if iteration == 0 || assignments[i] != bestCluster {
|
|
assignments[i] = bestCluster
|
|
changed = true
|
|
}
|
|
}
|
|
|
|
// Stop if nothing changed.
|
|
if !changed && iteration > 0 {
|
|
break
|
|
}
|
|
|
|
// Update step.
|
|
newCentroids := make([][]float64, k)
|
|
counts := make([]int, k)
|
|
|
|
for cluster := 0; cluster < k; cluster++ {
|
|
newCentroids[cluster] = make([]float64, dimension)
|
|
}
|
|
|
|
for i, vector := range normalized {
|
|
cluster := assignments[i]
|
|
counts[cluster]++
|
|
|
|
for d, value := range vector {
|
|
newCentroids[cluster][d] += value
|
|
}
|
|
}
|
|
|
|
for cluster := 0; cluster < k; cluster++ {
|
|
if counts[cluster] == 0 {
|
|
// Empty cluster: reinitialize with a random item.
|
|
index := rng.Intn(len(normalized))
|
|
newCentroids[cluster] = append(
|
|
[]float64(nil),
|
|
normalized[index]...,
|
|
)
|
|
continue
|
|
}
|
|
|
|
newCentroids[cluster] = normalize(newCentroids[cluster])
|
|
}
|
|
|
|
centroids = newCentroids
|
|
}
|
|
|
|
results := make([]Result, k)
|
|
|
|
for cluster := 0; cluster < k; cluster++ {
|
|
results[cluster] = Result{
|
|
Cluster: cluster,
|
|
Centroid: centroids[cluster],
|
|
}
|
|
}
|
|
|
|
for i, item := range items {
|
|
cluster := assignments[i]
|
|
|
|
item.Embedding = normalized[i]
|
|
|
|
results[cluster].Items = append(
|
|
results[cluster].Items,
|
|
item,
|
|
)
|
|
}
|
|
|
|
// Find the item closest to each centroid.
|
|
for cluster := range results {
|
|
if len(results[cluster].Items) == 0 {
|
|
continue
|
|
}
|
|
|
|
bestSimilarity := -math.MaxFloat64
|
|
|
|
for _, item := range results[cluster].Items {
|
|
similarity := cosineSimilarity(
|
|
item.Embedding,
|
|
results[cluster].Centroid,
|
|
)
|
|
|
|
if similarity > bestSimilarity {
|
|
bestSimilarity = similarity
|
|
results[cluster].Representative = item
|
|
}
|
|
}
|
|
}
|
|
|
|
return results, nil
|
|
}
|