├── .gitignore ├── LICENSE ├── README.md ├── main.go └── redrec ├── engine.go └── engine_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 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016, Redis Labs 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | * Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 15 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 16 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 17 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 18 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 19 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 20 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 21 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 22 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 23 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # redis-recommend 2 | ## A Simple Redis recommendation engine written in [Go](https://golang.org/). 3 | 4 | ### 5 | 6 | ## About 7 | This is a simple recommendation engine written in [Go](https://golang.org/) using [Redis](http://redis.io). The Redis client Go library used is [Redigo](https://github.com/garyburd/redigo). 8 | 9 | ## Usage 10 | 11 | Rate an item: 12 | 13 | ``` 14 | redis-recommend rate 15 | ``` 16 | 17 | Find (n) similar users for all users: 18 | 19 | ``` 20 | redis-recommend batch-update [--results=] 21 | ``` 22 | 23 | Get (n) suggested items for a user: 24 | ``` 25 | redis-recommend suggest [--results=] 26 | ``` 27 | 28 | Get the probable score a user would give to an item: 29 | ``` 30 | redis-recommend get-probability 31 | ``` 32 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strconv" 7 | 8 | "github.com/RedisLabs/redis-recommend/redrec" 9 | "github.com/docopt/docopt-go" 10 | ) 11 | 12 | var rr *redrec.Redrec 13 | var err error 14 | 15 | func main() { 16 | usage := ` 17 | 18 | Usage: 19 | redis-recommend rate 20 | redis-recommend suggest [--results=] 21 | redis-recommend get-probability 22 | redis-recommend batch-update [--results=] 23 | redis-recommend -h | --help 24 | redis-recommend --version 25 | 26 | Options: 27 | -h --help Show this screen. 28 | --version Show version. 29 | --results= Num of suggestions to get [default: 100]` 30 | 31 | arguments, _ := docopt.Parse(usage, nil, true, "redis-recommend", false) 32 | 33 | rr, err = redrec.New("redis://localhost:6379") 34 | chekErrorAndExit(err) 35 | 36 | if arguments["rate"].(bool) { 37 | user := arguments[""].(string) 38 | item := arguments[""].(string) 39 | score, err := strconv.ParseFloat(arguments[""].(string), 64) 40 | chekErrorAndExit(err) 41 | rate(user, item, score) 42 | } 43 | 44 | if arguments["get-probability"].(bool) { 45 | user := arguments[""].(string) 46 | item := arguments[""].(string) 47 | getProbability(user, item) 48 | } 49 | 50 | if arguments["suggest"].(bool) { 51 | user := arguments[""].(string) 52 | results, err := strconv.Atoi(arguments["--results"].(string)) 53 | chekErrorAndExit(err) 54 | suggest(user, results) 55 | } 56 | 57 | if arguments["batch-update"].(bool) { 58 | results, err := strconv.Atoi(arguments["--results"].(string)) 59 | chekErrorAndExit(err) 60 | update(results) 61 | } 62 | } 63 | 64 | func chekErrorAndExit(err error) { 65 | if err != nil { 66 | fmt.Fprintln(os.Stderr, err.Error()) 67 | rr.CloseConn() 68 | os.Exit(1) 69 | } 70 | } 71 | 72 | func rate(user string, item string, score float64) { 73 | fmt.Printf("User %s ranked item %s with %.2f\n", user, item, score) 74 | err := rr.Rate(item, user, score) 75 | chekErrorAndExit(err) 76 | } 77 | 78 | func getProbability(user string, item string) { 79 | score, err := rr.CalcItemProbability(item, user) 80 | chekErrorAndExit(err) 81 | fmt.Printf("%s %s %.2f\n", user, item, score) 82 | } 83 | 84 | func suggest(user string, max int) { 85 | fmt.Printf("Getting %d results for user %s\n", max, user) 86 | rr.UpdateSuggestedItems(user, max) 87 | s, err := rr.GetUserSuggestions(user, max) 88 | chekErrorAndExit(err) 89 | fmt.Println("results:") 90 | fmt.Println(s) 91 | } 92 | 93 | func update(max int) { 94 | fmt.Printf("Updating DB\n") 95 | err := rr.BatchUpdateSimilarUsers(max) 96 | chekErrorAndExit(err) 97 | } 98 | -------------------------------------------------------------------------------- /redrec/engine.go: -------------------------------------------------------------------------------- 1 | package redrec 2 | 3 | import ( 4 | "fmt" 5 | "math" 6 | "strconv" 7 | 8 | "github.com/garyburd/redigo/redis" 9 | ) 10 | 11 | // Redrec struct 12 | type Redrec struct { 13 | rconn redis.Conn 14 | } 15 | 16 | // New returns a new Redrec 17 | func New(url string) (*Redrec, error) { 18 | rconn, err := redis.DialURL(url) 19 | if err != nil { 20 | fmt.Println(err.Error()) 21 | return nil, err 22 | } 23 | 24 | rr := &Redrec{ 25 | rconn: rconn, 26 | } 27 | 28 | return rr, nil 29 | } 30 | 31 | // CloseConn closes the Redis connection 32 | func (rr *Redrec) CloseConn() { 33 | rr.rconn.Close() 34 | } 35 | 36 | // Rate adds user->score to a given item 37 | func (rr *Redrec) Rate(item string, user string, score float64) error { 38 | _, err := rr.rconn.Do("ZADD", fmt.Sprintf("user:%s:items", user), score, item) 39 | if err != nil { 40 | return err 41 | } 42 | 43 | _, err = rr.rconn.Do("ZADD", fmt.Sprintf("item:%s:scores", item), score, user) 44 | if err != nil { 45 | return err 46 | } 47 | 48 | _, err = rr.rconn.Do("SADD", "users", user) 49 | if err != nil { 50 | return err 51 | } 52 | 53 | return nil 54 | } 55 | 56 | // GetUserSuggestions return the existing user 57 | //suggestions range for a given user as a []string 58 | func (rr *Redrec) GetUserSuggestions(user string, max int) ([]string, error) { 59 | items, err := redis.Strings(rr.rconn.Do("ZREVRANGE", fmt.Sprintf("user:%s:suggestions", user), 0, max, "WITHSCORES")) 60 | if err != nil { 61 | return nil, err 62 | } 63 | 64 | return items, nil 65 | } 66 | 67 | // BatchUpdateSimilarUsers runs on all the users, 68 | // getting the similarity candidates for each user and storing the similar 69 | // users and scores in a sorted set 70 | func (rr *Redrec) BatchUpdateSimilarUsers(max int) error { 71 | users, err := redis.Strings(rr.rconn.Do("SMEMBERS", "users")) 72 | if err != nil { 73 | return err 74 | } 75 | for _, user := range users { 76 | candidates, err := rr.getSimilarityCandidates(user, max) 77 | args := []interface{}{} 78 | args = append(args, fmt.Sprintf("user:%s:similars", user)) 79 | for _, candidate := range candidates { 80 | if candidate != user { 81 | score, _ := rr.calcSimilarity(user, candidate) 82 | args = append(args, score) 83 | args = append(args, candidate) 84 | } 85 | } 86 | 87 | _, err = rr.rconn.Do("ZADD", args...) 88 | if err != nil { 89 | fmt.Println("ZADD ERR: ", err) 90 | return err 91 | } 92 | } 93 | 94 | return nil 95 | } 96 | 97 | // UpdateSuggestedItems gets the candidate suggest items for a given user and stores 98 | // the calculated probability for each item in a sorted set 99 | func (rr *Redrec) UpdateSuggestedItems(user string, max int) error { 100 | items, err := rr.getSuggestCandidates(user, max) 101 | if max > len(items) { 102 | max = len(items) 103 | } 104 | 105 | args := []interface{}{} 106 | args = append(args, fmt.Sprintf("user:%s:suggestions", user)) 107 | for _, item := range items { 108 | probability, _ := rr.CalcItemProbability(user, item) 109 | args = append(args, probability) 110 | args = append(args, item) 111 | } 112 | 113 | _, err = rr.rconn.Do("ZADD", args...) 114 | if err != nil { 115 | fmt.Println("ZADD ERR: ", err) 116 | return err 117 | } 118 | 119 | return nil 120 | } 121 | 122 | // CalcItemProbability takes all the user`s similars that rated the input item 123 | // and calculates the average score. 124 | func (rr *Redrec) CalcItemProbability(user string, item string) (float64, error) { 125 | _, err := rr.rconn.Do("ZINTERSTORE", 126 | "ztmp", 2, fmt.Sprintf("user:%s:similars", user), fmt.Sprintf("item:%s:scores", item), "WEIGHTS", 0, 1) 127 | if err != nil { 128 | return 0, err 129 | } 130 | 131 | scores, err := redis.Strings(rr.rconn.Do("ZRANGE", "ztmp", 0, -1, "WITHSCORES")) 132 | rr.rconn.Do("DEL", "ztmp") 133 | if err != nil { 134 | return 0, err 135 | } 136 | 137 | if len(scores) == 0 { 138 | return 0, nil 139 | } 140 | 141 | var score float64 142 | for i := 1; i < len(scores); i += 2 { 143 | val, _ := strconv.ParseFloat(scores[i], 64) 144 | score += val 145 | } 146 | score /= float64(len(scores) / 2) 147 | 148 | return score, nil 149 | } 150 | 151 | func (rr *Redrec) getUserItems(user string, max int) ([]string, error) { 152 | items, err := redis.Strings(rr.rconn.Do("ZREVRANGE", fmt.Sprintf("user:%s:items", user), 0, max)) 153 | if err != nil { 154 | return nil, err 155 | } 156 | 157 | return items, nil 158 | } 159 | 160 | func (rr *Redrec) getItemScores(item string, max int) (map[string]string, error) { 161 | scores, err := redis.StringMap(rr.rconn.Do("ZREVRANGE", fmt.Sprintf("item:%s:scores", item), 0, max)) 162 | if err != nil { 163 | return nil, err 164 | } 165 | 166 | return scores, nil 167 | } 168 | 169 | func (rr *Redrec) getSimilarityCandidates(user string, max int) ([]string, error) { 170 | items, err := rr.getUserItems(user, max) 171 | if max > len(items) { 172 | max = len(items) 173 | } 174 | 175 | args := []interface{}{} 176 | args = append(args, "ztmp", float64(max)) 177 | for i := 0; i < max; i++ { 178 | args = append(args, fmt.Sprintf("item:%s:scores", items[i])) 179 | } 180 | 181 | _, err = rr.rconn.Do("ZUNIONSTORE", args...) 182 | if err != nil { 183 | return nil, err 184 | } 185 | 186 | users, err := redis.Strings(rr.rconn.Do("ZRANGE", "ztmp", 0, -1)) 187 | if err != nil { 188 | return nil, err 189 | } 190 | 191 | _, err = rr.rconn.Do("DEL", "ztmp") 192 | if err != nil { 193 | return nil, err 194 | } 195 | 196 | return users, nil 197 | } 198 | 199 | func (rr *Redrec) getSuggestCandidates(user string, max int) ([]string, error) { 200 | similarUsers, err := redis.Strings(rr.rconn.Do("ZRANGE", fmt.Sprintf("user:%s:similars", user), 0, max)) 201 | if err != nil { 202 | return nil, err 203 | } 204 | 205 | max = len(similarUsers) 206 | args := []interface{}{} 207 | args = append(args, "ztmp", float64(max+1), fmt.Sprintf("user:%s:items", user)) 208 | weights := []interface{}{} 209 | weights = append(weights, "WEIGHTS", -1.0) 210 | for _, simuser := range similarUsers { 211 | args = append(args, fmt.Sprintf("user:%s:items", simuser)) 212 | weights = append(weights, 1.0) 213 | } 214 | 215 | args = append(args, weights...) 216 | args = append(args, "AGGREGATE", "MIN") 217 | _, err = rr.rconn.Do("ZUNIONSTORE", args...) 218 | if err != nil { 219 | return nil, err 220 | } 221 | 222 | candidates, err := redis.Strings(rr.rconn.Do("ZRANGEBYSCORE", "ztmp", 0, "inf")) 223 | if err != nil { 224 | return nil, err 225 | } 226 | 227 | _, err = rr.rconn.Do("DEL", "ztmp") 228 | if err != nil { 229 | return nil, err 230 | } 231 | 232 | return candidates, nil 233 | } 234 | 235 | func (rr *Redrec) calcSimilarity(user string, simuser string) (float64, error) { 236 | _, err := rr.rconn.Do("ZINTERSTORE", 237 | "ztmp", 2, fmt.Sprintf("user:%s:items", user), fmt.Sprintf("user:%s:items", simuser), "WEIGHTS", 1, -1) 238 | if err != nil { 239 | return 0, err 240 | } 241 | 242 | userDiffs, err := redis.Strings(rr.rconn.Do("ZRANGE", "ztmp", 0, -1, "WITHSCORES")) 243 | rr.rconn.Do("DEL", "ztmp") 244 | if err != nil { 245 | return 0, err 246 | } 247 | 248 | if len(userDiffs) == 0 { 249 | return 0, nil 250 | } 251 | 252 | var score float64 253 | for i := 1; i < len(userDiffs); i += 2 { 254 | diffVal, _ := strconv.ParseFloat(userDiffs[i], 64) 255 | score += diffVal * diffVal 256 | } 257 | score /= float64(len(userDiffs) / 2) 258 | score = math.Sqrt(score) 259 | 260 | return score, nil 261 | } 262 | -------------------------------------------------------------------------------- /redrec/engine_test.go: -------------------------------------------------------------------------------- 1 | package redrec 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | ) 7 | 8 | var rr *Redrec 9 | var err error 10 | var max = 100 11 | 12 | func TestNew(t *testing.T) { 13 | rr, err = New("redis://localhost:6379") 14 | if err != nil { 15 | t.Error("Redis init Error", err) 16 | } 17 | } 18 | 19 | func TestRate(t *testing.T) { 20 | err := rr.Rate("item1", "user1", 0.4) 21 | if err != nil { 22 | t.Error("Rate Error", err) 23 | } 24 | 25 | err = rr.Rate("item2", "user1", 0.5) 26 | err = rr.Rate("item3", "user1", 0.6) 27 | 28 | err = rr.Rate("item1", "user2", 0.4) 29 | err = rr.Rate("item2", "user2", 0.5) 30 | err = rr.Rate("item3", "user2", 0.6) 31 | err = rr.Rate("item4", "user2", 0.7) 32 | err = rr.Rate("item5", "user2", 0.8) 33 | err = rr.Rate("item6", "user2", 0.9) 34 | 35 | err = rr.Rate("item5", "user3", 0.66) 36 | err = rr.Rate("item6", "user3", 0.66) 37 | err = rr.Rate("item7", "user3", 0.66) 38 | err = rr.Rate("item8", "user3", 0.66) 39 | 40 | items, err := rr.getUserItems("user2", max) 41 | if err != nil { 42 | t.Error("Rate Error", err) 43 | } 44 | if len(items) != 6 { 45 | t.Error("Rate Items len", len(items)) 46 | } 47 | } 48 | 49 | func TestGetSimilarityCandidates(t *testing.T) { 50 | result, err := rr.getSimilarityCandidates("user1", max) 51 | if err != nil { 52 | t.Error("getSimilarityCandidates Error", err) 53 | } 54 | 55 | if len(result) != 2 { 56 | t.Error("getSimilarityCandidates result len", len(result)) 57 | } 58 | 59 | result, err = rr.getSimilarityCandidates("user2", max) 60 | if err != nil { 61 | t.Error("getSimilarityCandidates Error", err) 62 | } 63 | 64 | if len(result) != 3 { 65 | t.Error("getSimilarityCandidates result len", len(result)) 66 | } 67 | } 68 | 69 | func TestCalcSimilarity(t *testing.T) { 70 | result, err := rr.calcSimilarity("user1", "user2") 71 | if err != nil { 72 | t.Error("calcSimilarity Error", err) 73 | } 74 | 75 | if result != 0.0 { 76 | t.Error("calcSimilarity result", result) 77 | } 78 | } 79 | 80 | func TestGetSuggestCandidates(t *testing.T) { 81 | rr.BatchUpdateSimilarUsers(max) 82 | result, err := rr.getSuggestCandidates("user1", max) 83 | if err != nil { 84 | t.Error("getSuggestCandidates Error", err) 85 | } 86 | 87 | if len(result) != 3 { 88 | t.Error("getSuggestCandidates result len", len(result)) 89 | } 90 | 91 | if result[0] != "item4" { 92 | t.Error("getSuggestCandidates result[0]", result) 93 | } 94 | } 95 | 96 | func TestGetUserSuggestions(t *testing.T) { 97 | rr.UpdateSuggestedItems("user1", max) 98 | result, err := rr.GetUserSuggestions("user1", max) 99 | if err != nil { 100 | t.Error("GetUserSuggestions Error", err) 101 | } 102 | 103 | fmt.Println("result: ", result) 104 | if result[0] != "item6" { 105 | t.Error("GetUserSuggestions", result) 106 | } 107 | } 108 | --------------------------------------------------------------------------------