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