# 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個小時的人狂熱的程度還是不同,但是差異應該不大了
根據此想法,畫出來的曲線大致如下圖:

其間也嘗試過取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的概念,後續會改程式套用看看,不過目前沒有結果。