# KKTV 2nd solution ## 前言 我目前的工作是一位PM,但因為前份工作的原因,寫Python對我來說還ok。而Machine Learning相關的知識在研究所時期有接觸過,但距今也是八年之前的事情。 這一次是在參加人工智慧/資料科學年會時,從[Drake的分享](https://medium.com/kkstream/cultivating-data-culture-as-a-startup-slide-by-slide-f0f43b56b372)中得知有KKTV Data Game 17.11 以及 Kaggle 的存在。此外歷屆冠軍 (包含這屆也還是)的David,在關於上屆 KKTV Data Game 17.06的分享中提到「有時候簡單的做法其實很有用,只要將最後一週看的影集再上傳一次就有機會得名了」,因此想說試試看,說不定有機會得到KKTV免費看(?) 也因此我第一次的submission 與 Machine Learning 完全無關。 而後嘗試了Deep Learning 中的 CNN。當時還沒多少人參賽,剛上傳就得到了當下最高分(驚),也開啟了後續將近三個禮拜的時間投入。 這次的比賽對我也是一個持續學習的過程,從CNN/RNN的參數應怎麼設定,Loss Function應該用哪一個,optimizer、dropout該怎麼使用…等等。因此回想起來真的覺得蠻幸運的,初期就選擇了合適的Model,資料處理上沒有寫出太多bug,做的一些嘗試或多或少也發揮一些效用,讓分數持續有緩慢爬升,最後能維持在第二名的成績。 於此分享一些在過程中有嘗試的做法,有些(好像)有用,有些(好像)沒用,以及有些我認為會有用,但卻得到反效果的項目。希望有人可以幫忙解惑,也希望藉此拋磗引玉。 ## 使用的Learning Model 最後我是用RNN(LSTM)。而在我嘗試的過程中,分成三個階段: ### 簡單加法乘法 - 0.77361 (public) 將倒數四個禮拜的觀看時間依(0.1, 0.2, 0.3, 0.4)的比例加總。看起來分數還OK ### CNN - 0.88763 (public) 透過Python + Keras over Tensorflow 架了 CNN。有限的實驗內,得到最佳的組合是: Input Dimension: (6x28x1) - 在前6個禮拜,每個禮拜有28個Time Slot,每個Time Slot的 Duration - 看超過6個禮拜後,在實驗結果反而會變差。猜想因為沒有時間序列的關聯性,每個禮拜在training時都是等價的,因此更舊的紀錄可能會誤導training的結果。 Model: ``` sequence_input = Input(shape=(6, 28, 1)) x = sequence_input x = Conv2D(32, (3, 3), activation='relu')(x) x = Dropout(0.75)(x) x = Dense(64, activation='relu')(x) x = Dropout(0.75)(x) x = Flatten()(x) x = Dense(64, activation='relu')(x) preds = Dense(28, activation='sigmoid')(x) model = Model(sequence_input, preds) model.compile(loss='binary_crossentropy', optimizer='adam', metrics=['acc']) ``` ### RNN - 0.89316 (public) / 0.89236 (private) 轉換為 RNN 之後,即使在feature面完全不改,也可以得到比CNN更好的分數,並且time sequence 參考得越長,結果越好。因此全部32個禮拜的資料都可以派得上用場。(public: 0.88991) 而後續嘗試了不同的feature組合,以及LSTM的參數調整,得到最後的比賽分數。 Features: ``` feature_list = ("duration", # 28 "creation_time", # 1 "platform", # 3 "connection_type", # 2 "watch_ratio", # 1 "hot_titles", # 1 "new_episode_next_week" # 1 ) feature_num = 37 # 28+1+3+2+1+1+1 ``` Model: ``` x = sequence_input = Input(shape=(32, 37)) # 32 wk x feature_num x = LSTM(48, dropout=0.375)(x) x = Dense(128, activation='relu')(x) preds = Dense(28, activation='sigmoid')(x) model = Model(sequence_input, preds) model.compile(loss='binary_crossentropy', optimizer='adam', metrics=['acc']) ``` 整體來說Model還蠻簡單的,比較多時間花在Feature的處理。於下一個section說明。 ## Feature Engineering ### Duration 每個Time Slot中,花了多少時間在看影集。為了將Duration 轉換成0-1的數字,Conversion Function如下: ``` def durConv(n): m = n**(0.2) / (28800**0.2) return max(0, min(1, m)) ``` 其中28800是一個time slot最長的duration(從半夜1點一路不間斷看到早上9點...) 原本 0~28800 的 duration 轉換到 0~1 的range. 但在實際 duration 計算下來後,發現還是會有time slot duration是 3xxxx,一則是現實世界的log 本來就會有誤差,不然就是我的計算方式有誤差。所以output多加一層 0~1 的限制。 Duration的轉換最簡單的做法,是如果在一個Time Slot中有觀看紀錄,則該Slot=1;反之為0。這好處是和Label的方式一致,但也少了很多資訊。我的想法: - 在一個slot中看得越久的,代表他越狂熱,未來同一個slot看的機率也越大 - 只看10秒的人,和看1個小時的人應該有顯著的差異 - 看4個小時和看6個小時的人狂熱的程度還是不同,但是差異應該不大了 根據此想法,畫出來的曲線大致如下圖: ![](https://i.imgur.com/8GfLYNJ.png) 其間也嘗試過取Log, 效果沒差太多,實驗中最高分的是目前用的這個function. 相信有其他基於機率的轉換法,但我數學不好 orz 每個Time Slot的Duration是最重要的feature (當然),不過Duration Conversion 有或無對結果的影響也很顯著。 ### Creation Time 如果目前這個禮拜user的帳號已經被create了,則數值為1;反之為0。 在我的實驗中,這個 feature 似乎沒用,但似乎也無害所以就放著。 ### Platform User是透過什麼平台連上KKTV。是 "iOS", "Android", 或是 "Web". 透過 One-hot encoding 的方式呈現。 ``` if platform == "Web": ios, android, web = 0, 0, 1 elif platform == "Android": ios, android, web = 0, 1, 0 elif platform == "iOS": ios, android, web = 1, 0, 0 ``` ### Connection Type User透過什麼連結方式,我是簡單分為 "Cellular" 或是 "WiFi"。 ``` if "cellular" in internet_conn: cellular, wifi = 0, 1 elif "offline" in internet_conn: cellular, wifi = 0, 0 # should ignore this one else: # run with wifi cellular, wifi = 1, 0 ``` 想像中這個feature會有用,因為主要透過WiFi看影片的user, 在場域、時間點應是有侷限性的。 但實驗結果來看,"Platform" 和 "Connection Type" 二擇一,和兩個feature都放的結果是差不多的。猜想是Platform中的Web等同於Wifi, 而 Android, iOS多數是Cellular. 理論上兩個都放的資訊會更完整,但是feature 複雜度也變高,training的難度也跟著提高。 也可能是我做壞了,因為一個禮拜只取一個值當代表,可能很不準。 ### Watch Ratio 這個feature想表達的是,User對於目前在看的Title的投入程度。 概念: - 如果目前只看了前面幾集,user可能只是隨意看看而已 - 如果看了超過一定集數,應該是完全投入了。那下禮拜繼續接著看的機會更高吧 - 但是,如果影集看完了,下禮拜也許會想休息? - 此外,看多少集才能夠算很投入呢? 影集的總長度應該也要考慮? 最終Function如下: ``` viewed_episodes = user_viewed_matrix[(wk, title_id)] viewed_num = len(viewed_episodes) if cur_episode >= last_episode: watch_ratio = 0.0 else: watch_ratio = min(1, viewed_num / (6 + math.sqrt(last_episode))) ``` 其中 viewed_episodes是累積的結果 - 到了這個禮拜,這個影集已經看過哪些集數; 至於那個 6 + sqrt(last_episode) 是比賽最後一天福至心靈寫上去的,很可能是個 bad idea. 自認為這個feature也扮演蠻重要角色。 ### Hot Titles 概念: - 如果看的是目前最熱門,最多人看的影集,會不會有加成的效果? 因此統計了每個禮拜總觀看時間最高的影集排行榜,取前 N 名,如果使用者有看這些影集的話,看的時間會加成進去。 ...一開始取前10名,發現對結果是反效果;但最後只取前3名時就變得有用了 (Magic!) 最後的code長這樣: ``` f_offset = feature_offset["hot_titles"] for (wk, slot), value in user_data.items(): if from_wk <= wk < 32: title_id = value[6] hot_series_list = [74, 79, 77] # magic! if title_id in hot_series_list: arr[wk-from_wk, f_offset] += int(value[0]) # convert the duration to 0-1 for wk in range(wk_num): arr[wk, f_offset] = durConv(arr[wk, f_offset]) ``` 其中 [74, 79, 77] 是8/7-8/14這個禮拜最熱門的影集前三名。 durConv function 如前面 Duration 所述。 結果上來說,這feature有用。但我也不知道為什麼有用 lol 也許有人會有更好更合理的使用方式。 ### New Episode in Next Week 想法: - 有些影集還是持續在更新。如果知道目前在看的影集,下個禮拜有新的episode出來,那應該會影響打開KKTV的機率? 這是一個未來人的概念,透過training data可以知道8/14-21有哪些影集會更新,因此可以將這個資訊回放到前一個禮拜當feature. 前述的 "Watch Ratio" 的概念是,如果該影集已經看完了,那就沒了。這邊相反的,如果這個影集是on-going: - 看完目前最後一集的人,應該迫不急待要看新release的一集了吧? - 而這個期待的效應隨著看的集數減少遞減。 轉換成 Function 大略如下: ``` if has_new_episode_next_week: if cur_episode == last_episode: # setup the flag as the next week will highly possible to watch score = 1.0 else: # decaded by the viewed numbers. score = (viewed_num / last_episode)**2 ``` 這個feature對結果的影響很微妙,似乎有用,但至少無害。 ## Model Training ### 比賽期間的做法 - 先以80%的data做為 training data, 20% 為 testing data 來train model - 挑選其中 testing data AUC 最高的 model來做為 fine tune的對象 - fine tune的過程中,95% 做為 training data, 5%做為testing data,並且調低 learning rate 以避免飄太遠反而結果變差 ``` model.compile(loss='binary_crossentropy', optimizer=optimizers.Adam(lr=1e-4), metrics=['acc']) ``` fine tune 的結果,大約是 0.89270 -> 0.89316 其中個人的心得是,training data 越多準確率越高。因此透過95%的data來 training 可以得到比80%更好的結果。但是當testing data少到只剩5%時會變得浮動得很嚴重,反而很難找到在 training 過程中,理論上最好那個 model,一切就變得非常運氣。 因此,透過兩階段的training 還是有些許幫助。 ## 其他曾經嘗試(但失敗)的 ### 將Time Slot 切得更細 這是我有些困惑的地方。在觀察資料的時候會發現,有些人觀看的行為類似這樣:晚上某個時間點開始看,然後有些時候看到12:xx, 有些時候會跨到 01:xx。但是當跨到01:xx的時候,他就也會被算到下一個01:00 - 09:00 的time slot了。 所以我想像的是,如果在取feature時將time slot再對切,相當於資料遺失的量較少,應該可以得到更好的結果才對。 但實驗結果中,無論是CNN或RNN都只會變差,切越細越差。RNN好像可以理解,因為在feature之間彼此並沒有辦法呈現Time Slot的關聯性,因此越多feature會變得越難training;但是CNN的結果我比較難想像,經過Conv2D的卷積之後,相鄰的Time Slot 應該可以呈現出關聯? 總之,照著原本的 8-8-4-4 的time slot 切法,在我實驗中得到最好的結果。 有沒有人是別的切法但結果更好的? ### RNN中以"day"為單位 原本的time sequence是以week為單位。如果換成是以day為單位,然後每天有4個time slot呢? 實驗結果是毫無幫助,而且training時間會爆炸。 ### 移除離群值 (remove outliers) 想法:如果在本來的training data中,有user的行為模式特別怪異,將其移除是否能夠得到更好的結果? 做法:先train一個 general 的 model, 然後將所有training data中的 sample都丟回去做predict然後算MSE, 將MSE最高的N個移除,然後再重新來train model. 結果:無法判斷有沒有用,差異看不出來,比賽最後的model中沒有用到。 ## 其他在Data中的發現 Log是現實世界的資料,現實難免會有不完美的地方。其中兩個我認為有bug的部分: ### Session ID 理論上,Session ID 反應的是 "User使用某個Device, 在某個時間區間內連續使用的紀錄"。因此,透過Session ID 來做Time Usage (Duration)的計算應該是合理的做法。然而,在 https://www.kaggle.com/c/kktv-data-game-1711/discussion/44050 的討論之中,ChiakiChang 發現同一個session id會跨好幾天才送出 (導致label中他會有一連串的1) 而我在training data中也有發現類似的現象,若透過session id來算time usage, 會得到某些session的duration 會破十萬秒以上。 最後我的做法是: - 忽略掉session id, 以每筆單一的log做為一個session, 會得到較好的結果。 - 在training data 中的label部分,我是自己重新以training data 8/14-8/21的資料重算的,可以避掉少量因為session id 的影響。(如上面那篇討論上示) ### Action Type - Program Stopped 在觀察資料時,發現兩個很有趣的Action Type: - program stopped (iOS) - program stopped or enlarged-reduced (Android) 觀察中發現這兩個type的特性: - 只在8/9之後出現 (應該程式新改版吧) - 這action出現的時間點,和使用者本來的使用習慣似乎無關 - 大部分出現的時間都很短 - 其中Title ID 和 Episode 會無序亂跳 因此懷疑這兩個action應該不是正常使用者行為,盡一步做個統計: 在data001.csv中,這兩個action 發生的次數: | Date |01:00-09:00 | 09:00-17:00 | 17:00-21:00 | 21:00-01:00 | | -------- | -------- | -------- | --------| ---------| | 8/10(四)| 132 | 154 | 30 | 17 | | 8/11(五)| 52 | 199 | 56 | 12 | | 8/12(六)| 74 | 82 | 39 | 27 | | 8/13(日)| 101 | 112 | 20 | 8 | | 8/14(一)| 58 | 86 | 13 | 0 | | 8/15(二)| 38 | 80 | 24 | 10 | | SUM | 455 | 713 | 182 | 74 | 其中細節去看的話,01:00-09:00 較多在清晨到 9:00間,而17:00-21:00較多是在17:00~19:00間。 我的理解是,越少人看影集的時段,這個action被trigger的機率越高。 因此,它出現的情境很可能是: 「當手機被閒置,原本存在背景的程式因某些原因,被系統關閉。」 據此推測,我做了一些嘗試: - 將這個 action 從 training / testing data 中移除,並且重新產生 training labels - 能大幅改善在training 過程中, testing set 的 loss 以及 AUC。這結果應該證實了這個action type與user behavior無關,甚至可能相反。 - 但是對於競賽的submission 則完全無益,因為這個action type產生的效應本身被包含在答案的label中,在training 過程中將其影響拿掉後,反而會更偏離答案。 - 僅將其從 training / testing data 中移除,但保留其在 label中的影響 - 基於 "因為他是隨機的發生的,會在8/9-8/14 區間發生的,於 8/14-8/21區間發生的無關" 這個想法,應對training 仍有些幫助。 - 結果是沒有幫助。推測是,因為在8/9-8/14的這個action type的影響,使得 09:00-17:00 這個區間機率整體性的提高了,而在8/14-8/21仍舊有這個特性。因此預測結果還是會較接近答案。 結果來看,任何想對這個action type做的操作在競賽上都是無效的,畢竟他是答案的一部分;但是回歸現實應用,或許是KKTV可以留心的地方。 ## 賽後的嘗試 ### RNN + Ensemble (Bagging) Late Submission: 0.89379 (public) / 0.89295 (private) 賽後從[主辦單位的Benchmark](https://medium.com/kkstream/kktv-data-game-17-11-benchmarks-97fffc46fa23) 知道有Ensemble的做法,因此將Training-Testing Data 切分成五組,分別train成一個model。最後將各自的預測結果取平均。確實對於結果有幫助! ### RNN + Stacking (on-going) 冠軍[David的分享](https://medium.com/@kstseng/kktv-data-game-17-11-1st-place-solution-96b3d62c594c)中說到了有Stacking的做法,並且提供了很好的參考資料: [Kaggle Ensembling Guideline](https://mlwave.com/kaggle-ensembling-guide/) 很有趣,目前還在理解Stacking的概念,後續會改程式套用看看,不過目前沒有結果。