module Recommendable::Helpers::Calculations

Public Class Methods

predict_for(user_id, klass, item_id) click to toggle source

Predict how likely it is that a user will like an item. This probability is not based on percentage. 0.0 indicates that the user will neither like nor dislike the item. Values that approach Infinity indicate a rising likelihood of liking the item while values approaching -Infinity indicate a rising probability of disliking the item.

@param [Fixnum, String] user_id the user's ID @param [Class] klass the item's class @param [Fixnum, String] item_id the item's ID @return [Float] the probability that the user will like the item

# File lib/recommendable/helpers/calculations.rb, line 165
def predict_for(user_id, klass, item_id)
  user_id = user_id.to_s
  item_id = item_id.to_s

  liked_by_set = Recommendable::Helpers::RedisKeyMapper.liked_by_set_for(klass, item_id)
  disliked_by_set = Recommendable::Helpers::RedisKeyMapper.disliked_by_set_for(klass, item_id)
  similarity_sum = 0.0

  similarity_sum += similarity_total_for(user_id, liked_by_set)
  similarity_sum -= similarity_total_for(user_id, disliked_by_set)

  liked_by_count, disliked_by_count = Recommendable.redis.pipelined do
    Recommendable.redis.scard(liked_by_set)
    Recommendable.redis.scard(disliked_by_set)
  end
  prediction = similarity_sum / (liked_by_count + disliked_by_count).to_f
  prediction.finite? ? prediction : 0.0
end
similarity_between(user_id, other_user_id) click to toggle source

Calculate a numeric similarity value that can fall between -1.0 and 1.0. A value of 1.0 indicates that both users have rated the same items in the same ways. A value of -1.0 indicates that both users have rated the same items in opposite ways.

@param [Fixnum, String] user_id the ID of the first user @param [Fixnum, String] other_user_id the ID of another user @return [Float] the numeric similarity between this user and the passed user @note Similarity values are asymmetrical. `Calculations.similarity_between(user_id, other_user_id)` will not necessarily equal `Calculations.similarity_between(other_user_id, user_id)`

# File lib/recommendable/helpers/calculations.rb, line 14
def similarity_between(user_id, other_user_id)
  user_id = user_id.to_s
  other_user_id = other_user_id.to_s

  similarity = liked_count = disliked_count = 0
  in_common = Recommendable.config.ratable_classes.each do |klass|
    liked_set = Recommendable::Helpers::RedisKeyMapper.liked_set_for(klass, user_id)
    other_liked_set = Recommendable::Helpers::RedisKeyMapper.liked_set_for(klass, other_user_id)
    disliked_set = Recommendable::Helpers::RedisKeyMapper.disliked_set_for(klass, user_id)
    other_disliked_set = Recommendable::Helpers::RedisKeyMapper.disliked_set_for(klass, other_user_id)

    results = Recommendable.redis.pipelined do
      # Agreements
      Recommendable.redis.sinter(liked_set, other_liked_set)
      Recommendable.redis.sinter(disliked_set, other_disliked_set)

      # Disagreements
      Recommendable.redis.sinter(liked_set, other_disliked_set)
      Recommendable.redis.sinter(disliked_set, other_liked_set)

      Recommendable.redis.scard(liked_set)
      Recommendable.redis.scard(disliked_set)
    end

    # Agreements
    similarity += results[0].size
    similarity += results[1].size

    # Disagreements
    similarity -= results[2].size
    similarity -= results[3].size

    liked_count += results[4]
    disliked_count += results[5]
  end

  similarity / (liked_count + disliked_count).to_f
end
similarity_total_for(user_id, set) click to toggle source
# File lib/recommendable/helpers/calculations.rb, line 184
def similarity_total_for(user_id, set)
  similarity_set = Recommendable::Helpers::RedisKeyMapper.similarity_set_for(user_id)
  ids = Recommendable.redis.smembers(set)
  similarity_values = Recommendable.redis.pipelined do
    ids.each do |id|
      Recommendable.redis.zscore(similarity_set, id)
    end
  end
  similarity_values.map(&:to_f).reduce(&:+).to_f
end
update_recommendations_for(user_id) click to toggle source

Used internally to update this user's prediction values across all recommendable types. This is called by the background worker.

@private

