1. 程式人生 > >基於深度學習方法的dota2遊戲資料分析與勝率預測(python3.6+keras框架實現)

基於深度學習方法的dota2遊戲資料分析與勝率預測(python3.6+keras框架實現)

很久以前就有想過使用深度學習模型來對dota2的對局資料進行建模分析,以便在英雄選擇,出裝方面有所指導,幫助自己提升天梯等級,但苦於找不到資料來源,該計劃擱置了很長時間。直到前些日子,看到社群有老哥提到說OpenDota網站(https://www.opendota.com/)提供有一整套的介面可以獲取dota資料。通過瀏覽該網站,發現數據比較齊全,滿足建模分析的需求,那就二話不說,開始幹活。

這篇文章分為兩大部分,第一部分為資料獲取,第二部分為建模預測。

 

Part 1,資料獲取

1.介面分析

dota2的遊戲對局資料通過OpenDota所提供的API進行獲取,通過閱讀API文件(https://docs.opendota.com/#),發現幾個比較有意思/有用的介面:

①請求單場比賽

https://api.opendota.com/api/matches/{match_id} 呼叫該URL可以根據比賽ID來獲得單場比賽的詳細資訊,包括遊戲起始時間,遊戲持續時間,遊戲模式,大廳模式,天輝/夜魘剩餘兵營數量,玩家資訊等,甚至包括遊戲內聊天資訊都有記錄。

 

 上面就是一條聊天記錄示例,在這局遊戲的第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%。

 

 

 

 


&n