├── .gitignore ├── .travis.yml ├── Gopkg.lock ├── Gopkg.toml ├── LICENSE ├── README.md └── vectormodel ├── vector_model.go └── vector_model_test.go /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 2 | *.o 3 | *.a 4 | *.so 5 | 6 | # Folders 7 | _obj 8 | _test 9 | 10 | # Architecture specific extensions/prefixes 11 | *.[568vq] 12 | [568vq].out 13 | 14 | *.cgo1.go 15 | *.cgo2.c 16 | _cgo_defun.c 17 | _cgo_gotypes.go 18 | _cgo_export.* 19 | 20 | _testmain.go 21 | 22 | *.exe 23 | *.test 24 | *.prof 25 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | 3 | go: 4 | - 1.6 5 | - 1.7.x 6 | - 1.8.x 7 | - master 8 | -------------------------------------------------------------------------------- /Gopkg.lock: -------------------------------------------------------------------------------- 1 | # This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'. 2 | 3 | 4 | [[projects]] 5 | branch = "master" 6 | name = "gonum.org/v1/gonum" 7 | packages = ["blas","blas/blas64","blas/gonum","floats","internal/asm/c128","internal/asm/f32","internal/asm/f64","internal/math32","lapack","lapack/gonum","lapack/lapack64","mat"] 8 | revision = "a1f42c86acbcd78d782c5ee30ae5656c26ae728e" 9 | 10 | [solve-meta] 11 | analyzer-name = "dep" 12 | analyzer-version = 1 13 | inputs-digest = "8730063de4d3c6f8aea16c6942d2dc5ec1fd94534338afe9865bdc954b0a3041" 14 | solver-name = "gps-cdcl" 15 | solver-version = 1 16 | -------------------------------------------------------------------------------- /Gopkg.toml: -------------------------------------------------------------------------------- 1 | 2 | # Gopkg.toml example 3 | # 4 | # Refer to https://github.com/golang/dep/blob/master/docs/Gopkg.toml.md 5 | # for detailed Gopkg.toml documentation. 6 | # 7 | # required = ["github.com/user/thing/cmd/thing"] 8 | # ignored = ["github.com/user/project/pkgX", "bitbucket.org/user/project/pkgA/pkgY"] 9 | # 10 | # [[constraint]] 11 | # name = "github.com/user/project" 12 | # version = "1.0.0" 13 | # 14 | # [[constraint]] 15 | # name = "github.com/user/project2" 16 | # branch = "dev" 17 | # source = "github.com/myfork/project2" 18 | # 19 | # [[override]] 20 | # name = "github.com/x/y" 21 | # version = "2.4.0" 22 | 23 | 24 | [[constraint]] 25 | branch = "master" 26 | name = "gonum.org/v1/gonum" 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Juarez Bochi 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # facts 2 | 3 | [![Build Status](https://travis-ci.org/jbochi/facts.svg?branch=master)](https://travis-ci.org/jbochi/facts) 4 | 5 | Matrix Factorization based recommender system in Go. Because **facts** are more important than ever. 6 | 7 | This project provides a `vectormodel` package that can be used to serve real time recommendations. First of all, you will need to train a model to get document embeddings or latent **fact**ors. I highly recommend the [implicit](https://github.com/benfred/implicit) library for that. Once you have the documents as a map of `int` ids to arrays of `float64`, you can create the vector model by calling: 8 | 9 | `model, err := NewVectorModel(documents map[int][]float64, confidence, regularization float64)` 10 | 11 | And to generate recommendations call `.Recommend` with a set of items the user has seen: 12 | 13 | `recs := model.Recommend(seenDocs *map[int]bool, n int)` 14 | 15 | Note that user vectors are not required. Matter of fact, you can use this to recommend documents to users that were *not* in the training set. The recommendations will be computed very efficiently (probably <1ms, depends on your model size) in real time. 16 | 17 | Check out the [demo](https://github-recs.appspot.com/) for a complete example that recommends GitHub repositories. 18 | 19 | Demo source code is available here: https://github.com/jbochi/github-recs 20 | -------------------------------------------------------------------------------- /vectormodel/vector_model.go: -------------------------------------------------------------------------------- 1 | // Package vectormodel provides primitives for serving Matrix Factorization 2 | // based Recommender System models. 3 | // We assume that the document vectors were calculated according to the paper 4 | // "Collaborative Filtering for Implicit Feedback Datasets". 5 | // Given the document vectors and the list of documents consumed by a user, 6 | // this package can calculate the user vectors in realtime to generate 7 | // recommendations. 8 | package vectormodel 9 | 10 | import ( 11 | "errors" 12 | "fmt" 13 | "sort" 14 | 15 | "gonum.org/v1/gonum/mat" 16 | ) 17 | 18 | type ( 19 | // VectorModel is a struct to handle document vector space models. 20 | VectorModel struct { 21 | confidence float64 22 | regularization float64 23 | docIDs []int 24 | docIndexes map[int]int 25 | nFactors int 26 | itemFactorsY *mat.Dense 27 | squaredItemFactorsYtY *mat.Dense 28 | } 29 | 30 | // DocumentScore is the result of a recommendation 31 | DocumentScore struct { 32 | DocumentID int 33 | Score float64 34 | } 35 | ) 36 | 37 | func (a byDocScoreDesc) Len() int { return len(a) } 38 | func (a byDocScoreDesc) Swap(i, j int) { a[i], a[j] = a[j], a[i] } 39 | func (a byDocScoreDesc) Less(i, j int) bool { return a[i].Score > a[j].Score } 40 | 41 | type byDocScoreDesc []DocumentScore 42 | 43 | // NewVectorModel creates a new VectorModel 44 | func NewVectorModel(documents map[int][]float64, confidence, regularization float64) (*VectorModel, error) { 45 | var vm VectorModel 46 | vm.confidence = confidence 47 | vm.regularization = regularization 48 | vm.docIDs = make([]int, len(documents)) 49 | vm.docIndexes = make(map[int]int) 50 | 51 | data := make([]float64, 0) 52 | i := 0 53 | for doc, vector := range documents { 54 | if i == 0 { 55 | vm.nFactors = len(vector) 56 | } else if len(vector) != vm.nFactors { 57 | return nil, errors.New("Invalid vector size") 58 | } 59 | vm.docIndexes[doc] = i 60 | vm.docIDs[i] = doc 61 | data = append(data, vector...) 62 | i++ 63 | } 64 | vm.itemFactorsY = mat.NewDense(len(documents), vm.nFactors, data) 65 | 66 | var YtY mat.Dense 67 | YtY.Mul(vm.itemFactorsY.T(), vm.itemFactorsY) 68 | vm.squaredItemFactorsYtY = &YtY 69 | 70 | return &vm, nil 71 | } 72 | 73 | // Rank sorts a list of candidate assets for a given user history 74 | func (vm *VectorModel) Rank(candidates []int, seenDocs map[int]bool) (scores []float64, err error) { 75 | candidateScores, err := vm.scoreCandidates(candidates, seenDocs) 76 | if err != nil { 77 | return nil, err 78 | } 79 | scores = make([]float64, len(candidateScores)) 80 | for i, candidateScore := range candidateScores { 81 | candidates[i] = candidateScore.DocumentID 82 | scores[i] = candidateScore.Score 83 | } 84 | return scores, nil 85 | } 86 | 87 | // Recommend returns a list of recommendedDocs and a list of scores 88 | func (vm *VectorModel) Recommend(seenDocs map[int]bool, n int) (recommendations []DocumentScore, err error) { 89 | recommendations, err = vm.scoreCandidates(vm.docIDs, seenDocs) 90 | if err != nil { 91 | return nil, err 92 | } 93 | if len(recommendations) > n { 94 | recommendations = recommendations[:n] 95 | } 96 | return recommendations, nil 97 | } 98 | 99 | func (vm *VectorModel) scoreCandidates(candidates []int, seenDocs map[int]bool) (recommendations []DocumentScore, err error) { 100 | confidenceMap := vm.confidenceMap(seenDocs) 101 | if len(confidenceMap) == 0 { 102 | return nil, fmt.Errorf("No seen doc is in model. History: %d Model: %d", 103 | len(seenDocs), len(vm.docIndexes)) 104 | } 105 | userVec, err := vm.userVector(confidenceMap) 106 | if err != nil { 107 | return recommendations, err 108 | } 109 | scoresVec := vm.scoresForUserVec(&userVec) 110 | candidateScores := make([]DocumentScore, len(candidates)) 111 | for i, doc := range candidates { 112 | var score float64 113 | if _, docAlreadySeen := seenDocs[doc]; docAlreadySeen { 114 | score = -1 115 | } else if docIndex, docInModel := vm.docIndexes[doc]; !docInModel { 116 | score = 0 117 | } else { 118 | score = scoresVec.At(docIndex, 0) 119 | } 120 | candidateScores[i] = DocumentScore{doc, score} 121 | } 122 | sort.Sort(byDocScoreDesc(candidateScores)) 123 | return candidateScores, nil 124 | } 125 | 126 | func (vm *VectorModel) confidenceMap(seenDocs map[int]bool) map[int]float64 { 127 | confidenceMap := make(map[int]float64) 128 | for doc := range seenDocs { 129 | if _, inModel := vm.docIndexes[doc]; inModel { 130 | confidenceMap[doc] = vm.confidence 131 | } 132 | } 133 | return confidenceMap 134 | } 135 | 136 | // userVector returns the user vector for a given set of consumed documents 137 | func (vm *VectorModel) userVector(confidenceMap map[int]float64) (mat.VecDense, error) { 138 | // We follow the notation from the paper "Collaborative Filtering for Implicit Feedback Datasets" 139 | // Please see github.com/benfred/implicit as a reference implementation 140 | 141 | // We solve the following linear equation: 142 | // Xu = (YtCuY + regularization*I)i^-1 * YtYCuPu 143 | 144 | // A = YtCuY + reg * I = YtY + reg * I + Yt(Cu - I)Y 145 | // We initialize A to YtY + reg * I and sum the last term for each doc 146 | var A mat.Dense 147 | A.Add(vm.squaredItemFactorsYtY, eye(vm.nFactors, vm.regularization)) 148 | 149 | // b = YtCuPu 150 | b := mat.NewVecDense(vm.nFactors, make([]float64, vm.nFactors)) 151 | 152 | for doc, confidence := range confidenceMap { 153 | index, docFound := vm.docIndexes[doc] 154 | if !docFound { 155 | continue 156 | } 157 | factor := vm.itemFactorsY.RowView(index) 158 | 159 | // A += (confidence - 1) * np.outer(factor, factor) 160 | var factor2 mat.Dense 161 | factor2.Mul(factor, factor.T()) 162 | factor2.Scale(confidence-1, &factor2) 163 | A.Add(&A, &factor2) 164 | 165 | // b += confidence * factor 166 | b.AddScaledVec(b, confidence, factor) 167 | } 168 | 169 | var x mat.VecDense 170 | // We could just solve the matrix by calling the next line, but 171 | // A is positively defined, so we can use the Cholesky solver 172 | // err := x.SolveVec(&A, b) 173 | 174 | var ch mat.Cholesky 175 | if ok := ch.Factorize(&unsafeSymmetric{A, vm.nFactors}); !ok { 176 | return x, errors.New("Failed to run Cholesky factorization") 177 | } 178 | err := ch.SolveVec(&x, b) 179 | return x, err 180 | } 181 | 182 | // scoresForUserVec returns a vector with scores given set of consumed documents 183 | func (vm *VectorModel) scoresForUserVec(userVec *mat.VecDense) mat.VecDense { 184 | var y mat.VecDense 185 | y.MulVec(vm.itemFactorsY, userVec) 186 | return y 187 | } 188 | 189 | // eye returns an identity matrix with size n and `value` in the diagonal 190 | func eye(n int, value float64) mat.Matrix { 191 | m := mat.NewDense(n, n, make([]float64, n*n)) 192 | for i := 0; i < n; i++ { 193 | m.Set(i, i, value) 194 | } 195 | return m 196 | } 197 | 198 | type unsafeSymmetric struct { 199 | mat.Dense 200 | n int 201 | } 202 | 203 | func (s *unsafeSymmetric) Symmetric() int { 204 | return s.n 205 | } 206 | -------------------------------------------------------------------------------- /vectormodel/vector_model_test.go: -------------------------------------------------------------------------------- 1 | package vectormodel 2 | 3 | import ( 4 | "math" 5 | "testing" 6 | 7 | "gonum.org/v1/gonum/mat" 8 | ) 9 | 10 | func TestVectorModelConstructor(t *testing.T) { 11 | confidence := 1.0 12 | regularization := 0.01 13 | docs := make(map[int][]float64) 14 | docs[1234] = []float64{1, 2, 3} 15 | vm, err := NewVectorModel(docs, confidence, regularization) 16 | 17 | if err != nil { 18 | t.Fatalf("Failed to create vector model %s", err) 19 | } 20 | 21 | if vm.nFactors != 3 { 22 | t.Errorf("Expecting 3 factors, got %d", vm.nFactors) 23 | } 24 | if vm.confidence != confidence { 25 | t.Errorf("Wrong confidence: %f", vm.confidence) 26 | } 27 | if vm.regularization != regularization { 28 | t.Errorf("Wrong regularization: %f", vm.regularization) 29 | } 30 | 31 | Y := vm.itemFactorsY 32 | YtY := vm.squaredItemFactorsYtY 33 | Yrows, Ycols := Y.Dims() 34 | if Yrows != 1 || Ycols != 3 { 35 | t.Errorf("Y has wrong dimensions (%d, %d) instead of (3, 1)", Yrows, Ycols) 36 | } 37 | 38 | a, b, c := Y.At(0, 0), Y.At(0, 1), Y.At(0, 2) 39 | if a != 1 || b != 2 || c != 3 { 40 | t.Errorf("Y has the wrong values %f, %f, %f", a, b, c) 41 | } 42 | 43 | YtYrows, YtYcols := YtY.Dims() 44 | if YtYrows != 3 || YtYcols != 3 { 45 | t.Errorf("YtY has wrong dimensions (%d, %d) instead of (3, 3)", 46 | YtYrows, YtYcols) 47 | } 48 | 49 | a, b, c = YtY.At(0, 0), YtY.At(0, 1), YtY.At(0, 2) 50 | if a != 1 || b != 2 || c != 3 { 51 | t.Errorf("first row of YtY has the wrong values: %f, %f, %f", a, b, c) 52 | } 53 | a, b, c = YtY.At(1, 0), YtY.At(1, 1), YtY.At(1, 2) 54 | if a != 2 || b != 4 || c != 6 { 55 | t.Errorf("second row of YtY has the wrong values: %f, %f, %f", a, b, c) 56 | } 57 | a, b, c = YtY.At(2, 0), YtY.At(2, 1), YtY.At(2, 2) 58 | if a != 3 || b != 6 || c != 9 { 59 | t.Errorf("third row of YtY has the wrong values: %f, %f, %f", a, b, c) 60 | } 61 | 62 | } 63 | 64 | func TestVectorModelConstructorWithInvalidVectors(t *testing.T) { 65 | docs := make(map[int][]float64) 66 | docs[1234] = []float64{1, 2, 3} 67 | docs[1235] = []float64{1, 2, 3, 5} 68 | _, err := NewVectorModel(docs, 1.0, 0.01) 69 | 70 | if err == nil { 71 | t.Fatalf("Should not allow vectors with different sizes") 72 | } 73 | } 74 | 75 | func TestUserVector(t *testing.T) { 76 | defaultConfidence := 40.0 77 | regularization := 0.01 78 | docs := make(map[int][]float64) 79 | docs[1234] = []float64{1, 2, 3} 80 | vm, err := NewVectorModel(docs, defaultConfidence, regularization) 81 | if err != nil { 82 | t.Fatalf("Failed to create vector model %s", err) 83 | } 84 | 85 | confidence := map[int]float64{1234: 40.0, 666: 1.0} 86 | 87 | user, err := vm.userVector(confidence) 88 | 89 | if err != nil { 90 | t.Fatalf("Error solving user vector: %s", err) 91 | } 92 | 93 | rows, cols := user.Dims() 94 | if rows != 3 || cols != 1 { 95 | t.Fatalf("Invalid user vec dimensions: %d, %d", rows, cols) 96 | } 97 | 98 | a, b, c := user.At(0, 0), user.At(1, 0), user.At(2, 0) 99 | if math.Abs(a-0.0714273)+math.Abs(b-0.14285459)+math.Abs(c-0.21428189) > 1e-4 { 100 | t.Fatalf("Invalid user vec: [%f, %f, %f]", a, b, c) 101 | } 102 | } 103 | 104 | func BenchmarkUserVector(b *testing.B) { 105 | var err error 106 | 107 | defaultConfidence := 40.0 108 | regularization := 0.01 109 | docs := make(map[int][]float64) 110 | docs[1234] = []float64{1, 2, 3} 111 | vm, err := NewVectorModel(docs, defaultConfidence, regularization) 112 | if err != nil { 113 | b.Fatalf("Failed to create vector model %s", err) 114 | } 115 | 116 | confidence := map[int]float64{1234: 40.0, 666: 1.0} 117 | 118 | var user mat.VecDense 119 | 120 | // Reset benchmark timer 121 | b.ResetTimer() 122 | 123 | // Run benchmark 124 | for i := 0; i < b.N; i++ { 125 | user, err = vm.userVector(confidence) 126 | } 127 | if err != nil { 128 | b.Fatalf("Error solving user vector: %s", err) 129 | } 130 | 131 | rows, cols := user.Dims() 132 | if rows != 3 || cols != 1 { 133 | b.Fatalf("Invalid user vec dimensions: %d, %d", rows, cols) 134 | } 135 | 136 | x, y, z := user.At(0, 0), user.At(1, 0), user.At(2, 0) 137 | if math.Abs(x-0.0714273)+math.Abs(y-0.14285459)+math.Abs(z-0.21428189) > 1e-4 { 138 | b.Fatalf("Invalid user vec: [%f, %f, %f]", x, y, z) 139 | } 140 | } 141 | 142 | func TestScoresForUserVec(t *testing.T) { 143 | regularization := 0.01 144 | confidence := 40.0 145 | docs := make(map[int][]float64) 146 | docs[1234] = []float64{1, 2, 3} 147 | docs[4567] = []float64{3, 2, 1} 148 | vm, err := NewVectorModel(docs, confidence, regularization) 149 | if err != nil { 150 | t.Fatalf("Failed to create vector model %s", err) 151 | } 152 | 153 | userVec := mat.NewVecDense(3, []float64{0.2, 0.1, 0.0}) 154 | scores := vm.scoresForUserVec(userVec) 155 | 156 | rows, cols := scores.Dims() 157 | if rows != 2 || cols != 1 { 158 | t.Fatalf("Invalid scores dimensions: %d, %d", rows, cols) 159 | } 160 | 161 | score1 := scores.At(vm.docIndexes[1234], 0) 162 | score2 := scores.At(vm.docIndexes[4567], 0) 163 | if score1 != (0.2*1+0.1*2) || score2 != (3*0.2+2*0.1) { 164 | t.Fatalf("Invalid scores: %f (%d), %f (%d)", score1, vm.docIndexes[1234], score2, vm.docIndexes[4567]) 165 | } 166 | } 167 | 168 | func TestRecommend(t *testing.T) { 169 | confidence := 40.0 170 | regularization := 0.01 171 | docs := make(map[int][]float64) 172 | docs[1234] = []float64{1, 2, 3} 173 | docs[4567] = []float64{3, 2, 1} 174 | vm, err := NewVectorModel(docs, confidence, regularization) 175 | if err != nil { 176 | t.Fatalf("Failed to create vector model %s", err) 177 | } 178 | 179 | seenDocs := map[int]bool{1234: true} 180 | n := 10 181 | 182 | recommendations, err := vm.Recommend(seenDocs, n) 183 | if err != nil { 184 | t.Fatalf("Failed to recommend %s", err) 185 | } 186 | if len(recommendations) != 2 { 187 | t.Fatalf("Wrong number of recommendations: %v", recommendations) 188 | } 189 | if recommendations[0].DocumentID != 4567 { 190 | t.Errorf("Wrong recommendation: %v", recommendations[0]) 191 | } 192 | if recommendations[1].DocumentID != 1234 { 193 | t.Errorf("Wrong recommendation: %v", recommendations[1]) 194 | } 195 | 196 | // This is how you can obtain the excpected scores in python: 197 | // 198 | // import numpy as np 199 | // Y = np.array([[1, 2, 3], [3, 2, 1]]) 200 | // YtY = Y.T.dot(Y) 201 | // YtY 202 | // regularization = 0.01 203 | // confidence = 40 204 | // A = YtY + regularization * np.eye(3) 205 | // b = np.zeros(3) 206 | // factor = Y[0] 207 | // A += (confidence - 1) * np.outer(factor, factor) 208 | // b += confidence * factor 209 | // user = np.linalg.solve(A, b) 210 | // np.dot(Y, user) 211 | 212 | if math.Abs(recommendations[0].Score-0.00104011) > 1e-5 { 213 | t.Errorf("Wrong score: %f", recommendations[0].Score) 214 | } 215 | } 216 | 217 | func TestRecommendReturnsTopItems(t *testing.T) { 218 | confidence := 40.0 219 | regularization := 0.01 220 | docs := make(map[int][]float64) 221 | docs[0] = []float64{1, 2, 3} 222 | docs[1] = []float64{1, 2, 3.01} 223 | docs[2] = []float64{1, 2, 3.02} 224 | docs[3] = []float64{3, 2, 1} 225 | docs[4] = []float64{1, 2, 3.03} 226 | vm, err := NewVectorModel(docs, confidence, regularization) 227 | if err != nil { 228 | t.Fatalf("Failed to create vector model %s", err) 229 | } 230 | 231 | seenDocs := map[int]bool{0: true} 232 | n := 3 233 | recs, err := vm.Recommend(seenDocs, n) 234 | if err != nil { 235 | t.Fatalf("Failed to recommend %s", err) 236 | } 237 | if len(recs) != 3 { 238 | t.Fatalf("Wrong number of recommendations: %v", recs) 239 | } 240 | if recs[0].DocumentID != 1 || recs[1].DocumentID != 2 || recs[2].DocumentID != 4 { 241 | t.Errorf("Wrong recommendations: %v", recs) 242 | } 243 | } 244 | 245 | func TestRankSortsTopItems(t *testing.T) { 246 | confidence := 40.0 247 | regularization := 0.01 248 | docs := make(map[int][]float64) 249 | docs[0] = []float64{1, 2, 3} 250 | docs[1] = []float64{1, 2, 3} 251 | docs[2] = []float64{1, 2, 3} 252 | docs[3] = []float64{3, 2, 1} 253 | docs[4] = []float64{1, 2, 3} 254 | vm, err := NewVectorModel(docs, confidence, regularization) 255 | if err != nil { 256 | t.Fatalf("Failed to create vector model %s", err) 257 | } 258 | 259 | seenDocs := map[int]bool{0: true} 260 | items := []int{0, 1, 3, 10} 261 | scores, err := vm.Rank(items, seenDocs) 262 | if err != nil { 263 | t.Fatalf("Failed to recommend %s", err) 264 | } 265 | if notAlmostEqual(scores[0], 0.9302) || 266 | notAlmostEqual(scores[1], 0.001) || 267 | notAlmostEqual(scores[2], 0) || 268 | notAlmostEqual(scores[3], -1) { 269 | 270 | t.Errorf("Wrong scores: %v", scores) 271 | } 272 | // Order is: Most similar, Not read, Unknown, read 273 | if items[0] != 1 || items[1] != 3 || items[2] != 10 || items[3] != 0 { 274 | t.Errorf("Wrong recommendations: %v", items) 275 | } 276 | } 277 | 278 | func notAlmostEqual(a, b float64) bool { 279 | return math.Abs(a-b) > 1e-4 280 | } 281 | --------------------------------------------------------------------------------