# File lib/recommendable/helpers/calculations.rb, line 103
def update_recommendations_for(user_id)
  user_id = user_id.to_s

  nearest_neighbors = Recommendable.config.nearest_neighbors || Recommendable.config.user_class.count
  Recommendable.config.ratable_classes.each do |klass|
    rated_sets = [
      Recommendable::Helpers::RedisKeyMapper.liked_set_for(klass, user_id),
      Recommendable::Helpers::RedisKeyMapper.disliked_set_for(klass, user_id),
      Recommendable::Helpers::RedisKeyMapper.hidden_set_for(klass, user_id),
      Recommendable::Helpers::RedisKeyMapper.bookmarked_set_for(klass, user_id)
    ]
    temp_set = Recommendable::Helpers::RedisKeyMapper.temp_set_for(Recommendable.config.user_class, user_id)
    similarity_set  = Recommendable::Helpers::RedisKeyMapper.similarity_set_for(user_id)
    recommended_set = Recommendable::Helpers::RedisKeyMapper.recommended_set_for(klass, user_id)
    most_similar_user_ids, least_similar_user_ids = Recommendable.redis.pipelined do
      Recommendable.redis.zrevrange(similarity_set, 0, nearest_neighbors - 1)
      Recommendable.redis.zrange(similarity_set, 0, nearest_neighbors - 1)
    end

    # Get likes from the most similar users
    sets_to_union = most_similar_user_ids.inject([]) do |sets, id|
      sets << Recommendable::Helpers::RedisKeyMapper.liked_set_for(klass, id)
    end

    # Get dislikes from the least similar users
    sets_to_union = least_similar_user_ids.inject(sets_to_union) do |sets, id|
      sets << Recommendable::Helpers::RedisKeyMapper.disliked_set_for(klass, id)
    end

    return if sets_to_union.empty?

    # SDIFF rated items so they aren't recommended
    Recommendable.redis.sunionstore(temp_set, *sets_to_union)
    item_ids = Recommendable.redis.sdiff(temp_set, *rated_sets)
    scores = item_ids.map { |id| [predict_for(user_id, klass, id), id] }
    Recommendable.redis.pipelined do
      scores.each do |s|
        Recommendable.redis.zadd(recommended_set, s[0], s[1])
      end
    end

    Recommendable.redis.del(temp_set)

    if number_recommendations = Recommendable.config.recommendations_to_store
      length = Recommendable.redis.zcard(recommended_set)
      Recommendable.redis.zremrangebyrank(recommended_set, 0, length - number_recommendations - 1)
    end
  end

  true
end
update_score_for(klass, id) click to toggle source
# File lib/recommendable/helpers/calculations.rb, line 195
def update_score_for(klass, id)
  score_set = Recommendable::Helpers::RedisKeyMapper.score_set_for(klass)
  liked_by_set = Recommendable::Helpers::RedisKeyMapper.liked_by_set_for(klass, id)
  disliked_by_set = Recommendable::Helpers::RedisKeyMapper.disliked_by_set_for(klass, id)
  liked_by_count, disliked_by_count = Recommendable.redis.pipelined do
    Recommendable.redis.scard(liked_by_set)
    Recommendable.redis.scard(disliked_by_set)
  end

  return 0.0 unless liked_by_count + disliked_by_count > 0

  z = 1.96
  n = liked_by_count + disliked_by_count
  phat = liked_by_count / n.to_f

  begin
    score = (phat + z*z/(2*n) - z * Math.sqrt((phat*(1-phat)+z*z/(4*n))/n))/(1+z*z/n)
  rescue Math::DomainError
    score = 0
  end

  Recommendable.redis.zadd(score_set, score, id)
  true
end
update_similarities_for(user_id) click to toggle source

Used internally to update the similarity values between this user and all other users. This is called by the background worker.

# File lib/recommendable/helpers/calculations.rb, line 55
def update_similarities_for(user_id)
  user_id = user_id.to_s # For comparison. Redis returns all set members as strings.
  similarity_set = Recommendable::Helpers::RedisKeyMapper.similarity_set_for(user_id)

  # Only calculate similarities for users who have rated the items that
  # this user has rated
  relevant_user_ids = Recommendable.config.ratable_classes.inject([]) do |memo, klass|
    liked_set = Recommendable::Helpers::RedisKeyMapper.liked_set_for(klass, user_id)
    disliked_set = Recommendable::Helpers::RedisKeyMapper.disliked_set_for(klass, user_id)

    item_ids = Recommendable.redis.sunion(liked_set, disliked_set)

    unless item_ids.empty?
      sets = item_ids.map do |id|
        liked_by_set = Recommendable::Helpers::RedisKeyMapper.liked_by_set_for(klass, id)
        disliked_by_set = Recommendable::Helpers::RedisKeyMapper.disliked_by_set_for(klass, id)

        [liked_by_set, disliked_by_set]
      end

      memo | Recommendable.redis.sunion(*sets.flatten)
    else
      memo
    end
  end

  similarity_values = relevant_user_ids.map { |id| similarity_between(user_id, id) }
  Recommendable.redis.pipelined do
    relevant_user_ids.zip(similarity_values).each do |id, similarity_value|
      next if id == user_id # Skip comparing with self.
      Recommendable.redis.zadd(similarity_set, similarity_value, id)
    end
  end

  if knn = Recommendable.config.nearest_neighbors
    length = Recommendable.redis.zcard(similarity_set)
    kfn = Recommendable.config.furthest_neighbors || 0

    Recommendable.redis.zremrangebyrank(similarity_set, kfn, length - knn - 1)
  end

  true
end