Home | History | Annotate | Download | only in recommendation
      1 /*
      2  * Copyright (C) 2015 The Android Open Source Project
      3  *
      4  * Licensed under the Apache License, Version 2.0 (the "License");
      5  * you may not use this file except in compliance with the License.
      6  * You may obtain a copy of the License at
      7  *
      8  *      http://www.apache.org/licenses/LICENSE-2.0
      9  *
     10  * Unless required by applicable law or agreed to in writing, software
     11  * distributed under the License is distributed on an "AS IS" BASIS,
     12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
     13  * See the License for the specific language governing permissions and
     14  * limitations under the License.
     15  */
     16 
     17 package com.android.tv.recommendation;
     18 
     19 import android.content.Context;
     20 import android.support.annotation.VisibleForTesting;
     21 import android.util.Log;
     22 import android.util.Pair;
     23 
     24 import com.android.tv.data.Channel;
     25 
     26 import java.util.ArrayList;
     27 import java.util.Collection;
     28 import java.util.Collections;
     29 import java.util.Comparator;
     30 import java.util.HashMap;
     31 import java.util.List;
     32 import java.util.Map;
     33 import java.util.concurrent.TimeUnit;
     34 
     35 public class Recommender implements RecommendationDataManager.Listener {
     36     private static final String TAG = "Recommender";
     37 
     38     @VisibleForTesting
     39     static final String INVALID_CHANNEL_SORT_KEY = "INVALID";
     40     private static final long MINIMUM_RECOMMENDATION_UPDATE_PERIOD = TimeUnit.MINUTES.toMillis(5);
     41     private static final Comparator<Pair<Channel, Double>> mChannelScoreComparator =
     42             new Comparator<Pair<Channel, Double>>() {
     43                 @Override
     44                 public int compare(Pair<Channel, Double> lhs, Pair<Channel, Double> rhs) {
     45                     // Sort the scores with descending order.
     46                     return rhs.second.compareTo(lhs.second);
     47                 }
     48             };
     49 
     50     private final List<EvaluatorWrapper> mEvaluators = new ArrayList<>();
     51     private final boolean mIncludeRecommendedOnly;
     52     private final Listener mListener;
     53 
     54     private final Map<Long, String> mChannelSortKey = new HashMap<>();
     55     private final RecommendationDataManager mDataManager;
     56     private List<Channel> mPreviousRecommendedChannels = new ArrayList<>();
     57     private long mLastRecommendationUpdatedTimeUtcMillis;
     58     private boolean mChannelRecordLoaded;
     59 
     60     /**
     61      * Create a recommender object.
     62      *
     63      * @param includeRecommendedOnly true to include only recommended results, or false.
     64      */
     65     public Recommender(Context context, Listener listener, boolean includeRecommendedOnly) {
     66         mListener = listener;
     67         mIncludeRecommendedOnly = includeRecommendedOnly;
     68         mDataManager = RecommendationDataManager.acquireManager(context, this);
     69     }
     70 
     71     @VisibleForTesting
     72     Recommender(Listener listener, boolean includeRecommendedOnly,
     73             RecommendationDataManager dataManager) {
     74         mListener = listener;
     75         mIncludeRecommendedOnly = includeRecommendedOnly;
     76         mDataManager = dataManager;
     77     }
     78 
     79     public boolean isReady() {
     80         return mChannelRecordLoaded;
     81     }
     82 
     83     public void release() {
     84         mDataManager.release(this);
     85     }
     86 
     87     public void registerEvaluator(Evaluator evaluator) {
     88         registerEvaluator(evaluator,
     89                 EvaluatorWrapper.DEFAULT_BASE_SCORE, EvaluatorWrapper.DEFAULT_WEIGHT);
     90     }
     91 
     92     /**
     93      * Register the evaluator used in recommendation.
     94      *
     95      * The range of evaluated scores by this evaluator will be between {@code baseScore} and
     96      * {@code baseScore} + {@code weight} (inclusive).
     97 
     98      * @param evaluator The evaluator to register inside this recommender.
     99      * @param baseScore Base(Minimum) score of the score evaluated by {@code evaluator}.
    100      * @param weight Weight value to rearrange the score evaluated by {@code evaluator}.
    101      */
    102     public void registerEvaluator(Evaluator evaluator, double baseScore, double weight) {
    103         mEvaluators.add(new EvaluatorWrapper(this, evaluator, baseScore, weight));
    104     }
    105 
    106     public List<Channel> recommendChannels() {
    107         return recommendChannels(mDataManager.getChannelRecordCount());
    108     }
    109 
    110     /**
    111      * Return the channel list of recommendation up to {@code n} or the number of channels.
    112      * During the evaluation, this method updates the channel sort key of recommended channels.
    113      *
    114      * @param size The number of channels that might be recommended.
    115      * @return Top {@code size} channels recommended sorted by score in descending order. If
    116      *         {@code size} is bigger than the number of channels, the number of results could
    117      *         be less than {@code size}.
    118      */
    119     public List<Channel> recommendChannels(int size) {
    120         List<Pair<Channel, Double>> records = new ArrayList<>();
    121         Collection<ChannelRecord> channelRecordList = mDataManager.getChannelRecords();
    122         for (ChannelRecord cr : channelRecordList) {
    123             double maxScore = Evaluator.NOT_RECOMMENDED;
    124             for (EvaluatorWrapper evaluator : mEvaluators) {
    125                 double score = evaluator.getScaledEvaluatorScore(cr.getChannel().getId());
    126                 if (score > maxScore) {
    127                     maxScore = score;
    128                 }
    129             }
    130             if (!mIncludeRecommendedOnly || maxScore != Evaluator.NOT_RECOMMENDED) {
    131                 records.add(new Pair<>(cr.getChannel(), maxScore));
    132             }
    133         }
    134         if (size > records.size()) {
    135             size = records.size();
    136         }
    137         Collections.sort(records, mChannelScoreComparator);
    138 
    139         List<Channel> results = new ArrayList<>();
    140 
    141         mChannelSortKey.clear();
    142         String sortKeyFormat = "%0" + String.valueOf(size).length() + "d";
    143         for (int i = 0; i < size; ++i) {
    144             // Channel with smaller sort key has higher priority.
    145             mChannelSortKey.put(records.get(i).first.getId(), String.format(sortKeyFormat, i));
    146             results.add(records.get(i).first);
    147         }
    148         return results;
    149     }
    150 
    151     /**
    152      * Returns the {@link Channel} object for a given channel ID from the channel pool that this
    153      * recommendation engine has.
    154      *
    155      * @param channelId The channel ID to retrieve the {@link Channel} object for.
    156      * @return the {@link Channel} object for the given channel ID, {@code null} if such a channel
    157      *         is not found.
    158      */
    159     public Channel getChannel(long channelId) {
    160         ChannelRecord record = mDataManager.getChannelRecord(channelId);
    161         return record == null ? null : record.getChannel();
    162     }
    163 
    164     /**
    165      * Returns the {@link ChannelRecord} object for a given channel ID.
    166      *
    167      * @param channelId The channel ID to receive the {@link ChannelRecord} object for.
    168      * @return the {@link ChannelRecord} object for the given channel ID.
    169      */
    170     public ChannelRecord getChannelRecord(long channelId) {
    171         return mDataManager.getChannelRecord(channelId);
    172     }
    173 
    174     /**
    175      * Returns the sort key of a given channel Id. Sort key is determined in
    176      * {@link #recommendChannels()} and getChannelSortKey must be called after that.
    177      *
    178      * If getChannelSortKey was called before evaluating the channels or trying to get sort key
    179      * of non-recommended channel, it returns {@link #INVALID_CHANNEL_SORT_KEY}.
    180      */
    181     public String getChannelSortKey(long channelId) {
    182         String key = mChannelSortKey.get(channelId);
    183         return key == null ? INVALID_CHANNEL_SORT_KEY : key;
    184     }
    185 
    186     @Override
    187     public void onChannelRecordLoaded() {
    188         mChannelRecordLoaded = true;
    189         mListener.onRecommenderReady();
    190         List<ChannelRecord> channels = new ArrayList<>(mDataManager.getChannelRecords());
    191         for (EvaluatorWrapper evaluator : mEvaluators) {
    192             evaluator.onChannelListChanged(Collections.unmodifiableList(channels));
    193         }
    194     }
    195 
    196     @Override
    197     public void onNewWatchLog(ChannelRecord channelRecord) {
    198         for (EvaluatorWrapper evaluator : mEvaluators) {
    199             evaluator.onNewWatchLog(channelRecord);
    200         }
    201         checkRecommendationChanged();
    202     }
    203 
    204     @Override
    205     public void onChannelRecordChanged() {
    206         if (mChannelRecordLoaded) {
    207             List<ChannelRecord> channels = new ArrayList<>(mDataManager.getChannelRecords());
    208             for (EvaluatorWrapper evaluator : mEvaluators) {
    209                 evaluator.onChannelListChanged(Collections.unmodifiableList(channels));
    210             }
    211         }
    212         checkRecommendationChanged();
    213     }
    214 
    215     private void checkRecommendationChanged() {
    216         long currentTimeUtcMillis = System.currentTimeMillis();
    217         if (currentTimeUtcMillis - mLastRecommendationUpdatedTimeUtcMillis
    218                 < MINIMUM_RECOMMENDATION_UPDATE_PERIOD) {
    219             return;
    220         }
    221         mLastRecommendationUpdatedTimeUtcMillis = currentTimeUtcMillis;
    222         List<Channel> recommendedChannels = recommendChannels();
    223         if (!recommendedChannels.equals(mPreviousRecommendedChannels)) {
    224             mPreviousRecommendedChannels = recommendedChannels;
    225             mListener.onRecommendationChanged();
    226         }
    227     }
    228 
    229     @VisibleForTesting
    230     void setLastRecommendationUpdatedTimeUtcMs(long newUpdatedTimeMs) {
    231         mLastRecommendationUpdatedTimeUtcMillis = newUpdatedTimeMs;
    232     }
    233 
    234     public static abstract class Evaluator {
    235         public static final double NOT_RECOMMENDED = -1.0;
    236         private Recommender mRecommender;
    237 
    238         protected Evaluator() {}
    239 
    240         protected void onChannelRecordListChanged(List<ChannelRecord> channelRecords) {
    241         }
    242 
    243         /**
    244          * This will be called when a new watch log comes into WatchedPrograms table.
    245          *
    246          * @param channelRecord The channel record corresponds to the new watch log.
    247          */
    248         protected void onNewWatchLog(ChannelRecord channelRecord) {
    249         }
    250 
    251         /**
    252          * The implementation should return the recommendation score for the given channel ID.
    253          * The return value should be in the range of [0.0, 1.0] or NOT_RECOMMENDED for denoting
    254          * that it gives up to calculate the score for the channel.
    255          *
    256          * @param channelId The channel ID which will be evaluated by this recommender.
    257          * @return The recommendation score
    258          */
    259         protected abstract double evaluateChannel(final long channelId);
    260 
    261         protected void setRecommender(Recommender recommender) {
    262             mRecommender = recommender;
    263         }
    264 
    265         protected Recommender getRecommender() {
    266             return mRecommender;
    267         }
    268     }
    269 
    270     private static class EvaluatorWrapper {
    271         private static final double DEFAULT_BASE_SCORE = 0.0;
    272         private static final double DEFAULT_WEIGHT = 1.0;
    273 
    274         private final Evaluator mEvaluator;
    275         // The minimum score of the Recommender unless it gives up to provide the score.
    276         private final double mBaseScore;
    277         // The weight of the recommender. The return-value of getScore() will be multiplied by
    278         // this value.
    279         private final double mWeight;
    280 
    281         public EvaluatorWrapper(Recommender recommender, Evaluator evaluator,
    282                 double baseScore, double weight) {
    283             mEvaluator = evaluator;
    284             evaluator.setRecommender(recommender);
    285             mBaseScore = baseScore;
    286             mWeight = weight;
    287         }
    288 
    289         /**
    290          * This returns the scaled score for the given channel ID based on the returned value
    291          * of evaluateChannel().
    292          *
    293          * @param channelId The channel ID which will be evaluated by the recommender.
    294          * @return Returns the scaled score (mBaseScore + score * mWeight) when evaluateChannel() is
    295          *         in the range of [0.0, 1.0]. If evaluateChannel() returns NOT_RECOMMENDED or any
    296          *         negative numbers, it returns NOT_RECOMMENDED. If calculateScore() returns more
    297          *         than 1.0, it returns (mBaseScore + mWeight).
    298          */
    299         private double getScaledEvaluatorScore(long channelId) {
    300             double score = mEvaluator.evaluateChannel(channelId);
    301             if (score < 0.0) {
    302                 if (score != Evaluator.NOT_RECOMMENDED) {
    303                     Log.w(TAG, "Unexpected score (" + score + ") from the recommender"
    304                             + mEvaluator);
    305                 }
    306                 // If the recommender gives up to calculate the score, return 0.0
    307                 return Evaluator.NOT_RECOMMENDED;
    308             } else if (score > 1.0) {
    309                 Log.w(TAG, "Unexpected score (" + score + ") from the recommender"
    310                         + mEvaluator);
    311                 score = 1.0;
    312             }
    313             return mBaseScore + score * mWeight;
    314         }
    315 
    316         public void onNewWatchLog(ChannelRecord channelRecord) {
    317             mEvaluator.onNewWatchLog(channelRecord);
    318         }
    319 
    320         public void onChannelListChanged(List<ChannelRecord> channelRecords) {
    321             mEvaluator.onChannelRecordListChanged(channelRecords);
    322         }
    323     }
    324 
    325     public interface Listener {
    326         /**
    327          * Called after channel record map is loaded.
    328          */
    329         void onRecommenderReady();
    330 
    331         /**
    332          * Called when the recommendation changes.
    333          */
    334         void onRecommendationChanged();
    335     }
    336 }
    337