Vector: Reorganize package into topic-based files #4669

Split the catch-all values.go into one file per concept, each mirrored
by its test: distance.go, norm.go, stats.go, product.go, centroid.go,
plus the mean methods folded into mean.go and Copy/Dim/Sum into
vector.go. Remove values.go, values_test.go, and values_more_test.go so
functionality and tests live where developers expect them.

Hoist the two 512-dimensional face embeddings shared by the distance,
norm, and cosine tests into fixtures_test.go, removing the previous
triplication, and decompose the monolithic TestVector into per-concept
tests. Close pre-existing coverage gaps in the integer converters and
the GeometricMean/HarmonicMean method wrappers, bringing the package to
100% statement coverage. Pure code movement; no behavior change.
This commit is contained in:
Michael Mayer 2026-05-30 09:44:03 +00:00
parent 40b170ecb0
commit 03129c9129
20 changed files with 700 additions and 611 deletions

40
pkg/vector/centroid.go Normal file
View file

@ -0,0 +1,40 @@
package vector
// Centroid returns the element-wise mean (centroid) of the given vectors as a
// new, independent vector. Vectors whose length differs from the first vector
// are ignored, and the mean is taken over the vectors actually included. It
// returns nil when vs is empty or the first vector has no elements.
func Centroid(vs Vectors) Vector {
if len(vs) == 0 {
return nil
}
dim := len(vs[0])
if dim == 0 {
return nil
}
result := make(Vector, dim)
n := 0
for _, v := range vs {
if len(v) != dim {
continue
}
for j := range dim {
result[j] += v[j]
}
n++
}
inv := 1 / float64(n)
for j := range result {
result[j] *= inv
}
return result
}

View file

@ -0,0 +1,39 @@
package vector
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestCentroid(t *testing.T) {
t.Run("Mean", func(t *testing.T) {
got := Centroid(Vectors{{1, 2}, {3, 4}})
assert.Equal(t, Vector{2, 3}, got)
})
t.Run("Single", func(t *testing.T) {
got := Centroid(Vectors{{2, 4, 6}})
assert.Equal(t, Vector{2, 4, 6}, got)
})
t.Run("SkipsMismatchedDimensions", func(t *testing.T) {
// The 3-D vector is ignored; the mean is taken over the two 2-D vectors.
got := Centroid(Vectors{{1, 2}, {1, 2, 3}, {3, 4}})
assert.Equal(t, Vector{2, 3}, got)
})
t.Run("Empty", func(t *testing.T) {
assert.Nil(t, Centroid(Vectors{}))
assert.Nil(t, Centroid(nil))
})
t.Run("FirstVectorEmpty", func(t *testing.T) {
assert.Nil(t, Centroid(Vectors{{}, {1, 2}}))
})
t.Run("Independent", func(t *testing.T) {
a := Vector{1, 2}
b := Vector{3, 4}
got := Centroid(Vectors{a, b})
got[0] = 100
// Mutating the result must not change the inputs.
assert.Equal(t, Vector{1, 2}, a)
assert.Equal(t, Vector{3, 4}, b)
})
}

82
pkg/vector/distance.go Normal file
View file

@ -0,0 +1,82 @@
package vector
import "math"
// EuclideanDist returns the Euclidean distance between the vectors,
func (v Vector) EuclideanDist(w Vector) float64 {
return EuclideanDist(v, w)
}
// CosineSimilarity returns the cosine similarity between two vectors,
// ranging from -1 (opposite) to 1 (identical).
func (v Vector) CosineSimilarity(w Vector) float64 {
return CosineSimilarity(v, w)
}
// CosineDist returns the cosine distance between two vectors (1 - cosine similarity).
func (v Vector) CosineDist(w Vector) float64 {
return CosineDist(v, w)
}
// EuclideanDist returns the Euclidean distance between multiple vectors.
func EuclideanDist(a, b Vector) float64 {
if a.Dim() != b.Dim() {
return NaN()
}
var (
s, t float64
)
for i := range a {
t = a[i] - b[i]
s += t * t
}
return math.Sqrt(s)
}
// CosineSimilarity returns the cosine similarity between two vectors, ranging
// from -1 (opposite) to 1 (identical). It returns NaN when the dimensions
// differ and 0 when either operand is a zero vector.
func CosineSimilarity(a, b Vector) float64 {
if a.Dim() != b.Dim() {
return NaN()
}
var sum, s1, s2 float64
for i := range a {
sum += a[i] * b[i]
s1 += a[i] * a[i]
s2 += b[i] * b[i]
}
if s1 == 0 || s2 == 0 {
return 0.0
}
return sum / (math.Sqrt(s1) * math.Sqrt(s2))
}
// CosineDist returns the cosine distance between two vectors, defined as
// 1 - CosineSimilarity. Identical vectors yield 0; it returns NaN when the
// dimensions differ.
func CosineDist(a, b Vector) float64 {
return 1.0 - CosineSimilarity(a, b)
}
// CosineDists returns the cosine distances between two sets of vectors.
func CosineDists(x, y Vectors) Vectors {
result := make(Vectors, len(x))
for i, a := range x {
result[i] = make([]float64, len(y))
for j, b := range y {
result[i][j] = CosineDist(a, b)
}
}
return result
}

