├── .gitignore ├── .swiftpm └── xcode │ └── package.xcworkspace │ └── xcshareddata │ └── IDEWorkspaceChecks.plist ├── LICENSE.md ├── Package.resolved ├── Package.swift ├── README.md ├── Sources └── CollaborativeFiltering │ └── CollaborativeFiltering.swift └── Tests └── CollaborativeFilteringTests └── CollaborativeFilteringTests.swift /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/config/registries.json 8 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 9 | .netrc 10 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Portions of this library were taken and modified from 4 | [collaborative-filtering](https://github.com/TSonono/collaborative-filtering) 5 | under the MIT license: 6 | 7 | Copyright (c) 2019 TSonono 8 | 9 | All modifications and additions to the original code are also licensed under 10 | the MIT license: 11 | 12 | Copyright (c) 2023 Ryan Ashcraft 13 | 14 | Permission is hereby granted, free of charge, to any person obtaining a copy 15 | of this software and associated documentation files (the "Software"), to deal 16 | in the Software without restriction, including without limitation the rights 17 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 18 | copies of the Software, and to permit persons to whom the Software is 19 | furnished to do so, subject to the following conditions: 20 | 21 | The above copyright notice and this permission notice shall be included in all 22 | copies or substantial portions of the Software. 23 | 24 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 25 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 26 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 27 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 28 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 29 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 30 | SOFTWARE. 31 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "cwlcatchexception", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/mattgallagher/CwlCatchException.git", 7 | "state" : { 8 | "revision" : "3b123999de19bf04905bc1dfdb76f817b0f2cc00", 9 | "version" : "2.1.2" 10 | } 11 | }, 12 | { 13 | "identity" : "cwlpreconditiontesting", 14 | "kind" : "remoteSourceControl", 15 | "location" : "https://github.com/mattgallagher/CwlPreconditionTesting.git", 16 | "state" : { 17 | "revision" : "a23ded2c91df9156628a6996ab4f347526f17b6b", 18 | "version" : "2.1.2" 19 | } 20 | }, 21 | { 22 | "identity" : "laswift", 23 | "kind" : "remoteSourceControl", 24 | "location" : "https://github.com/AlexanderTar/LASwift.git", 25 | "state" : { 26 | "revision" : "85271d14245c88890d2f36066d1aaf71486fde06", 27 | "version" : "0.3.2" 28 | } 29 | }, 30 | { 31 | "identity" : "nimble", 32 | "kind" : "remoteSourceControl", 33 | "location" : "https://github.com/Quick/Nimble.git", 34 | "state" : { 35 | "revision" : "1f3bde57bde12f5e7b07909848c071e9b73d6edc", 36 | "version" : "10.0.0" 37 | } 38 | }, 39 | { 40 | "identity" : "quick", 41 | "kind" : "remoteSourceControl", 42 | "location" : "https://github.com/Quick/Quick.git", 43 | "state" : { 44 | "revision" : "f9d519828bb03dfc8125467d8f7b93131951124c", 45 | "version" : "5.0.1" 46 | } 47 | } 48 | ], 49 | "version" : 2 50 | } 51 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.8 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "CollaborativeFiltering", 8 | platforms: [ 9 | .macOS(.v10_13), 10 | .iOS(.v12), 11 | .tvOS(.v12), 12 | .watchOS(.v6), 13 | ], 14 | products: [ 15 | .library( 16 | name: "CollaborativeFiltering", 17 | targets: ["CollaborativeFiltering"] 18 | ), 19 | ], 20 | dependencies: [ 21 | .package(url: "https://github.com/AlexanderTar/LASwift.git", from: "0.3.2"), 22 | ], 23 | targets: [ 24 | .target( 25 | name: "CollaborativeFiltering", 26 | dependencies: ["LASwift"] 27 | ), 28 | .testTarget( 29 | name: "CollaborativeFilteringTests", 30 | dependencies: ["CollaborativeFiltering"] 31 | ), 32 | ] 33 | ) 34 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CollaborativeFiltering 2 | 3 | [Collaborative filtering](https://en.wikipedia.org/wiki/Collaborative_filtering) is a type of recommendation algorithm that utilizes the preferences and behavior of similar users to suggest items (e.g. movies, music, products, etc.) that a user may like. It works by finding patterns in the preferences of different users and using these patterns to predict the preferences of new users. This is based on the assumption that people who have similar preferences in the past will have similar preferences in the future. 4 | 5 | This library provides one form of collaborative filtering that runs in-memory and uses [Jaccard similarity](https://en.wikipedia.org/wiki/Jaccard_index) to measure the similarity between users' preferences. It provides item recommendations for a user based on users with a similar taste. 6 | 7 | You can also apply this algorithm to single-user use cases, where you aim to predict future behavior based on past behavior. In scenarios like this, "users" can instead represent distinct user sessions instead. 8 | 9 | This library is largely a straightforward port from [collaborative-filtering](https://github.com/TSonono/collaborative-filtering), a JavaScript open-source project by TSonono. 10 | 11 | ## Usage 12 | 13 | 1. Prepare input data ("ratings"). 14 | 15 | You need to structure your input data as a two-dimensional array. Each row represents a user (or user session), and each column represents an item rating. Each item rating value can either be 0 or 1. 1 is a positive rating, e.g. a "like", rating, purchase, or some type of feature engagement. 0 indicates null or no usage. 16 | 17 | ``` 18 | I0 I1 I2 . . . 19 | [ 20 | U0 [1 1 1 . . .], 21 | U1 [1 0 1 . . .], 22 | U2 [1 0 0 . . .], 23 | . [. . . . . .], 24 | . [. . . . . .], 25 | . [. . . . . .], 26 | ] 27 | ``` 28 | 29 | The algorithm in this library runs in-memory and has a runtime complexity of approximately O(U(I^2)), where U is the number of users and I is the number of items. Therefore, it is best suited for use cases with a reasonably small scale. 30 | 31 | The current user must be included in this 2D array. When preparing this data, you'll need to remember which row represents the current user and what each column index represents. 32 | 33 | ```swift 34 | let recommendedItemIndices = try CollaborativeFiltering.collaborativeFilter( 35 | ratings: ratings, 36 | userIndex: userIndex 37 | ) 38 | ``` 39 | 40 | 2. Map the resulting array of indices back to items to recommend. 41 | 42 | ## Example Use Cases 43 | 44 | There are many ways this algorithm could be applied. Here are some creative examples: 45 | 46 | 1. Implement a feature similar to "Siri Suggestions" on iOS, where items are recommended based on recently selected items. 47 | 2. For a social movie tracking app, recommend movies based on what the user has liked versus what their friends have liked. 48 | 3. For an instant messaging app, recommend people to add to a group conversation based on other group conversations. 49 | 4. For a music playing app, automatically choose songs to play next based on last few songs that the user has selected. In this case, the "users" would represent previous listening sessions. The resulting recommendations could try to predict which songs the user would otherwise manually seek out next. 50 | 51 | ## Challenges 52 | 53 | ### Sparse Data 54 | 55 | Very large quantities of items or users will lead to significant performance challenges. Explore various strategies to reducing the size of either items or users. For example, you could only use data from X most recent days. Or you could only include users that have some level of usage. 56 | 57 | ### Cold Starts 58 | 59 | If you don't have enough data for a given user, it's hard for the algorithm to generate good recommendations, especially if the number of items is large. 60 | 61 | There is no universal strategy to handling cold starts. One possible solution is to simply suggest the most popular items. You can also try disabling `onlyRecommendFromSimilarTaste`, which is a parameter to the `getRecommendations` function (you'll need to generate the co-occurrence matrix using `createCoMatrix` first). If this flag is disabled, the algorithm may yield recommendations from others that have no similarity with the user. 62 | 63 | ## Dependencies 64 | 65 | This library has one dependency, [LASwift](https://github.com/AlexanderTar/LASwift), which provides various linear algebra conveniences. 66 | 67 | ## Contributions 68 | 69 | If you'd like to submit a bug fix or enhancement, please submit a pull request. Please include some context, your motivation, add tests if appropriate. 70 | 71 | ## License 72 | 73 | See [LICENSE](/LICENSE.md). 74 | 75 | Portions of this library were taken and modified from [collaborative-filtering](https://github.com/TSonono/collaborative-filtering), an MIT-licensed library, Copyright (c) 2019 TSonono. The code has been modified for use in this project. 76 | -------------------------------------------------------------------------------- /Sources/CollaborativeFiltering/CollaborativeFiltering.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) 2019 TSonono 3 | // Copyright (c) 2023 Ryan Ashcraft 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 | // 23 | 24 | import Foundation 25 | import LASwift 26 | 27 | /// Collaborative filtering is a type of recommendation algorithm that utilizes the preferences and behavior of similar 28 | /// users to suggest items that a user may like. It works by finding patterns in the preferences of different users and 29 | /// using these patterns to predict the preferences of new users. 30 | /// 31 | /// Collaborative filtering can be used in a variety of applications such as recommending movies, music, or products 32 | /// ("items") to customers ("users"). The algorithm is based on the assumption that people who have similar preferences 33 | /// in the past will have similar preferences in the future. 34 | /// 35 | /// The algorithm can also be applied to single-user use cases where you aim to predict future behavior based on past 36 | /// behavior. In scenarios like this, users can instead represent distinct user sessions. 37 | /// 38 | /// This implementation was ported from collaborative-filtering, a JavaScript [open-source project by TSonono](https://github.com/TSonono/collaborative-filtering). 39 | public enum CollaborativeFiltering { 40 | public typealias Ratings = [[Double]] 41 | 42 | public enum Error: Swift.Error { 43 | case coMatrixWrongDimensions 44 | case userIndexOutOfRange 45 | case ratingArrayValueInvalid 46 | } 47 | 48 | /// Generates recommendations using collaborative filtering. 49 | /// 50 | /// This implementation runs in memory and has a runtime complexity of approximately O(U • I^2), where U is the number 51 | /// of users and I represents the number of items. Therefore, it is best suited for use cases with a reasonably small 52 | /// scale, e.g. 100 users and 100 items. 53 | /// 54 | /// - Parameters: 55 | /// - ratings: A two-dimensional array of consisting of the user ratings. The array should be of the following format: 56 | /// 57 | /// ``` 58 | /// I0 I1 I2 . . . 59 | /// [ 60 | /// U0 [1 1 1 . . .], 61 | /// U1 [1 0 1 . . .], 62 | /// U2 [1 0 0 . . .], 63 | /// . [. . . . . .], 64 | /// . [. . . . . .], 65 | /// . [. . . . . .], 66 | /// ] 67 | /// ``` 68 | /// 69 | /// Where IX is an item and UY is a user. Therefore, the size of the matrix be X by Y. The values in the 70 | /// matrix should be the rating for a given user. If the user has not rated that item, the value should be 0. 71 | /// If the user liked the item, it should be a 1. 72 | /// 73 | /// - Returns: An array of item indices, sorted by how recommended the item is. 74 | public static func collaborativeFilter(ratings: Ratings, userIndex: Int) throws -> [Int] { 75 | let coMatrix = try createCoMatrix(ratings: ratings) 76 | let recommendations = try getRecommendations(ratings: ratings, coMatrix: coMatrix, userIndex: userIndex) 77 | 78 | return recommendations 79 | } 80 | 81 | /// Generate recommendations for a user given a co-occurrence matrix. 82 | 83 | /// - Parameters: 84 | /// - ratings: A two-dimensional array of consisting of the user ratings. The array should be of the following format: 85 | /// 86 | /// ``` 87 | /// I0 I1 I2 . . . 88 | /// [ 89 | /// U0 [1 1 1 . . .], 90 | /// U1 [1 0 1 . . .], 91 | /// U2 [1 0 0 . . .], 92 | /// . [. . . . . .], 93 | /// . [. . . . . .], 94 | /// . [. . . . . .], 95 | /// ] 96 | /// ``` 97 | /// 98 | /// Where IX is an item and UY is a user. Therefore, the size of the matrix be X by Y. The values in the 99 | /// matrix should be the rating for a given user. If the user has not rated that item, the value should be 0. 100 | /// If the user liked the item, it should be a 1. 101 | /// - coMatrix: A co-occurrence matrix. 102 | /// - userIndex: The index of the user you want to know which items the user has rated. 103 | /// - onlyRecommendFromSimilarTaste: When enabled, you will never receive a recommendation from someone who 104 | /// has no similarity with the user. 105 | /// 106 | /// - Returns: An array of item indices, sorted by how recommended the item is. 107 | public static func getRecommendations( 108 | ratings: Ratings, 109 | coMatrix: Matrix, 110 | userIndex: Int, 111 | onlyRecommendFromSimilarTaste: Bool = true 112 | ) throws -> [Int] { 113 | let ratingsMatrix = Matrix(ratings) 114 | let itemCount = ratingsMatrix.cols 115 | 116 | // Runtime validations 117 | try validateMatrixSize(matrix: coMatrix, size: itemCount) 118 | try validateUserIndex(userIndex: userIndex, ratings: ratings) 119 | try validateRatingValues(matrix: ratingsMatrix) 120 | 121 | let ratedItemsForUser: [Int] = getRatedItemsForUser(ratings: ratings, userIndex: userIndex, itemCount: itemCount) 122 | let ratedItemCount = ratedItemsForUser.count 123 | let similarities = zeros(ratedItemCount, itemCount) 124 | 125 | // Sum of each row in similarity matrix becomes one row 126 | var recommendations = zeros(itemCount) 127 | 128 | // Mutate matrices using a mutable pointer to avoid perf penalty from copy on write behavior 129 | similarities.flat.withUnsafeMutableBufferPointer { similaritiesBuffer in 130 | recommendations.withUnsafeMutableBufferPointer { recommendationsBuffer in 131 | for i in 0 ..< ratedItemCount { 132 | for j in 0 ..< itemCount { 133 | similaritiesBuffer[i * similarities.cols + j] += coMatrix[ratedItemsForUser[i] * coMatrix.cols + j] 134 | } 135 | } 136 | 137 | for i in 0 ..< ratedItemCount { 138 | for j in 0 ..< itemCount { 139 | recommendationsBuffer[j] += similaritiesBuffer[i * similarities.cols + j] 140 | } 141 | } 142 | } 143 | } 144 | 145 | recommendations = rdivide(recommendations, Double(ratedItemCount)) 146 | 147 | var rec: [Double?] = recommendations 148 | var recSorted = recommendations.sorted { $1.isLess(than: $0) } 149 | 150 | if onlyRecommendFromSimilarTaste { 151 | recSorted = recSorted.filter { $0 != 0 } 152 | } 153 | 154 | var recOrder: [Int] = recSorted.compactMap { element in 155 | guard let index = rec.firstIndex(of: element) else { return nil } 156 | 157 | rec[index] = nil // To ensure no duplicate indices in the future iterations 158 | 159 | return index 160 | } 161 | 162 | recOrder = recOrder.filter { !ratedItemsForUser.contains($0) } 163 | 164 | return recOrder 165 | } 166 | 167 | /// Generates a co-occurrence matrix. 168 | /// 169 | /// - Parameters: 170 | /// - ratings: A two-dimensional array of consisting of the user ratings. The array should be of the following format: 171 | /// 172 | /// ``` 173 | /// I0 I1 I2 . . . 174 | /// [ 175 | /// U0 [1 1 1 . . .], 176 | /// U1 [1 0 1 . . .], 177 | /// U2 [1 0 0 . . .], 178 | /// . [. . . . . .], 179 | /// . [. . . . . .], 180 | /// . [. . . . . .], 181 | /// ] 182 | /// ``` 183 | /// 184 | /// Where IX is an item and UY is a user. Therefore, the size of the matrix be X by Y. The values in the 185 | /// matrix should be the rating for a given user. If the user has not rated that item, the value should be 0. 186 | /// If the user liked the item, it should be a 1. 187 | /// - normalizeOnPopularity: If false, the popularity of items will bias the results. 188 | /// 189 | /// - Returns: A two-dimensional co-occurrence matrix with size X by X (X being the number of items that have 190 | /// received at least one rating). The diagonal from left to right should consist of only zeroes. 191 | public static func createCoMatrix( 192 | ratings: Ratings, 193 | normalizeOnPopularity: Bool = true 194 | ) throws -> Matrix { 195 | let ratingsMatrix = Matrix(ratings) 196 | let (userCount, itemCount) = ratingsMatrix.size 197 | let coMatrix = zeros(itemCount, itemCount) 198 | let normalizerMatrix = eye(itemCount, itemCount) 199 | 200 | // Mutate matrices using a mutable pointer to avoid perf penalty from copy on write behavior 201 | coMatrix.flat.withUnsafeMutableBufferPointer { coMatrixBuffer in 202 | normalizerMatrix.flat.withUnsafeMutableBufferPointer { normalizerMatrixBuffer in 203 | for y in 0 ..< userCount { 204 | for x in 0 ..< itemCount - 1 { 205 | for i in x + 1 ..< itemCount { 206 | // Co-occurrence 207 | if ratings[y][x] == 1 && ratings[y][i] == 1 { 208 | coMatrixBuffer[x * itemCount + i] += 1 209 | coMatrixBuffer[i * itemCount + x] += 1 // mirror 210 | } 211 | 212 | if normalizeOnPopularity, ratings[y][x] == 1 || ratings[y][i] == 1 { 213 | normalizerMatrixBuffer[x * itemCount + i] += 1 214 | normalizerMatrixBuffer[i * itemCount + x] += 1 215 | } 216 | } 217 | } 218 | } 219 | } 220 | } 221 | 222 | return normalizeOnPopularity 223 | ? normalizeCoMatrix(coMatrix: coMatrix, normalizerMatrix: normalizerMatrix) 224 | : coMatrix 225 | } 226 | 227 | // MARK: - Private Helpers 228 | 229 | /// Normalizes a co-occurrence matrix based on popularity. 230 | /// 231 | /// - Parameters 232 | /// - coMatrix: A co-occurrence matrix. 233 | /// - normalizerMatrix: A matrix with division factors for the coMatrix. Should be the same size as coMatrix. 234 | /// 235 | /// - Returns A normalized co-occurrence matrix. 236 | static func normalizeCoMatrix(coMatrix: Matrix, normalizerMatrix: Matrix) -> Matrix { 237 | return rdivide(coMatrix, normalizerMatrix) 238 | } 239 | 240 | /// Extract which items have a rating for a given user. 241 | /// 242 | /// - Parameters: 243 | /// - ratings: The ratings of all the users. 244 | /// - userIndex: The index of the user you want to know which items he/she/they have rated. 245 | /// - itemCount: The number of items which have been rated. 246 | /// 247 | /// - Returns: An array of indices noting what games which have been rated. 248 | static func getRatedItemsForUser(ratings: Ratings, userIndex: Int, itemCount: Int) -> [Int] { 249 | var ratedItems: [Int] = [] 250 | 251 | for index in 0 ..< itemCount { 252 | if ratings[userIndex][index] != 0 { 253 | ratedItems.append(index) 254 | } 255 | } 256 | 257 | return ratedItems 258 | } 259 | 260 | // MARK: - Private Runtime Validations 261 | 262 | static func validateMatrixSize(matrix: Matrix, size: Int) throws { 263 | if matrix.size != (size, size) { 264 | throw CollaborativeFiltering.Error.coMatrixWrongDimensions 265 | } 266 | } 267 | 268 | static func validateUserIndex(userIndex: Int, ratings: Ratings) throws { 269 | if (userIndex < 0) || (userIndex >= ratings.count) { 270 | throw CollaborativeFiltering.Error.userIndexOutOfRange 271 | } 272 | } 273 | 274 | static func validateRatingValues(matrix: Matrix) throws { 275 | let allowedRatings: [Double] = [0, 1] 276 | 277 | try matrix.forEach { row in 278 | try row.forEach { value in 279 | if !allowedRatings.contains(value) { 280 | throw CollaborativeFiltering.Error.ratingArrayValueInvalid 281 | } 282 | } 283 | } 284 | } 285 | } 286 | 287 | // MARK: - LASwift Extensions 288 | 289 | private extension Matrix { 290 | var size: (Int, Int) { 291 | (rows, cols) 292 | } 293 | } 294 | -------------------------------------------------------------------------------- /Tests/CollaborativeFilteringTests/CollaborativeFilteringTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) 2019 TSonono 3 | // Copyright (c) 2023 Ryan Ashcraft 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 | // 23 | 24 | @testable import CollaborativeFiltering 25 | import LASwift 26 | import XCTest 27 | 28 | struct Examples { 29 | static let invalid: CollaborativeFiltering.Ratings = [ 30 | [1, 1, 2], 31 | [1, 0, 1], 32 | [1, 0, 0], 33 | ] 34 | 35 | static let simple: CollaborativeFiltering.Ratings = [ 36 | [1, 1, 1], 37 | [1, 0, 1], 38 | [1, 0, 0], 39 | ] 40 | 41 | static let complex: CollaborativeFiltering.Ratings = [ 42 | [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 1], 43 | [0, 1, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0], 44 | [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 45 | [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0], 46 | [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0], 47 | [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 48 | [0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 0, 0, 0, 1, 1, 1, 1, 1], 49 | [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 50 | [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 51 | [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0], 52 | ] 53 | 54 | static func makeRandom(userCount: Int, itemCount: Int) -> CollaborativeFiltering.Ratings { 55 | (0 ..< userCount).map { _ in 56 | (0 ..< itemCount).map { _ in 57 | Bool.random() ? 1 : 0 58 | } 59 | } 60 | } 61 | } 62 | 63 | final class CollaborativeFilteringTests: XCTestCase { 64 | func testCollaborativeFilter() async { 65 | let ratings: CollaborativeFiltering.Ratings = [ 66 | [1, 1, 1, 1], 67 | [1, 0, 1, 1], 68 | [1, 0, 0, 1], 69 | [1, 0, 0, 1], 70 | ] 71 | 72 | let recs = try! CollaborativeFiltering.collaborativeFilter(ratings: ratings, userIndex: 2) 73 | 74 | XCTAssertEqual(recs, [2, 1]) 75 | } 76 | 77 | func testSimpleCoMatrix() async { 78 | let coMatrix = try! CollaborativeFiltering.createCoMatrix(ratings: Examples.simple) 79 | 80 | XCTAssertEqual( 81 | coMatrix, 82 | Matrix( 83 | [ 84 | [0.0 / 1.0, 1.0 / 3.0, 2.0 / 3.0], 85 | [1.0 / 3.0, 0.0 / 1.0, 1.0 / 2.0], 86 | [2.0 / 3.0, 1.0 / 2.0, 0.0 / 1.0], 87 | ] 88 | ) 89 | ) 90 | } 91 | 92 | func testGetRecommendationsSimple1() async { 93 | let coMatrix = try! CollaborativeFiltering.createCoMatrix(ratings: Examples.simple) 94 | let recs = try! CollaborativeFiltering.getRecommendations(ratings: Examples.simple, coMatrix: coMatrix, userIndex: 1) 95 | 96 | XCTAssertEqual(recs, [1]) 97 | } 98 | 99 | func testGetRecommendationsSimple2() async { 100 | let coMatrix = try! CollaborativeFiltering.createCoMatrix(ratings: Examples.simple) 101 | let recs = try! CollaborativeFiltering.getRecommendations(ratings: Examples.simple, coMatrix: coMatrix, userIndex: 2) 102 | 103 | XCTAssertEqual(recs, [2, 1]) 104 | } 105 | 106 | func testGetRecommendationsSimple0() async { 107 | let coMatrix = try! CollaborativeFiltering.createCoMatrix(ratings: Examples.simple) 108 | let recs = try! CollaborativeFiltering.getRecommendations(ratings: Examples.simple, coMatrix: coMatrix, userIndex: 0) 109 | 110 | XCTAssertEqual(recs, []) 111 | } 112 | 113 | func testThrowsUserIndexOutOfRangeError() async { 114 | let coMatrix = try! CollaborativeFiltering.createCoMatrix(ratings: Examples.simple) 115 | 116 | XCTAssertThrowsError(try CollaborativeFiltering.getRecommendations(ratings: Examples.simple, coMatrix: coMatrix, userIndex: 3)) { error in 117 | XCTAssertEqual(error as! CollaborativeFiltering.Error, CollaborativeFiltering.Error.userIndexOutOfRange) 118 | } 119 | } 120 | 121 | func testThrowsCoMatrixWrongDimensionsError() async { 122 | do { 123 | _ = try CollaborativeFiltering.getRecommendations(ratings: Examples.simple, coMatrix: ones(4, 7), userIndex: 1) 124 | } catch { 125 | XCTAssertEqual(error as! CollaborativeFiltering.Error, CollaborativeFiltering.Error.coMatrixWrongDimensions) 126 | } 127 | } 128 | 129 | func testThrowsRatingArrayValueInvalidError() async { 130 | do { 131 | _ = try CollaborativeFiltering.createCoMatrix(ratings: Examples.invalid) 132 | } catch { 133 | XCTAssertEqual(error as! CollaborativeFiltering.Error, CollaborativeFiltering.Error.ratingArrayValueInvalid) 134 | } 135 | } 136 | 137 | func testGetRecommendationsComplex6() async { 138 | let coMatrix = try! CollaborativeFiltering.createCoMatrix(ratings: Examples.complex) 139 | let recs = try! CollaborativeFiltering.getRecommendations(ratings: Examples.complex, coMatrix: coMatrix, userIndex: 6) 140 | 141 | XCTAssertEqual(recs.first, 1) 142 | XCTAssertEqual(recs.last, 11) 143 | } 144 | 145 | func testGetRecommendationsComplex0() async { 146 | let coMatrix = try! CollaborativeFiltering.createCoMatrix(ratings: Examples.complex) 147 | let recs = try! CollaborativeFiltering.getRecommendations(ratings: Examples.complex, coMatrix: coMatrix, userIndex: 0) 148 | 149 | // The recommendations with rank 1-4 for U0 should be I14-I17 (in no specific order) 150 | XCTAssertTrue([14, 15, 16, 17].contains(recs[0])) 151 | XCTAssertTrue([14, 15, 16, 17].contains(recs[1])) 152 | XCTAssertTrue([14, 15, 16, 17].contains(recs[2])) 153 | XCTAssertTrue([14, 15, 16, 17].contains(recs[3])) 154 | 155 | // The recommendations with rank 5 for U0 should be I11 156 | XCTAssertEqual(recs[4], 11) 157 | 158 | // The number of recommendations for U0 should be 5 159 | XCTAssertEqual(recs.count, 5) 160 | } 161 | 162 | func testGetRecommendationsComplex9() async { 163 | let coMatrix = try! CollaborativeFiltering.createCoMatrix(ratings: Examples.complex) 164 | let recs = try! CollaborativeFiltering.getRecommendations(ratings: Examples.complex, coMatrix: coMatrix, userIndex: 9) 165 | 166 | // The only recommendation for U9 should be I12 167 | XCTAssertEqual(recs.first, 12) 168 | XCTAssertEqual(recs.count, 1) 169 | } 170 | 171 | func testGetRecommendationsComplex1() async { 172 | let coMatrix = try! CollaborativeFiltering.createCoMatrix(ratings: Examples.complex) 173 | let recs = try! CollaborativeFiltering.getRecommendations(ratings: Examples.complex, coMatrix: coMatrix, userIndex: 1) 174 | 175 | XCTAssertEqual(recs.count, 12) 176 | XCTAssertEqual(recs.first, 18) 177 | 178 | // The recommendations with rank 1-4 for U0 should be I14-I17 (in no specific order) 179 | XCTAssertTrue([0, 2, 3, 4, 5, 6, 7, 8, 9, 10].contains(recs[1])) 180 | XCTAssertTrue([0, 2, 3, 4, 5, 6, 7, 8, 9, 10].contains(recs[2])) 181 | XCTAssertTrue([0, 2, 3, 4, 5, 6, 7, 8, 9, 10].contains(recs[3])) 182 | XCTAssertTrue([0, 2, 3, 4, 5, 6, 7, 8, 9, 10].contains(recs[4])) 183 | XCTAssertTrue([0, 2, 3, 4, 5, 6, 7, 8, 9, 10].contains(recs[5])) 184 | XCTAssertTrue([0, 2, 3, 4, 5, 6, 7, 8, 9, 10].contains(recs[6])) 185 | XCTAssertTrue([0, 2, 3, 4, 5, 6, 7, 8, 9, 10].contains(recs[7])) 186 | 187 | // The recommendations with rank 9-12 should be (I14-I17) in no specific order 188 | XCTAssertTrue([14, 15, 16, 17].contains(recs[8])) 189 | XCTAssertTrue([14, 15, 16, 17].contains(recs[9])) 190 | XCTAssertTrue([14, 15, 16, 17].contains(recs[10])) 191 | XCTAssertTrue([14, 15, 16, 17].contains(recs[11])) 192 | } 193 | 194 | func testPerformance() async { 195 | measureMetrics([.wallClockTime], automaticallyStartMeasuring: false) { 196 | let randomRatings = Examples.makeRandom(userCount: 100, itemCount: 100) 197 | 198 | startMeasuring() 199 | 200 | _ = try! CollaborativeFiltering.collaborativeFilter(ratings: randomRatings, userIndex: 1) 201 | 202 | stopMeasuring() 203 | } 204 | } 205 | } 206 | --------------------------------------------------------------------------------