collaborative-filter
Version:
A lightweight implementation of collaborative filtering.
233 lines (206 loc) • 8.22 kB
JavaScript
const math = require('mathjs');
/*
* If you put this to 0, you will get recommendations from users which don't necessarily have
* similar taste as you (these will however be lower ranked than recommendations from people
* with similar taste). This option is available if you consider a cold start something that
* will make your service seem poor. With this flag enabled, you will never receive a
* recommendation from someone who has no similarity with you.
*/
const ONLY_RECOMMEND_FROM_SIMILAR_TASTE = 1;
const NORMALIZE_ON_POPULARITY = 1;
// Local functions
/**
* Normalizes a co-occurrence matrix based on popularity.
* TODO: Error check (size)
* @param {mathjs matrix} coMatrix A co-occurrence matrix
* @param {mathjs matrix} normalizerMatrix A matrix with division factors for the
* coMatrix. Should be the same size as coMatrix
* @returns {mathjs matrix} A normalized co-occurrence matrix
*/
function normalizeCoMatrix(coMatrix, normalizerMatrix) {
return math.dotDivide(coMatrix, normalizerMatrix);
}
/**
* Extract which items have a rating for a given user.
* @param {array} ratings The ratings of all the users
* @param {number} userIndex The index of the user you want to know which items
* he or she has rated.
* @param {number} numItems The number of items which have been rated.
* @returns {array} An array of indices noting what games which have been rated.
*/
function getRatedItemsForUser(ratings, userIndex, numItems) {
const ratedItems = [];
for (let index = 0; index < numItems; index += 1) {
if (ratings[userIndex][index] !== 0) {
ratedItems.push(index);
}
}
return ratedItems;
}
function arraysAreEqual(array1, array2) {
if (array1.length !== array2.length) {
return false;
}
for (let index = 0; index < array1.length; index += 1) {
if (array1[index] !== array2[index]) {
return false;
}
}
return true;
}
function typeCheckRatings(ratings) {
if (!Array.isArray(ratings)) {
throw new TypeError('The ratings and coMatrix field should be an array of arrays (matrix)');
}
}
function typeCheckCoOccurrenceMatrix(coMatrix, numItems) {
if (!(coMatrix instanceof math.Matrix)) {
throw new TypeError('The occurrence matrix should be a mathJS Matrix object generated by createCoMatrix');
}
if (!arraysAreEqual(coMatrix.size(), [numItems, numItems])) {
throw new RangeError('Co matrix has wrong dimensions. Make sure to generate it using createCoMatrix');
}
}
function typeCheckUserIndex(userIndex, ratings) {
if (!Number.isInteger(userIndex)) {
throw new TypeError('The field userIndex should be an integer');
}
if ((userIndex < 0) || (userIndex >= ratings.length)) {
throw new RangeError('User index out of rage');
}
}
function checkRatingValues(ratingMatrix) {
const allowedRatings = [0, 1];
ratingMatrix.forEach((value) => {
if ((!Number.isInteger(value)) || (!allowedRatings.includes(value))) {
throw new TypeError('Wrong rating in rating array. Currently permitted values are 0 and 1');
}
});
return true;
}
// Global API functions
/**
* Generate recommendations for a user.
* @param {array} ratings Same definition as in the collaborativeFilter function.
* @param {array} coMatrix A co-occurrence matrix
* @param {number} userIndex The index of the user you want to know which items
* he or she has rated.
* @returns {array} An array of item indices sorted in how much well recommended
* the item is.
*/
function getRecommendations(ratings, coMatrix, userIndex) {
typeCheckRatings(ratings);
let ratingsMatrix;
try {
ratingsMatrix = math.matrix(ratings);
} catch (error) {
throw new RangeError('Dimension error in ratings matrix');
}
const numItems = ratingsMatrix.size()[1];
typeCheckCoOccurrenceMatrix(coMatrix, numItems);
typeCheckUserIndex(userIndex, ratings);
const ratedItemsForUser = getRatedItemsForUser(ratings, userIndex, numItems);
const numRatedItems = ratedItemsForUser.length;
const similarities = math.zeros(numRatedItems, numItems);
for (let rated = 0; rated < numRatedItems; rated += 1) {
for (let item = 0; item < numItems; item += 1) {
similarities.set([rated, item], coMatrix.get([ratedItemsForUser[rated], item])
+ similarities.get([rated, item]));
}
}
// Sum of each row in similarity matrix becomes one row:
let recommendations = math.zeros(numItems);
for (let y = 0; y < numRatedItems; y += 1) {
for (let x = 0; x < numItems; x += 1) {
recommendations.set([x], recommendations.get([x]) + similarities.get([y, x]));
}
}
recommendations = math.dotDivide(recommendations, numRatedItems);
const rec = recommendations.toArray();
let recSorted = recommendations.toArray();
recSorted.sort((a, b) => b - a);
if (ONLY_RECOMMEND_FROM_SIMILAR_TASTE) {
recSorted = recSorted.filter((element) => element !== 0);
}
let recOrder = recSorted.map((element) => {
const index = rec.indexOf(element);
rec[index] = null; // To ensure no duplicate indices in the future iterations.
return index;
});
recOrder = recOrder.filter((index) => !ratedItemsForUser.includes(index));
return recOrder;
}
/**
* Generates a co-occurrence matrix based on the input from the ratings param.
* @param {array} ratings Same definition as in the collaborativeFilter function.
* @returns {mathjs Matrix} A two-dimensional co-occurrence matrix with size X x X (X
* being the number of items that have received at least one rating. The
* diagonal from left to right should consist of only zeroes.
*/
function createCoMatrix(ratings) {
// We create the ratings matrix to ensure we have correct dimensions
typeCheckRatings(ratings);
let ratingsMatrix;
try {
ratingsMatrix = math.matrix(ratings);
} catch (error) {
throw new RangeError('Dimension error in ratings matrix');
}
checkRatingValues(ratingsMatrix);
const nUsers = ratingsMatrix.size()[0];
const nItems = ratingsMatrix.size()[1];
const coMatrix = math.zeros(nItems, nItems);
// const normalizerMatrix = math.zeros(nItems, nItems)
const normalizerMatrix = math.identity(nItems);
for (let y = 0; y < nUsers; y += 1) {
// User
for (let x = 0; x < (nItems - 1); x += 1) {
// Items in the user
for (let index = x + 1; index < nItems; index += 1) {
// Co-occurrence
if (ratings[y][x] === 1 && ratings[y][index] === 1) {
coMatrix.set([x, index], coMatrix.get([x, index]) + 1);
coMatrix.set([index, x], coMatrix.get([index, x]) + 1); // mirror
}
if (NORMALIZE_ON_POPULARITY && (ratings[y][x] === 1 || ratings[y][index] === 1)) {
normalizerMatrix.set([x, index], normalizerMatrix.get([x, index]) + 1);
normalizerMatrix.set([index, x], normalizerMatrix.get([index, x]) + 1);
}
}
}
}
return NORMALIZE_ON_POPULARITY ? normalizeCoMatrix(coMatrix, normalizerMatrix) : coMatrix;
}
/**
* This function starts the collaborative filtering process.
* @param {array} ratings - A two-dimensional array of consisting of the user
* ratings. The array should be of the following format:
* I0 I1 I2 . . .
* [
* U0 [1 1 1 . . .],
* U1 [1 0 1 . . .],
* U2 [1 0 0 . . .],
* . [. . . . . .],
* . [. . . . . .],
* . [. . . . . .],
* ]
* where IX is an item and UY is a user. Therefor, the size of the matrix
* be X x Y. The values in the matrix should be the rating for a given user.
* If the user has not rated that item, the value should be 0. If the user
* liked the item, it should be a 1. If disliked, a -1. Dislikes should be
* implemented last.
* @returns {array} A two-dimensional array for the normalized co occurrence
* matrix
*/
function collaborativeFilter(ratings, userIndex) {
if (!Array.isArray(ratings)) return false;
const coMatrix = createCoMatrix(ratings);
const recommendations = getRecommendations(ratings, coMatrix, userIndex);
return recommendations;
}
// Export API functions
module.exports = {
cFilter: collaborativeFilter,
getRecommendations,
coMatrix: createCoMatrix,
};