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