116
pkg/vector/distance_test.go Normal file
View file

@ -0,0 +1,116 @@
package vector
import (
"math"
"testing"
"github.com/stretchr/testify/assert"
)
func TestEuclideanDist(t *testing.T) {
a := Vector{1, 2, 3, 4, 6, 5}
b := Vector{2, 1, 3, 4, 5, 6}
d := Vector{0, 0, 0, 0, 0, 0}
e := Vector{}
n := make(Vector, 512)
t.Run("Method", func(t *testing.T) {
assert.InDelta(t, 2, a.EuclideanDist(b), 0.01)
assert.InDelta(t, a.EuclideanDist(b), b.EuclideanDist(a), 0.01)
assert.True(t, math.IsNaN(faceEmbeddingB.EuclideanDist(d)))
assert.InDelta(t, 0, d.EuclideanDist(d), 0.01)
assert.True(t, math.IsNaN(e.EuclideanDist(d)))
assert.InDelta(t, 0.9999999779072661, faceEmbeddingB.EuclideanDist(n), 0.01)
})
t.Run("Func", func(t *testing.T) {
assert.InDelta(t, 2.0, EuclideanDist(a, b), 0.01)
})
}
func TestCosineSimilarity(t *testing.T) {
a := Vector{1, 2, 3, 4, 6, 5}
b := Vector{2, 1, 3, 4, 5, 6}
d := Vector{0, 0, 0, 0, 0, 0}
e := Vector{}
n := make(Vector, 512)
t.Run("Values", func(t *testing.T) {
assert.InDelta(t, 0.978021978021978, a.CosineSimilarity(b), 0.01)
assert.True(t, math.IsNaN(faceEmbeddingB.CosineSimilarity(d)))
assert.InDelta(t, 0, d.CosineSimilarity(d), 0.01)
assert.True(t, math.IsNaN(e.CosineSimilarity(d)))
assert.InDelta(t, 0, faceEmbeddingB.CosineSimilarity(n), 0.01)
assert.InDelta(t, 0, n.CosineSimilarity(n), 0.01)
assert.InDelta(t, 1.0, faceEmbeddingB.CosineSimilarity(faceEmbeddingB), 0.01)
assert.InDelta(t, 1.0, a.CosineSimilarity(a), 0.01)
assert.InDelta(t, 1.0, b.CosineSimilarity(b), 0.01)
})
t.Run("Func", func(t *testing.T) {
assert.InDelta(t, 0.978021978021978, CosineSimilarity(a, b), 0.01)
})
t.Run("Orthogonal", func(t *testing.T) {
assert.InDelta(t, 0.0, CosineSimilarity(Vector{1, 0}, Vector{0, 1}), 0.00001)
assert.InDelta(t, 1.0, CosineDist(Vector{1, 0}, Vector{0, 1}), 0.00001)
})
t.Run("Opposite", func(t *testing.T) {
assert.InDelta(t, -1.0, CosineSimilarity(Vector{1, 0}, Vector{-1, 0}), 0.00001)
assert.InDelta(t, 2.0, CosineDist(Vector{1, 0}, Vector{-1, 0}), 0.00001)
})
t.Run("DimensionMismatch", func(t *testing.T) {
assert.True(t, math.IsNaN(CosineSimilarity(Vector{1, 0}, Vector{1, 0, 0})))
assert.True(t, math.IsNaN(CosineDist(Vector{1, 0}, Vector{1, 0, 0})))
})
}
func TestCosineDist(t *testing.T) {
a := Vector{1, 2, 3, 4, 6, 5}
b := Vector{2, 1, 3, 4, 5, 6}
d := Vector{0, 0, 0, 0, 0, 0}
e := Vector{}
n := make(Vector, 512)
t.Run("Values", func(t *testing.T) {
// Distance is 1 - similarity: 0 for identical, 1 for a zero vector, NaN on dim mismatch.
assert.InDelta(t, 0.021978021978022, a.CosineDist(b), 0.01)
assert.True(t, math.IsNaN(faceEmbeddingB.CosineDist(d)))
assert.InDelta(t, 1.0, d.CosineDist(d), 0.01)
assert.True(t, math.IsNaN(e.CosineDist(d)))
assert.InDelta(t, 1.0, faceEmbeddingB.CosineDist(n), 0.01)
assert.InDelta(t, 1.0, n.CosineDist(n), 0.01)
assert.InDelta(t, 0, faceEmbeddingB.CosineDist(faceEmbeddingB), 0.01)
assert.InDelta(t, 0, a.CosineDist(a), 0.01)
assert.InDelta(t, 0, b.CosineDist(b), 0.01)
})
t.Run("Func", func(t *testing.T) {
assert.InDelta(t, 0.021978021978022, CosineDist(a, b), 0.01)
})
t.Run("Equal", func(t *testing.T) {
x := Vector{1, 0, 0, 1, 0, 0}
y := Vector{1, 0, 0, 1, 0, 0}
// Identical vectors: similarity 1, distance 0.
assert.InDelta(t, 1.0, CosineSimilarity(x, y), 0.00001)
assert.InDelta(t, 0.0, CosineDist(x, y), 0.00001)
})
t.Run("Faces", func(t *testing.T) {
// Real face embeddings: near-orthogonal, so distance is ~1.
assert.InDelta(t, -0.003275301858301365, CosineSimilarity(faceEmbeddingA, faceEmbeddingB), 0.00001)
assert.InDelta(t, 1.003275301858301365, CosineDist(faceEmbeddingA, faceEmbeddingB), 0.00001)
})
}
func TestCosineDists(t *testing.T) {
x := Vectors{{1, 0}, {0, 1}}
y := Vectors{{1, 0}, {-1, 0}}
got := CosineDists(x, y)
assert.Len(t, got, 2)
assert.InDelta(t, 0.0, got[0][0], 0.00001) // identical
assert.InDelta(t, 2.0, got[0][1], 0.00001) // opposite
assert.InDelta(t, 1.0, got[1][0], 0.00001) // orthogonal
assert.InDelta(t, 1.0, got[1][1], 0.00001) // orthogonal
}
func BenchmarkCosineDist(b *testing.B) {
for b.Loop() {
CosineDist(faceEmbeddingA, faceEmbeddingB)
}
}

