很久以前就有想過使用深度學習模型來對dota2的對局數據進行建模分析,以便在英雄選擇,出裝方面有所指導,幫助自己提升天梯等級,但苦於找不到數據源,該計划擱置了很長時間。直到前些日子,看到社區有老哥提到說OpenDota網站(https://www.opendota.com/)提供有一整套的接口可以獲取dota數據。通過瀏覽該網站,發現數據比較齊全,滿足建模分析的需求,那就二話不說,開始干活。
這篇文章分為兩大部分,第一部分為數據獲取,第二部分為建模預測。
Part 1,數據獲取
1.接口分析
dota2的游戲對局數據通過OpenDota所提供的API進行獲取,通過閱讀API文檔(https://docs.opendota.com/#),發現幾個比較有意思/有用的接口:
①請求單場比賽

上面就是一條聊天記錄示例,在這局游戲的第7條聊天記錄中,玩家“高高興興把家回”發送了消息:”1指1個小朋友”。
②隨機查找10場比賽https://api.opendota.com/api/findMatches
該URL會隨機返回10場近期比賽的基本數據,包括游戲起始時間,對陣雙方英雄ID,天輝是否勝利等數據。
③查找英雄id對應名稱
https://api.opendota.com/api/heroes
該接口URL返回該英雄對應的基本信息,包括有英雄屬性,近戰/遠程,英雄名字,英雄有幾條腿等等。這里我們只對英雄名字這一條信息進行使用。
④查看數據庫表
https://api.opendota.com/api/schema
這個接口URL可以返回opendota數據庫的表名稱和其所包含的列名,在寫sql語句時會有所幫助,一般與下方的數據瀏覽器接口配合使用。
⑤數據瀏覽器
https://api.opendota.com/api/explorer?sql={查詢的sql語句}
該接口用來對網站的數據庫進行訪問,所輸入參數為sql語句,可以對所需的數據進行篩選。如下圖就是在matches表中尋找ID=5080676255的比賽的調用方式。
但是在實際使用中發現,這個數據瀏覽器接口僅能夠查詢到正式比賽數據,像我們平時玩的游戲情況在matches數據表里是不存在的。
⑥公開比賽查找
https://api.opendota.com/api/publicMatches?less_than_match_id={match_id}
該接口URL可以查詢到我們所需要的在線游戲對局數據,其輸入參數less_than_match_id指的是某局游戲的match_id,該接口會返回100條小於這個match_id的游戲對局數據,包括游戲時間,持續時間,游戲模式,大廳模式,對陣雙方英雄,天輝是否獲勝等信息。本次建模所需的數據都是通過這個接口來進行獲取的。
2.通過爬蟲獲取游戲對局數據
這次實驗准備建立一個通過對陣雙方的英雄選擇情況來對勝率進行預測的模型,因此需要獲得以下數據,[天輝方英雄列表]、[夜魘方英雄列表]、[哪方獲勝]。
此外,為了保證所爬取的對局質量,規定如下限制條件:平均匹配等級>4000,游戲時間>15分鍾(排除掉秒退局),天梯匹配比賽(避免普通比賽中亂選英雄的現象)。
首先,完成數據爬取函數:
1 #coding:utf-8 2 3 import json 4 import requests 5 import time 6 7 base_url = 'https://api.opendota.com/api/publicMatches?less_than_match_id=' 8 session = requests.Session() 9 session.headers = { 10 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36' 11 } 12 13 def crawl(input_url): 14 time.sleep(1) # 暫停一秒,防止請求過快導致網站封禁。 15 crawl_tag = 0 16 while crawl_tag==0: 17 try: 18 session.get("http://www.opendota.com/") #獲取了網站的cookie 19 content = session.get(input_url) 20 crawl_tag = 1 21 except: 22 print(u"Poor internet connection. We'll have another try.") 23 json_content = json.loads(content.text) 24 return json_content
這里我們使用request包來新建一個公共session,模擬成瀏覽器對服務器進行請求。接下來編輯爬取函數crawl(),其參數 input_url 代表opendota所提供的API鏈接地址。由於沒有充值會員,每秒鍾只能向服務器發送一個請求,因此用sleep函數使程序暫停一秒,防止過快調用導致異常。由於API返回的數據是json格式,我們這里使用json.loads()函數來對其進行解析。
接下來,完成數據的篩選和記錄工作:
1 max_match_id = 5072713911 # 設置一個極大值作為match_id,可以查出最近的比賽(即match_id最大的比賽)。 2 target_match_num = 10000 3 lowest_mmr = 4000 # 匹配定位線,篩選該分數段以上的天梯比賽 4 5 match_list = [] 6 recurrent_times = 0 7 write_tag = 0 8 with open('../data/matches_list_ranking.csv','w',encoding='utf-8') as fout: 9 fout.write('比賽ID, 時間, 天輝英雄, 夜魘英雄, 天輝是否勝利\n') 10 while(len(match_list)<target_match_num): 11 json_content = crawl(base_url+str(max_match_id)) 12 for i in range(len(json_content)): 13 match_id = json_content[i]['match_id'] 14 radiant_win = json_content[i]['radiant_win'] 15 start_time = json_content[i]['start_time'] 16 avg_mmr = json_content[i]['avg_mmr'] 17 if avg_mmr==None: 18 avg_mmr = 0 19 lobby_type = json_content[i]['lobby_type'] 20 game_mode = json_content[i]['game_mode'] 21 radiant_team = json_content[i]['radiant_team'] 22 dire_team = json_content[i]['dire_team'] 23 duration = json_content[i]['duration'] # 比賽持續時間 24 if int(avg_mmr)<lowest_mmr: # 匹配等級過低,忽略 25 continue 26 if int(duration)<900: # 比賽時間過短,小於15min,視作有人掉線,忽略。 27 continue 28 if int(lobby_type)!=7 or (int(game_mode)!=3 and int(game_mode)!=22): 29 continue 30 x = time.localtime(int(start_time)) 31 game_time = time.strftime('%Y-%m-%d %H:%M:%S',x) 32 one_game = [game_time,radiant_team,dire_team,radiant_win,match_id] 33 match_list.append(one_game) 34 max_match_id = json_content[-1]['match_id'] 35 recurrent_times += 1 36 print(recurrent_times,len(match_list),max_match_id) 37 if len(match_list)>target_match_num: 38 match_list = match_list[:target_match_num] 39 if write_tag<len(match_list): # 如果小於新的比賽列表長度,則將新比賽寫入文件 40 for i in range(len(match_list))[write_tag:]: 41 fout.write(str(match_list[i][4])+', '+match_list[i][0]+', '+match_list[i][1]+', '+\ 42 match_list[i][2]+', '+str(match_list[i][3])+'\n') 43 write_tag = len(match_list)
在上述代碼中,首先定義一個 max_match_id ,即表明搜索在這場比賽之前的對局數據,另外兩個變量target_match_num 和 lowest_mmr 分別代表所需記錄的對局數據數量、最低的匹配分數。
外層while循環判斷已經獲取的比賽數據數量是否達到目標值,未達到則繼續循環;在每次while循環中,首先通過crawl()函數獲取服務器返回的數據,內層for循環對每一條json數據進行解析、篩選(其中lobby_type=7是天梯匹配,game_mode=3是隨機征召模式,game_mode=22是天梯全英雄選擇模式)。在for循環結束后,更新max_match_id的值(使其對應到當前爬取數據的最后一條數據,下一次爬取數據時則從該位置繼續向下爬取),再將新爬取的數據寫入csv數據文件。工作流程如下方圖示,其中藍框表示條件判斷。
最終,我們通過該爬蟲爬取了10萬條游戲對陣數據,其格式如下:
這10萬條數據包含了10月16日2點到10月29日13點期間所有的高分段對局數據,每一條數據共有5個屬性,分別是[比賽ID,開始時間,天輝英雄列表,夜魘英雄列表,天輝是否勝利] 下面開始用這些數據來進行建模。
Part 2,建模及預測
1.訓練數據制作
一條訓練(測試)樣本分為輸入、輸出兩個部分。
輸入部分由一個二維矩陣組成,其形狀為[2*129]其中2代表天輝、夜魘兩個向量,每個向量有129位,當天輝(夜魘)中有某個英雄時,這個英雄id所對應的位置置為1,其余位置為0。因此,一條樣本的輸入是由兩個稀疏向量組成的二維矩陣。(英雄id的取值范圍為1-129,但實際只有117個英雄,有些數值沒有對應任何英雄,為了方便樣本制作,將向量長度設為129)
輸出部分則是一個整形的標量,代表天輝方是否勝利。我們將數據文件中的True使用1來代替,False使用0來代替。
因此,10萬條樣本最終的輸入shape為[100000,2,129],輸出shape為[100000,1]。
1 # ===================TODO 讀取對局數據 TODO======================== 2 with open('../data/matches_list_ranking_all.csv','r',encoding='utf-8') as fo_1: 3 line_matches = fo_1.readlines() 4 sample_in = [] 5 sample_out = [] 6 for i in range(len(line_matches))[1:]: 7 split = line_matches[i].split(', ') 8 radiant = split[2] 9 dire = split[3] 10 # print(split[4][:-1]) 11 if split[4][:-1]=='True': 12 win = 1.0 13 else: 14 win = 0.0 15 radiant = list(map(int,radiant.split(','))) 16 dire = list(map(int,dire.split(','))) 17 radiant_vector = np.zeros(hero_id_max) 18 dire_vector = np.zeros(hero_id_max) 19 for item in radiant: 20 radiant_vector[item-1] = 1 21 for item in dire: 22 dire_vector[item-1] = 1 23 sample_in.append([radiant_vector,dire_vector]) 24 sample_out.append(win)
之后,我們將樣本進行分割,按照8:1:1的比例,80000條樣本作為訓練集,10000條樣本作為測試集,10000條樣本作為驗證集。其中驗證集的作用是模型每在訓練集上訓練一個輪次以后,觀測模型在驗證集上的效果,如果模型在驗證集上的預測精度沒有提升,則停止訓練,以防止模型對訓練集過擬合。
1 def make_samples(): 2 train_x = [] 3 train_y = [] 4 test_x = [] 5 test_y = [] 6 validate_x = [] 7 validate_y = [] 8 for i in range(len(sample_in)): 9 if i%10==8: 10 test_x.append(sample_in[i]) 11 test_y.append(sample_out[i]) 12 elif i%10==9: 13 validate_x.append(sample_in[i]) 14 validate_y.append(sample_out[i]) 15 else: 16 train_x.append(sample_in[i]) 17 train_y.append(sample_out[i]) 18 return train_x,train_y,test_x,test_y,validate_x,validate_y
2.搭建深度學習模型
考慮到一個樣本的輸入是由兩個稀疏向量組成的二維矩陣,這里我們一共搭建了三種模型,CNN模型,LSTM模型以及CNN+LSTM模型。那為什么用這三種模型呢,我其實也做不出什么特別合理的解釋~~先試試嘛,效果不行丟垃圾,效果不錯真牛B。
①CNN模型
模型構建的示意圖如下,使用維度為長度為3的一維卷積核對輸入進行卷積操作,之后再經過池化和兩次全鏈接操作,將維度變為[1*1],最后使用sigmoid激活函數將輸出限定在[0,1]之間,即對應樣本的獲勝概率。下圖展現了矩陣、向量的維度變化情況。
下方為模型代碼,考慮到使用二維卷積時,會跨越向量,即把天輝和夜魘的英雄卷到一起,可能對預測結果沒有實際幫助,這里使用Conv1D來對輸入進行一維卷積。經過卷積操作后,得到維度為[2,64]的矩陣,再使用配套的MaxPooling1D()函數加入池化層。下一步使用Reshape()函數將其調整為一維向量,再加上兩個Dropout和Dense層將輸出轉換成一個標量。
1 model = Sequential() 2 model.add(Conv1D(cnn_output_dim,kernel_size,padding='same',activation='relu',input_shape=(team_num,hero_id_max))) #(none,team_num,129) 轉換為 (none,team_num,32) 3 model.add(MaxPooling1D(pool_size=pool_size,data_format='channels_first')) #(none,team_num,32)轉換為 (none,team_num,16) 4 model.add(Reshape((int(team_num*cnn_output_dim/pool_size),), input_shape=(team_num,int(cnn_output_dim/pool_size)))) 5 model.add(Dropout(0.2)) 6 model.add(Dense((10),input_shape=(team_num,cnn_output_dim/pool_size))) 7 model.add(Dropout(0.2)) 8 model.add(Dense(1)) # 全連接到一個元素 9 model.add(Activation('sigmoid')) 10 model.compile(loss='mse',optimizer='adam',metrics=['accuracy'])
在實際的調參過程中,卷積核長度,卷積輸出向量維度,Dropout的比例等參數都不是固定不變的,可以根據模型訓練效果靈活的進行調整。
②LSTM模型
模型構建的示意圖如下,LSTM層直接以[2,129]的樣本矩陣作為輸入,生成一個長度為256的特征向量,該特征向量經過兩次Dropout和全連接,成為一個標量,再使用sigmoid激活函數將輸出限定在[0,1]之間。
下方為構建LSTM模型的代碼,要注意hidden_size參數即為輸出特征向量的長度,在進行調參時,也是一個可以調節的變量。
1 model = Sequential() 2 model.add(LSTM(hidden_size, input_shape=(team_num,hero_id_max), return_sequences=False)) # 輸入(none,team_num,129) 輸出向量 (hidden_size,) 3 model.add(Dropout(0.2)) 4 model.add(Dense(10)) 5 model.add(Dropout(0.2)) 6 model.add(Dense(1)) # 全連接到一個元素 7 model.add(Activation('sigmoid')) 8 model.compile(loss='mse',optimizer='adam',metrics=['accuracy'])
③CNN+LSTM模型
模型構建的示意圖如下,與CNN模型很像,唯一區別是將reshape操作由LSTM層進行替換,進而生成一個長度為256的特征向量。
CNN+LSTM模型的代碼如下,與前兩個模型方法類似,這里不再詳細解說。
1 model = Sequential() 2 model.add(Conv1D(cnn_output_dim,kernel_size,padding='same',input_shape=(team_num,hero_id_max))) #(none,team_num,9) 轉換為 (none,team_num,32) 3 model.add(MaxPooling1D(pool_size=pool_size,data_format='channels_first')) #(none,team_num,32)轉換為 (none,team_num,16) 4 model.add(LSTM(hidden_size, input_shape=(team_num,(cnn_output_dim/pool_size)), return_sequences=False)) # 輸入(none,team_num,129) 輸出向量 (hidden_size,) 5 model.add(Dropout(0.2)) 6 model.add(Dense(10)) 7 model.add(Dropout(0.2)) 8 model.add(Dense(1)) # 全連接到一個元素 9 model.add(Activation('sigmoid')) 10 model.compile(loss='mse',optimizer='adam',metrics=['accuracy'])
3.設置回調函數(callbacks)
回調函數是在每一輪訓練之后,檢查模型在驗證集上的效果,如經過本輪訓練,模型驗證集上的預測效果比上一輪要差,則回調函數可以做出調整學習率或停止訓練的操作。
1 callbacks = [keras.callbacks.EarlyStopping(monitor='val_loss', patience=2, verbose=0, mode='min'),\ 2 keras.callbacks.ReduceLROnPlateau(monitor='val_loss', factor=0.5, patience=1, verbose=0, mode='min',\ 3 epsilon=0.0001, cooldown=0, min_lr=0)] 4 hist = model.fit(tx,ty,batch_size=batch_size,epochs=epochs,shuffle=True,\ 5 validation_data=(validate_x, validate_y),callbacks=callbacks) 6 model.save(model_saved_path+model_name+'.h5')
先設定回調函數callbacks,其中monitor=’val_loss’是指對驗證集的損失進行監控,如果這個loss經過一輪訓練沒有繼續變小,則進行回調;patience參數指的是等待輪次,在上述代碼中,如果連續1輪訓練’val_loss’沒有變小,則調整學習率(ReduceLROnPlateau),如果連續2輪訓練’val_loss’沒有變小,則終止訓練(EarlyStopping)。
最后使用model.fit()函數開始訓練,tx,ty是訓練集的輸入與輸出,在validation_data參數中需要傳入我們的驗證集,而在callbacks參數中,需要傳入我們設置好的回調函數。
4.預測效果
我們將訓練后的模型來對測試集進行預測,經過這一天的反復調參,得到了多個預測效果不錯的模型,最高的模型預測准確度可以達到58%。即,對於“看陣容猜勝負”這個任務,模型可以達到58%的准確率。為了更全面的了解模型的預測效果,我們分別計算模型在測試集、訓練集、驗證集上的預測准確度,並計算模型在打分較高的情況下的預測精度。以下面這個模型為例:
可以看出,模型在訓練集上的預測效果稍好,超過61%,而在訓練集和驗證集上的預測准確度在58%附近,沒有出現特別明顯的過擬合現象。
此外對於測試中的10000個樣本,有4514個被模型判斷為擁有60%以上的勝率,其中2844個預測正確,准確率達到63%;
有378場比賽被模型判斷為擁有75%以上的勝率,其中281場預測正確,准確率74.3%;
有97場比賽被模型判斷為擁有80%以上的勝率(陣容選出來就八二開),其中72場預測正確,准確率74.2%;
還有8場被模型認定為接近九一開的比賽,預測對了7場,准確率87.5%。
可以看出,模型給出的預測結果具有一定的參考價值。
為了對預測效果有些直觀的感受,修改代碼讓模型對預測勝率大於0.85的比賽陣容進行展示。
這8場比賽,模型全部預測天輝勝率,勝率從85%~87%不等。如果讓我來看陣容猜勝負的話,我是沒有信心給到這么高的概率的。這8場比賽中,帕吉的出現次數很多,達到了5次,我在max+上查詢了一下帕吉的克制指數:
從上圖可以看出,在上面的8場比賽中,修補匠、炸彈人(工程師)、幽鬼、狙擊手、魅惑魔女這些英雄確實出現在了帕吉的對面。這也說明我們模型的預測結果與統計層面上所展示出來的結論是較為一致的。
寫在最后:
1.代碼已經上傳到GitHub上,有興趣的同學可以去玩一玩。https://github.com/NosenLiu/Dota2_data_analysis
2.展望一下應用場景。
①選好陣容以后,用模型預測一下,陣容82開或91開的話,直接秒退吧,省的打完了不開心。╮( ̄﹏ ̄)╭
②對方已經選好陣容,我方還差一個英雄沒選的情況下,使用模型對剩下來的英雄進行預測,選出勝率最高的英雄開戰。實現起來較為困難,估計程序還沒跑完,選英雄的時間就已經到了。
③參與電競比賽博彩,根據預測結果下注。這個嘛,鑒於天梯單排和職業戰隊比賽觀感上完全不一樣,估計模型不能做出較為准確的預測。
3.可能會有同學會問這次的10萬條樣本能不能包含所有的對陣可能性,結論是否定的。我也是在開展本次實驗之前計算了一下,真是不算不知道,一算嚇一跳。游戲一共有117個英雄,天輝選擇5個,夜魘在剩余的112個里面選5個,一共能選出來
種不同的對陣,大概是2*1016!10萬條樣本完全只是九牛一毛而已。
4.最后吐槽一下V社的平衡性吧,這次爬取的10萬條比賽記錄,天輝勝利的有54814條,夜魘勝利的有45186條。說明當前版本地圖的平衡性也太差了,天輝勝率比夜魘勝率高了9.6%。