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.support.annotation.Nullable; 20 import android.support.annotation.VisibleForTesting; 21 import android.text.TextUtils; 22 import com.android.tv.data.Program; 23 import java.text.BreakIterator; 24 import java.util.ArrayList; 25 import java.util.Calendar; 26 import java.util.List; 27 import java.util.concurrent.TimeUnit; 28 29 public class RoutineWatchEvaluator extends Recommender.Evaluator { 30 // TODO: test and refine constant values in WatchedProgramRecommender in order to 31 // improve the performance of this recommender. 32 private static final double REQUIRED_MIN_SCORE = 0.15; 33 @VisibleForTesting static final double MULTIPLIER_FOR_UNMATCHED_DAY_OF_WEEK = 0.7; 34 private static final double TITLE_MATCH_WEIGHT = 0.5; 35 private static final double TIME_MATCH_WEIGHT = 1 - TITLE_MATCH_WEIGHT; 36 private static final long DIFF_MS_TOLERANCE_FOR_OLD_PROGRAM = TimeUnit.DAYS.toMillis(14); 37 private static final long MAX_DIFF_MS_FOR_OLD_PROGRAM = TimeUnit.DAYS.toMillis(56); 38 39 @Override evaluateChannel(long channelId)40 public double evaluateChannel(long channelId) { 41 ChannelRecord cr = getRecommender().getChannelRecord(channelId); 42 if (cr == null) { 43 return NOT_RECOMMENDED; 44 } 45 46 Program currentProgram = cr.getCurrentProgram(); 47 if (currentProgram == null) { 48 return NOT_RECOMMENDED; 49 } 50 51 WatchedProgram[] watchHistory = cr.getWatchHistory(); 52 if (watchHistory.length < 1) { 53 return NOT_RECOMMENDED; 54 } 55 56 Program watchedProgram = watchHistory[watchHistory.length - 1].getProgram(); 57 long startTimeDiffMsWithCurrentProgram = 58 currentProgram.getStartTimeUtcMillis() - watchedProgram.getStartTimeUtcMillis(); 59 if (startTimeDiffMsWithCurrentProgram >= MAX_DIFF_MS_FOR_OLD_PROGRAM) { 60 return NOT_RECOMMENDED; 61 } 62 63 double maxScore = NOT_RECOMMENDED; 64 long watchedDurationMs = watchHistory[watchHistory.length - 1].getWatchedDurationMs(); 65 for (int i = watchHistory.length - 2; i >= 0; --i) { 66 if (watchedProgram.getStartTimeUtcMillis() 67 == watchHistory[i].getProgram().getStartTimeUtcMillis()) { 68 watchedDurationMs += watchHistory[i].getWatchedDurationMs(); 69 } else { 70 double score = 71 calculateRoutineWatchScore( 72 currentProgram, watchedProgram, watchedDurationMs); 73 if (score >= REQUIRED_MIN_SCORE && score > maxScore) { 74 maxScore = score; 75 } 76 watchedProgram = watchHistory[i].getProgram(); 77 watchedDurationMs = watchHistory[i].getWatchedDurationMs(); 78 startTimeDiffMsWithCurrentProgram = 79 currentProgram.getStartTimeUtcMillis() 80 - watchedProgram.getStartTimeUtcMillis(); 81 if (startTimeDiffMsWithCurrentProgram >= MAX_DIFF_MS_FOR_OLD_PROGRAM) { 82 return maxScore; 83 } 84 } 85 } 86 double score = 87 calculateRoutineWatchScore(currentProgram, watchedProgram, watchedDurationMs); 88 if (score >= REQUIRED_MIN_SCORE && score > maxScore) { 89 maxScore = score; 90 } 91 return maxScore; 92 } 93 calculateRoutineWatchScore( Program currentProgram, Program watchedProgram, long watchedDurationMs)94 private static double calculateRoutineWatchScore( 95 Program currentProgram, Program watchedProgram, long watchedDurationMs) { 96 double timeMatchScore = calculateTimeMatchScore(currentProgram, watchedProgram); 97 double titleMatchScore = 98 calculateTitleMatchScore(currentProgram.getTitle(), watchedProgram.getTitle()); 99 double watchDurationScore = calculateWatchDurationScore(watchedProgram, watchedDurationMs); 100 long diffMs = 101 currentProgram.getStartTimeUtcMillis() - watchedProgram.getStartTimeUtcMillis(); 102 double multiplierForOldProgram = 103 (diffMs < MAX_DIFF_MS_FOR_OLD_PROGRAM) 104 ? 1.0 105 - (double) Math.max(diffMs - DIFF_MS_TOLERANCE_FOR_OLD_PROGRAM, 0) 106 / (MAX_DIFF_MS_FOR_OLD_PROGRAM 107 - DIFF_MS_TOLERANCE_FOR_OLD_PROGRAM) 108 : 0.0; 109 return (titleMatchScore * TITLE_MATCH_WEIGHT + timeMatchScore * TIME_MATCH_WEIGHT) 110 * watchDurationScore 111 * multiplierForOldProgram; 112 } 113 114 @VisibleForTesting calculateTitleMatchScore(@ullable String title1, @Nullable String title2)115 static double calculateTitleMatchScore(@Nullable String title1, @Nullable String title2) { 116 if (TextUtils.isEmpty(title1) || TextUtils.isEmpty(title2)) { 117 return 0; 118 } 119 List<String> wordList1 = splitTextToWords(title1); 120 List<String> wordList2 = splitTextToWords(title2); 121 if (wordList1.isEmpty() || wordList2.isEmpty()) { 122 return 0; 123 } 124 int maxMatchedWordSeqLen = calculateMaximumMatchedWordSequenceLength(wordList1, wordList2); 125 126 // F-measure score 127 double precision = (double) maxMatchedWordSeqLen / wordList1.size(); 128 double recall = (double) maxMatchedWordSeqLen / wordList2.size(); 129 return 2.0 * precision * recall / (precision + recall); 130 } 131 132 @VisibleForTesting calculateMaximumMatchedWordSequenceLength( List<String> toSearchWords, List<String> toMatchWords)133 static int calculateMaximumMatchedWordSequenceLength( 134 List<String> toSearchWords, List<String> toMatchWords) { 135 int[] matchedWordSeqLen = new int[toMatchWords.size()]; 136 int maxMatchedWordSeqLen = 0; 137 for (String word : toSearchWords) { 138 for (int j = toMatchWords.size() - 1; j >= 0; --j) { 139 if (word.equals(toMatchWords.get(j))) { 140 matchedWordSeqLen[j] = j > 0 ? matchedWordSeqLen[j - 1] + 1 : 1; 141 } else { 142 maxMatchedWordSeqLen = Math.max(maxMatchedWordSeqLen, matchedWordSeqLen[j]); 143 matchedWordSeqLen[j] = 0; 144 } 145 } 146 } 147 for (int len : matchedWordSeqLen) { 148 maxMatchedWordSeqLen = Math.max(maxMatchedWordSeqLen, len); 149 } 150 151 return maxMatchedWordSeqLen; 152 } 153 calculateTimeMatchScore(Program p1, Program p2)154 private static double calculateTimeMatchScore(Program p1, Program p2) { 155 ProgramTime t1 = ProgramTime.createFromProgram(p1); 156 ProgramTime t2 = ProgramTime.createFromProgram(p2); 157 158 double dupTimeScore = calculateOverlappedIntervalScore(t1, t2); 159 160 // F-measure score 161 double precision = dupTimeScore / (t1.endTimeOfDayInSec - t1.startTimeOfDayInSec); 162 double recall = dupTimeScore / (t2.endTimeOfDayInSec - t2.startTimeOfDayInSec); 163 return 2.0 * precision * recall / (precision + recall); 164 } 165 166 @VisibleForTesting calculateOverlappedIntervalScore(ProgramTime t1, ProgramTime t2)167 static double calculateOverlappedIntervalScore(ProgramTime t1, ProgramTime t2) { 168 if (t1.dayChanged && !t2.dayChanged) { 169 // Swap two values. 170 return calculateOverlappedIntervalScore(t2, t1); 171 } 172 173 boolean sameDay = false; 174 // Handle cases like (00:00 - 02:00) - (01:00 - 03:00) or (22:00 - 25:00) - (23:00 - 26:00). 175 double score = 176 Math.max( 177 0, 178 Math.min(t1.endTimeOfDayInSec, t2.endTimeOfDayInSec) 179 - Math.max(t1.startTimeOfDayInSec, t2.startTimeOfDayInSec)); 180 if (score > 0) { 181 sameDay = (t1.weekDay == t2.weekDay); 182 } else if (t1.dayChanged != t2.dayChanged) { 183 // To handle cases like t1 : (00:00 - 01:00) and t2 : (23:00 - 25:00). 184 score = 185 Math.max( 186 0, 187 Math.min(t1.endTimeOfDayInSec, t2.endTimeOfDayInSec - 24 * 60 * 60) 188 - t1.startTimeOfDayInSec); 189 // Same day if next day of t2's start day equals to t1's start day. (1 <= weekDay <= 7) 190 sameDay = (t1.weekDay == ((t2.weekDay % 7) + 1)); 191 } 192 193 if (!sameDay) { 194 score *= MULTIPLIER_FOR_UNMATCHED_DAY_OF_WEEK; 195 } 196 return score; 197 } 198 calculateWatchDurationScore(Program program, long durationMs)199 private static double calculateWatchDurationScore(Program program, long durationMs) { 200 return (double) durationMs 201 / (program.getEndTimeUtcMillis() - program.getStartTimeUtcMillis()); 202 } 203 204 @VisibleForTesting getTimeOfDayInSec(Calendar time)205 static int getTimeOfDayInSec(Calendar time) { 206 return time.get(Calendar.HOUR_OF_DAY) * 60 * 60 207 + time.get(Calendar.MINUTE) * 60 208 + time.get(Calendar.SECOND); 209 } 210 211 @VisibleForTesting splitTextToWords(String text)212 static List<String> splitTextToWords(String text) { 213 List<String> wordList = new ArrayList<>(); 214 BreakIterator boundary = BreakIterator.getWordInstance(); 215 boundary.setText(text); 216 int start = boundary.first(); 217 for (int end = boundary.next(); 218 end != BreakIterator.DONE; 219 start = end, end = boundary.next()) { 220 String word = text.substring(start, end); 221 if (Character.isLetterOrDigit(word.charAt(0))) { 222 wordList.add(word); 223 } 224 } 225 return wordList; 226 } 227 228 @VisibleForTesting 229 static class ProgramTime { 230 final int startTimeOfDayInSec; 231 final int endTimeOfDayInSec; 232 final int weekDay; 233 final boolean dayChanged; 234 createFromProgram(Program p)235 public static ProgramTime createFromProgram(Program p) { 236 Calendar time = Calendar.getInstance(); 237 238 time.setTimeInMillis(p.getStartTimeUtcMillis()); 239 int weekDay = time.get(Calendar.DAY_OF_WEEK); 240 int startTimeOfDayInSec = getTimeOfDayInSec(time); 241 242 time.setTimeInMillis(p.getEndTimeUtcMillis()); 243 boolean dayChanged = (weekDay != time.get(Calendar.DAY_OF_WEEK)); 244 // Set maximum program duration time to 12 hours. 245 int endTimeOfDayInSec = 246 startTimeOfDayInSec 247 + (int) 248 Math.min( 249 p.getEndTimeUtcMillis() 250 - p.getStartTimeUtcMillis(), 251 TimeUnit.HOURS.toMillis(12)) 252 / 1000; 253 254 return new ProgramTime(startTimeOfDayInSec, endTimeOfDayInSec, weekDay, dayChanged); 255 } 256 ProgramTime( int startTimeOfDayInSec, int endTimeOfDayInSec, int weekDay, boolean dayChanged)257 private ProgramTime( 258 int startTimeOfDayInSec, int endTimeOfDayInSec, int weekDay, boolean dayChanged) { 259 this.startTimeOfDayInSec = startTimeOfDayInSec; 260 this.endTimeOfDayInSec = endTimeOfDayInSec; 261 this.weekDay = weekDay; 262 this.dayChanged = dayChanged; 263 } 264 } 265 } 266