File diff suppressed because one or more lines are too long

View file

@ -1,6 +1,9 @@
package vector
import "math"
import (
"fmt"
"math"
)
// Mean gets the average of a slice of numbers
func Mean(v Vector) float64 {
@ -56,3 +59,47 @@ func HarmonicMean(v Vector) float64 {
return float64(l) / p
}
// Mean returns the vector's mean value.
func (v Vector) Mean() float64 {
return Mean(v)
}
// GeometricMean returns the vector's geometric mean value.
func (v Vector) GeometricMean() float64 {
return GeometricMean(v)
}
// HarmonicMean returns the vector's harmonic mean value.
func (v Vector) HarmonicMean() float64 {
return HarmonicMean(v)
}
// weightedSum returns the weighted sum of the vector. This is really only useful in
// calculating the weighted mean.
func (v Vector) weightedSum(w Vector) (float64, error) {
if len(v) != len(w) {
return NaN(), fmt.Errorf("length of weights unequal to vector length")
}
ws := 0.0
for i := range v {
ws += v[i] * w[i]
}
return ws, nil
}
// WeightedMean returns the vector's weighted mean value based of the specified weights.
func (v Vector) WeightedMean(w Vector) (float64, error) {
ws, err := v.weightedSum(w)
if err != nil {
return NaN(), err
}
sw := w.Sum()
return ws / sw, nil
}

View file

@ -26,6 +26,9 @@ func TestGeometricMean(t *testing.T) {
t.Run("PowersOfThree", func(t *testing.T) {
assert.InDelta(t, math.Pow(3, 1.5), GeometricMean(Vector{1, 3, 9, 27}), 0.00001)
})
t.Run("Method", func(t *testing.T) {
assert.InDelta(t, 4.0, Vector{2, 8}.GeometricMean(), 0.00001)
})
t.Run("ContainsZero", func(t *testing.T) {
// A zero element drives the product to 0, so the geometric mean is 0.
assert.InDelta(t, 0.0, GeometricMean(Vector{2, 0, 8}), 0.00001)
@ -48,6 +51,9 @@ func TestHarmonicMean(t *testing.T) {
t.Run("Equal", func(t *testing.T) {
assert.InDelta(t, 2.0, HarmonicMean(Vector{2, 2}), 0.00001)
})
t.Run("Method", func(t *testing.T) {
assert.InDelta(t, 3.0/1.75, Vector{1, 2, 4}.HarmonicMean(), 0.00001)
})
t.Run("ContainsZero", func(t *testing.T) {
assert.True(t, math.IsNaN(HarmonicMean(Vector{1, 0, 2})))
})
@ -58,3 +64,16 @@ func TestHarmonicMean(t *testing.T) {
assert.True(t, math.IsNaN(HarmonicMean(Vector{})))
})
}
func TestVector_WeightedMean(t *testing.T) {
t.Run("Values", func(t *testing.T) {
r, err := Vector{1, 2, 4}.WeightedMean(Vector{1, 0, 1})
assert.NoError(t, err)
assert.InDelta(t, 2.5, r, 0.00001)
})
t.Run("LengthMismatch", func(t *testing.T) {
r, err := Vector{1, 2, 4}.WeightedMean(Vector{1, 1})
assert.Error(t, err)
assert.True(t, math.IsNaN(r))
})
}

28
pkg/vector/norm.go Normal file
View file

@ -0,0 +1,28 @@
package vector
import "math"
// Norm returns the vector size (magnitude),
// see https://builtin.com/data-science/vector-norms.
func (v Vector) Norm(pow float64) float64 {
return Norm(v, pow)
}
// EuclideanNorm returns the Euclidean vector size (magnitude),
// see https://builtin.com/data-science/vector-norms.
func (v Vector) EuclideanNorm() float64 {
return v.Norm(2.0)
}
// Norm returns the size of the vector (use pow = 2.0 for the Euclidean distance),
// see https://builtin.com/data-science/vector-norms. Absolute values are used so
// that odd powers (e.g. the L1 norm) stay well-defined for negative components.
func Norm(v Vector, pow float64) float64 {
s := 0.0
for _, f := range v {
s += math.Pow(math.Abs(f), pow)
}
return math.Pow(s, 1/pow)
}

32
pkg/vector/norm_test.go Normal file
View file

@ -0,0 +1,32 @@
package vector
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestVector_EuclideanNorm(t *testing.T) {
a := Vector{1, 2, 3, 4, 6, 5}
b := Vector{2, 1, 3, 4, 5, 6}
d := Vector{0, 0, 0, 0, 0, 0}
e := Vector{}
assert.InDelta(t, 9.539392014169456, a.EuclideanNorm(), 0.01)
assert.InDelta(t, 9.539392014169456, b.EuclideanNorm(), 0.01)
assert.Equal(t, a.EuclideanNorm(), b.EuclideanNorm())
assert.InDelta(t, 0.9999999779072661, faceEmbeddingB.EuclideanNorm(), 0.01)
assert.InDelta(t, 0, d.EuclideanNorm(), 0.01)
assert.InDelta(t, 0, e.EuclideanNorm(), 0.01)
assert.Equal(t, d.EuclideanNorm(), e.EuclideanNorm())
}
func TestNorm(t *testing.T) {
t.Run("Euclidean", func(t *testing.T) {
assert.InDelta(t, 5.0, Norm(Vector{3, -4}, 2.0), 0.00001)
})
t.Run("Manhattan", func(t *testing.T) {
// L1 norm uses absolute values, so negatives contribute their magnitude.
assert.InDelta(t, 5.0, Norm(Vector{1, -2, 2}, 1.0), 0.00001)
})
}

32
pkg/vector/normalize.go Normal file
View file

@ -0,0 +1,32 @@
package vector
import "math"
// Normalize scales the vector to unit length (L2 norm) in place.
// A zero vector (including an empty one) is left unchanged to avoid
// a division by zero.
func (v Vector) Normalize() {
var sum float64
for _, f := range v {
sum += f * f
}
if sum == 0 {
return
}
inv := 1 / math.Sqrt(sum)
for i := range v {
v[i] *= inv
}
}
// Normalized returns an L2-normalized copy of the vector,
// leaving the receiver unchanged.
func (v Vector) Normalized() Vector {
c := v.Copy()
c.Normalize()
return c
}

View file

@ -58,35 +58,3 @@ func TestVector_Normalized(t *testing.T) {
assert.Equal(t, Vector{}, Vector{}.Normalized())
})
}
func TestCentroid(t *testing.T) {
t.Run("Mean", func(t *testing.T) {
got := Centroid(Vectors{{1, 2}, {3, 4}})
assert.Equal(t, Vector{2, 3}, got)
})
t.Run("Single", func(t *testing.T) {
got := Centroid(Vectors{{2, 4, 6}})
assert.Equal(t, Vector{2, 4, 6}, got)
})
t.Run("SkipsMismatchedDimensions", func(t *testing.T) {
// The 3-D vector is ignored; the mean is taken over the two 2-D vectors.
got := Centroid(Vectors{{1, 2}, {1, 2, 3}, {3, 4}})
assert.Equal(t, Vector{2, 3}, got)
})
t.Run("Empty", func(t *testing.T) {
assert.Nil(t, Centroid(Vectors{}))
assert.Nil(t, Centroid(nil))
})
t.Run("FirstVectorEmpty", func(t *testing.T) {
assert.Nil(t, Centroid(Vectors{{}, {1, 2}}))
})
t.Run("Independent", func(t *testing.T) {
a := Vector{1, 2}
b := Vector{3, 4}
got := Centroid(Vectors{a, b})
got[0] = 100
// Mutating the result must not change the inputs.
assert.Equal(t, Vector{1, 2}, a)
assert.Equal(t, Vector{3, 4}, b)
})
}

29
pkg/vector/product.go Normal file
View file

@ -0,0 +1,29 @@
package vector
import "fmt"
// Product returns a vector of element-wise products of two input vectors.
func Product(a, b Vector) (Vector, error) {
if len(a) != len(b) {
return nil, fmt.Errorf("vector dimensions do not match (%d, %d)", len(a), len(b))
}
p := make(Vector, len(a))
for i := range a {
p[i] = a[i] * b[i]
}
return p, nil
}
// DotProduct returns the dot product of two vectors.
func DotProduct(a, b Vector) (float64, error) {
p, err := Product(a, b)
if err != nil {
return NaN(), err
}
return p.Sum(), nil
}

View file

@ -0,0 +1,34 @@
package vector
import (
"math"
"testing"
"github.com/stretchr/testify/assert"
)
func TestProduct(t *testing.T) {
t.Run("Values", func(t *testing.T) {
p, err := Product(Vector{1, 2, 3}, Vector{4, 5, 6})
assert.NoError(t, err)
assert.Equal(t, Vector{4, 10, 18}, p)
})
t.Run("LengthMismatch", func(t *testing.T) {
p, err := Product(Vector{1, 2, 3}, Vector{4, 5})
assert.Error(t, err)
assert.Nil(t, p)
})
}
func TestDotProduct(t *testing.T) {
t.Run("Values", func(t *testing.T) {
r, err := DotProduct(Vector{1, 2, 3}, Vector{4, 5, 6})
assert.NoError(t, err)
assert.InDelta(t, 32.0, r, 0.00001)
})
t.Run("LengthMismatch", func(t *testing.T) {
r, err := DotProduct(Vector{1, 2, 3}, Vector{4, 5})
assert.Error(t, err)
assert.True(t, math.IsNaN(r))
})
}

53
pkg/vector/stats.go Normal file
View file

@ -0,0 +1,53 @@
package vector
import "math"
// Sd calculates the vector's standard deviation.
func (v Vector) Sd() float64 {
return math.Sqrt(v.Variance())
}
// Variance calculates the vector's variance.
func (v Vector) Variance() float64 {
return v.variance(v.Mean())
}
// variance returns the sample variance around the given mean.
// Empty and single-element vectors have zero variance by convention,
// which also avoids a division by zero in the n-1 denominator.
func (v Vector) variance(mean float64) float64 {
n := float64(len(v))
if n < 2 {
return 0
}
ss := 0.0
for _, f := range v {
d := f - mean
ss += d * d
}
return ss / (n - 1)
}
// Cor returns the Pearson correlation between two vectors.
func Cor(a, b Vector) (float64, error) {
n := float64(len(a))
xy, err := Product(a, b)
if err != nil {
return NaN(), err
}
sx := a.Sd()
sy := b.Sd()
mx := a.Mean()
my := b.Mean()
r := (xy.Sum() - n*mx*my) / ((n - 1) * sx * sy)
return r, nil
}

38
pkg/vector/stats_test.go Normal file
View file

@ -0,0 +1,38 @@
package vector
import (
"math"
"testing"
"github.com/stretchr/testify/assert"
)
func TestVector_Variance(t *testing.T) {
t.Run("Values", func(t *testing.T) {
assert.InDelta(t, 32.0/7.0, Vector{2, 4, 4, 4, 5, 5, 7, 9}.Variance(), 0.00001)
})
t.Run("Single", func(t *testing.T) {
assert.InDelta(t, 0.0, Vector{5}.Variance(), 0.00001)
})
t.Run("Empty", func(t *testing.T) {
assert.InDelta(t, 0.0, Vector{}.Variance(), 0.00001)
})
}
func TestVector_Sd(t *testing.T) {
assert.InDelta(t, math.Sqrt(32.0/7.0), Vector{2, 4, 4, 4, 5, 5, 7, 9}.Sd(), 0.00001)
assert.InDelta(t, 0.0, Vector{5}.Sd(), 0.00001)
}
func TestCor(t *testing.T) {
t.Run("PerfectPositive", func(t *testing.T) {
r, err := Cor(Vector{1, 2, 3}, Vector{2, 4, 6})
assert.NoError(t, err)
assert.InDelta(t, 1.0, r, 0.00001)
})
t.Run("LengthMismatch", func(t *testing.T) {
r, err := Cor(Vector{1, 2, 3}, Vector{1, 2})
assert.Error(t, err)
assert.True(t, math.IsNaN(r))
})
}

View file

@ -1,321 +0,0 @@
package vector
import (
"fmt"
"math"
)
// Copy returns a copy of the vector.
func (v Vector) Copy() Vector {
y := make(Vector, len(v))
copy(y, v)
return y
}
// Dim returns the number of values (dimension).
func (v Vector) Dim() int {
return len(v)
}
// Sum returns the sum of the vector values.
func (v Vector) Sum() float64 {
s := 0.0
for _, f := range v {
s += f
}
return s
}
// weightedSum returns the weighted sum of the vector. This is really only useful in
// calculating the weighted mean.
func (v Vector) weightedSum(w Vector) (float64, error) {
if len(v) != len(w) {
return NaN(), fmt.Errorf("length of weights unequal to vector length")
}
ws := 0.0
for i := range v {
ws += v[i] * w[i]
}
return ws, nil
}
// Mean returns the vector's mean value.
func (v Vector) Mean() float64 {
return Mean(v)
}
// GeometricMean returns the vector's geometric mean value.
func (v Vector) GeometricMean() float64 {
return GeometricMean(v)
}
// HarmonicMean returns the vector's harmonic mean value.
func (v Vector) HarmonicMean() float64 {
return HarmonicMean(v)
}
// WeightedMean returns the vector's weighted mean value based of the specified weights.
func (v Vector) WeightedMean(w Vector) (float64, error) {
ws, err := v.weightedSum(w)
if err != nil {
return NaN(), err
}
sw := w.Sum()
return ws / sw, nil
}
// Sd calculates the vector's standard deviation.
func (v Vector) Sd() float64 {
return math.Sqrt(v.Variance())
}
// Variance calculates the vector's variance.
func (v Vector) Variance() float64 {
return v.variance(v.Mean())
}
// EuclideanDist returns the Euclidean distance between the vectors,
func (v Vector) EuclideanDist(w Vector) float64 {
return EuclideanDist(v, w)
}
// CosineSimilarity returns the cosine similarity between two vectors,
// ranging from -1 (opposite) to 1 (identical).
func (v Vector) CosineSimilarity(w Vector) float64 {
return CosineSimilarity(v, w)
}
// CosineDist returns the cosine distance between two vectors (1 - cosine similarity).
func (v Vector) CosineDist(w Vector) float64 {
return CosineDist(v, w)
}
// Norm returns the vector size (magnitude),
// see https://builtin.com/data-science/vector-norms.
func (v Vector) Norm(pow float64) float64 {
return Norm(v, pow)
}
// EuclideanNorm returns the Euclidean vector size (magnitude),
// see https://builtin.com/data-science/vector-norms.
func (v Vector) EuclideanNorm() float64 {
return v.Norm(2.0)
}
// Normalize scales the vector to unit length (L2 norm) in place.
// A zero vector (including an empty one) is left unchanged to avoid
// a division by zero.
func (v Vector) Normalize() {
var sum float64
for _, f := range v {
sum += f * f
}
if sum == 0 {
return
}
inv := 1 / math.Sqrt(sum)
for i := range v {
v[i] *= inv
}
}
// Normalized returns an L2-normalized copy of the vector,
// leaving the receiver unchanged.
func (v Vector) Normalized() Vector {
c := v.Copy()
c.Normalize()
return c
}
// variance returns the sample variance around the given mean.
// Empty and single-element vectors have zero variance by convention,
// which also avoids a division by zero in the n-1 denominator.
func (v Vector) variance(mean float64) float64 {
n := float64(len(v))
if n < 2 {
return 0
}
ss := 0.0
for _, f := range v {
d := f - mean
ss += d * d
}
return ss / (n - 1)
}
// Product returns a vector of element-wise products of two input vectors.
func Product(a, b Vector) (Vector, error) {
if len(a) != len(b) {
return nil, fmt.Errorf("vector dimensions do not match (%d, %d)", len(a), len(b))
}
p := make(Vector, len(a))
for i := range a {
p[i] = a[i] * b[i]
}
return p, nil
}
// DotProduct returns the dot product of two vectors.
func DotProduct(a, b Vector) (float64, error) {
p, err := Product(a, b)
if err != nil {
return NaN(), err
}
return p.Sum(), nil
}
// Norm returns the size of the vector (use pow = 2.0 for the Euclidean distance),
// see https://builtin.com/data-science/vector-norms. Absolute values are used so
// that odd powers (e.g. the L1 norm) stay well-defined for negative components.
func Norm(v Vector, pow float64) float64 {
s := 0.0
for _, f := range v {
s += math.Pow(math.Abs(f), pow)
}
return math.Pow(s, 1/pow)
}
// EuclideanDist returns the Euclidean distance between multiple vectors.
func EuclideanDist(a, b Vector) float64 {
if a.Dim() != b.Dim() {
return NaN()
}
var (
s, t float64
)
for i := range a {
t = a[i] - b[i]
s += t * t
}
return math.Sqrt(s)
}
// CosineSimilarity returns the cosine similarity between two vectors, ranging
// from -1 (opposite) to 1 (identical). It returns NaN when the dimensions
// differ and 0 when either operand is a zero vector.
func CosineSimilarity(a, b Vector) float64 {
if a.Dim() != b.Dim() {
return NaN()
}
var sum, s1, s2 float64
for i := range a {
sum += a[i] * b[i]
s1 += a[i] * a[i]
s2 += b[i] * b[i]
}
if s1 == 0 || s2 == 0 {
return 0.0
}
return sum / (math.Sqrt(s1) * math.Sqrt(s2))
}
// CosineDist returns the cosine distance between two vectors, defined as
// 1 - CosineSimilarity. Identical vectors yield 0; it returns NaN when the
// dimensions differ.
func CosineDist(a, b Vector) float64 {
return 1.0 - CosineSimilarity(a, b)
}
// CosineDists returns the cosine distances between two sets of vectors.
func CosineDists(x, y Vectors) Vectors {
result := make(Vectors, len(x))
for i, a := range x {
result[i] = make([]float64, len(y))
for j, b := range y {
result[i][j] = CosineDist(a, b)
}
}
return result
}
// Centroid returns the element-wise mean (centroid) of the given vectors as a
// new, independent vector. Vectors whose length differs from the first vector
// are ignored, and the mean is taken over the vectors actually included. It
// returns nil when vs is empty or the first vector has no elements.
func Centroid(vs Vectors) Vector {
if len(vs) == 0 {
return nil
}
dim := len(vs[0])
if dim == 0 {
return nil
}
result := make(Vector, dim)
n := 0
for _, v := range vs {
if len(v) != dim {
continue
}
for j := range dim {
result[j] += v[j]
}
n++
}
inv := 1 / float64(n)
for j := range result {
result[j] *= inv
}
return result
}
// Cor returns the Pearson correlation between two vectors.
func Cor(a, b Vector) (float64, error) {
n := float64(len(a))
xy, err := Product(a, b)
if err != nil {
return NaN(), err
}
sx := a.Sd()
sy := b.Sd()
mx := a.Mean()
my := b.Mean()
r := (xy.Sum() - n*mx*my) / ((n - 1) * sx * sy)
return r, nil
}

View file

@ -1,146 +0,0 @@
package vector
import (
"math"
"testing"
"github.com/stretchr/testify/assert"
)
func TestVector_Sum(t *testing.T) {
assert.InDelta(t, 6.0, Vector{1, 2, 3}.Sum(), 0.00001)
assert.InDelta(t, 0.0, Vector{}.Sum(), 0.00001)
}
func TestVector_Dim(t *testing.T) {
assert.Equal(t, 3, Vector{1, 2, 3}.Dim())
assert.Equal(t, 0, Vector{}.Dim())
}
func TestVector_Copy(t *testing.T) {
a := Vector{1, 2, 3}
b := a.Copy()
b[0] = 99
assert.Equal(t, Vector{1, 2, 3}, a, "modifying the copy must not change the original")
assert.Equal(t, Vector{99, 2, 3}, b)
}
func TestNullVector(t *testing.T) {
v := NullVector(3)
assert.Equal(t, 3, v.Dim())
assert.Equal(t, Vector{0, 0, 0}, v)
}
func TestNewVector_Float64Copy(t *testing.T) {
// NewVector must return a vector independent of the source slice.
src := []float64{1, 2, 3}
v, err := NewVector(src)
assert.NoError(t, err)
src[0] = 99
assert.Equal(t, Vector{1, 2, 3}, v)
}
func TestVector_Variance(t *testing.T) {
t.Run("Values", func(t *testing.T) {
assert.InDelta(t, 32.0/7.0, Vector{2, 4, 4, 4, 5, 5, 7, 9}.Variance(), 0.00001)
})
t.Run("Single", func(t *testing.T) {
assert.InDelta(t, 0.0, Vector{5}.Variance(), 0.00001)
})
t.Run("Empty", func(t *testing.T) {
assert.InDelta(t, 0.0, Vector{}.Variance(), 0.00001)
})
}
func TestVector_Sd(t *testing.T) {
assert.InDelta(t, math.Sqrt(32.0/7.0), Vector{2, 4, 4, 4, 5, 5, 7, 9}.Sd(), 0.00001)
assert.InDelta(t, 0.0, Vector{5}.Sd(), 0.00001)
}
func TestVector_WeightedMean(t *testing.T) {
t.Run("Values", func(t *testing.T) {
r, err := Vector{1, 2, 4}.WeightedMean(Vector{1, 0, 1})
assert.NoError(t, err)
assert.InDelta(t, 2.5, r, 0.00001)
})
t.Run("LengthMismatch", func(t *testing.T) {
r, err := Vector{1, 2, 4}.WeightedMean(Vector{1, 1})
assert.Error(t, err)
assert.True(t, math.IsNaN(r))
})
}
func TestCor(t *testing.T) {
t.Run("PerfectPositive", func(t *testing.T) {
r, err := Cor(Vector{1, 2, 3}, Vector{2, 4, 6})
assert.NoError(t, err)
assert.InDelta(t, 1.0, r, 0.00001)
})
t.Run("LengthMismatch", func(t *testing.T) {
r, err := Cor(Vector{1, 2, 3}, Vector{1, 2})
assert.Error(t, err)
assert.True(t, math.IsNaN(r))
})
}
func TestNorm(t *testing.T) {
t.Run("Euclidean", func(t *testing.T) {
assert.InDelta(t, 5.0, Norm(Vector{3, -4}, 2.0), 0.00001)
})
t.Run("Manhattan", func(t *testing.T) {
// L1 norm uses absolute values, so negatives contribute their magnitude.
assert.InDelta(t, 5.0, Norm(Vector{1, -2, 2}, 1.0), 0.00001)
})
}
func TestProduct(t *testing.T) {
t.Run("Values", func(t *testing.T) {
p, err := Product(Vector{1, 2, 3}, Vector{4, 5, 6})
assert.NoError(t, err)
assert.Equal(t, Vector{4, 10, 18}, p)
})
t.Run("LengthMismatch", func(t *testing.T) {
p, err := Product(Vector{1, 2, 3}, Vector{4, 5})
assert.Error(t, err)
assert.Nil(t, p)
})
}
func TestDotProduct(t *testing.T) {
t.Run("Values", func(t *testing.T) {
r, err := DotProduct(Vector{1, 2, 3}, Vector{4, 5, 6})
assert.NoError(t, err)
assert.InDelta(t, 32.0, r, 0.00001)
})
t.Run("LengthMismatch", func(t *testing.T) {
r, err := DotProduct(Vector{1, 2, 3}, Vector{4, 5})
assert.Error(t, err)
assert.True(t, math.IsNaN(r))
})
}
func TestCosineSimilarity(t *testing.T) {
t.Run("Orthogonal", func(t *testing.T) {
assert.InDelta(t, 0.0, CosineSimilarity(Vector{1, 0}, Vector{0, 1}), 0.00001)
assert.InDelta(t, 1.0, CosineDist(Vector{1, 0}, Vector{0, 1}), 0.00001)
})
t.Run("Opposite", func(t *testing.T) {
assert.InDelta(t, -1.0, CosineSimilarity(Vector{1, 0}, Vector{-1, 0}), 0.00001)
assert.InDelta(t, 2.0, CosineDist(Vector{1, 0}, Vector{-1, 0}), 0.00001)
})
t.Run("DimensionMismatch", func(t *testing.T) {
assert.True(t, math.IsNaN(CosineSimilarity(Vector{1, 0}, Vector{1, 0, 0})))
assert.True(t, math.IsNaN(CosineDist(Vector{1, 0}, Vector{1, 0, 0})))
})
}
func TestCosineDists(t *testing.T) {
x := Vectors{{1, 0}, {0, 1}}
y := Vectors{{1, 0}, {-1, 0}}
got := CosineDists(x, y)
assert.Len(t, got, 2)
assert.InDelta(t, 0.0, got[0][0], 0.00001) // identical
assert.InDelta(t, 2.0, got[0][1], 0.00001) // opposite
assert.InDelta(t, 1.0, got[1][0], 0.00001) // orthogonal
assert.InDelta(t, 1.0, got[1][1], 0.00001) // orthogonal
}

File diff suppressed because one or more lines are too long

View file

@ -72,6 +72,29 @@ func NullVector(dim int) Vector {
return make(Vector, dim)
}
// Copy returns a copy of the vector.
func (v Vector) Copy() Vector {
y := make(Vector, len(v))
copy(y, v)
return y
}
// Dim returns the number of values (dimension).
func (v Vector) Dim() int {
return len(v)
}
// Sum returns the sum of the vector values.
func (v Vector) Sum() float64 {
s := 0.0
for _, f := range v {
s += f
}
return s
}
// uint8ToVector creates a new vector from a uint8 slice.
func uint8ToVector(values []uint8) Vector {
v := make(Vector, len(values))

View file

@ -22,9 +22,88 @@ func TestNewVector(t *testing.T) {
assert.IsType(t, Vector{}, v)
assert.NoError(t, err)
})
t.Run("Uint8", func(t *testing.T) {
v, err := NewVector([]uint8{1, 2, 3})
assert.NoError(t, err)
assert.Equal(t, Vector{1, 2, 3}, v)
})
t.Run("Uint16", func(t *testing.T) {
v, err := NewVector([]uint16{1, 2, 3})
assert.NoError(t, err)
assert.Equal(t, Vector{1, 2, 3}, v)
})
t.Run("Uint32", func(t *testing.T) {
v, err := NewVector([]uint32{1, 2, 3})
assert.NoError(t, err)
assert.Equal(t, Vector{1, 2, 3}, v)
})
t.Run("Uint64", func(t *testing.T) {
v, err := NewVector([]uint64{1, 2, 3})
assert.NoError(t, err)
assert.Equal(t, Vector{1, 2, 3}, v)
})
t.Run("Int8", func(t *testing.T) {
v, err := NewVector([]int8{-1, 2, 3})
assert.NoError(t, err)
assert.Equal(t, Vector{-1, 2, 3}, v)
})
t.Run("Int16", func(t *testing.T) {
v, err := NewVector([]int16{-1, 2, 3})
assert.NoError(t, err)
assert.Equal(t, Vector{-1, 2, 3}, v)
})
t.Run("Int32", func(t *testing.T) {
v, err := NewVector([]int32{-1, 2, 3})
assert.NoError(t, err)
assert.Equal(t, Vector{-1, 2, 3}, v)
})
t.Run("Int64", func(t *testing.T) {
v, err := NewVector([]int64{-1, 2, 3})
assert.NoError(t, err)
assert.Equal(t, Vector{-1, 2, 3}, v)
})
t.Run("Vector", func(t *testing.T) {
src := Vector{1, 2, 3}
v, err := NewVector(src)
assert.NoError(t, err)
assert.Equal(t, src, v)
})
t.Run("String", func(t *testing.T) {
v, err := NewVector([]string{"a", "b", "c"})
assert.IsType(t, Vector{}, v)
assert.Error(t, err)
})
}
func TestNewVector_Float64Copy(t *testing.T) {
// NewVector must return a vector independent of the source slice.
src := []float64{1, 2, 3}
v, err := NewVector(src)
assert.NoError(t, err)
src[0] = 99
assert.Equal(t, Vector{1, 2, 3}, v)
}
func TestNullVector(t *testing.T) {
v := NullVector(3)
assert.Equal(t, 3, v.Dim())
assert.Equal(t, Vector{0, 0, 0}, v)
}
func TestVector_Copy(t *testing.T) {
a := Vector{1, 2, 3}
b := a.Copy()
b[0] = 99
assert.Equal(t, Vector{1, 2, 3}, a, "modifying the copy must not change the original")
assert.Equal(t, Vector{99, 2, 3}, b)
}
func TestVector_Dim(t *testing.T) {
assert.Equal(t, 3, Vector{1, 2, 3}.Dim())
assert.Equal(t, 0, Vector{}.Dim())
}
func TestVector_Sum(t *testing.T) {
assert.InDelta(t, 6.0, Vector{1, 2, 3}.Sum(), 0.00001)
assert.InDelta(t, 0.0, Vector{}.Sum(), 0.00001)
}