LightGCN實踐2——GPU記憶體爆炸終結篇

VideoRec發表於2020-10-23

hi,GPU記憶體佔滿的問題,這幾天必須解決。這裡issue,暫且相信一次,畢竟分散式GPU訓練太難改模型了。

人生艱難啊。近在眼前都不能抓住,更不要鬼扯遠在天邊的了。

For Video Recommendation in Deep learning QQ Group 277356808

視訊推薦深度學習加這個群

For Visual in deep learning QQ Group 629530787

視覺深度學習加這個,別加錯

I'm here waiting for you

別加那麼多,沒必要,另外,不接受這個網頁的私聊/私信!!!

 1-NeuRec開始,

util部分,引數輸入寫的有點繁瑣,我覺得一個argparse完全能夠解決問題。

>>> from configparser import ConfigParser
>>> help(configparser)

    ConfigParser -- responsible for parsing a list of
                        configuration files, and managing the parsed database.
    
        methods:
    
        __init__(defaults=None, dict_type=_default_dict, allow_no_value=False,
                 delimiters=('=', ':'), comment_prefixes=('#', ';'),
                 inline_comment_prefixes=None, strict=True,
                 empty_lines_in_values=True, default_section='DEFAULT',
                 interpolation=<unset>, converters=<unset>):
            Create the parser. When `defaults' is given, it is initialized into the
            dictionary or intrinsic defaults. The keys must be strings, the values
            must be appropriate for %()s string interpolation.
    
            When `dict_type' is given, it will be used to create the dictionary
            objects for the list of sections, for the options within a section, and
            for the default values.
    
            When `delimiters' is given, it will be used as the set of substrings
            that divide keys from values.
    
            When `comment_prefixes' is given, it will be used as the set of
            substrings that prefix comments in empty lines. Comments can be
            indented.

之前的一篇已經提及,sys與argparse一起使用比較麻煩。

作者定義的類class Configurator(object)將手動輸入命令列的引數進行了修改,是在讀取config檔案後的修改(_read_config_file)

2-不行啊,使用原來的LightGCN簡單的設定下面引數無濟於事,而且利用率為0,似乎程式不動了,雖然沒有爆炸。

config.gpu_options.per_process_gpu_memory_fraction = 0.8

但等了一會記憶體還是爆炸了。還是這個博文中同樣的錯誤。別告訴我要調成0.5,估計還是會吧【15:08發現還是不行啊】,試試的同時 還是得看完整的NeuRec,那繼續吧。

3-300萬的interaction,50萬user,10萬items,這樣的pre引數計算需要40mins,這是很浪費時間的。如果可以的話我會用這個博文的方法全程加速,先看NeuRec有沒有解決方案。這裡主要是矩陣計算引起的,其中還有稀疏矩陣啥的,比較繁瑣。

大概是從16:08開始計算的,沒想到計算pre引數這麼快?訓練時GPU記憶體也沒有爆炸,一個epoch大概7mins

2020-10-21 16:13:01.396: metrics:	Recall@50   	NDCG@50     	MAP@50      	MRR@50      
2020-10-21 16:20:29.883: epoch 0:	0.26113239  	0.11061251  	0.07287365  	0.07287365  
2020-10-21 16:26:48.926: epoch 1:	0.27756262  	0.11437650  	0.07370490  	0.07370490  

後面的指標MAP和MRR相同說明是採用的LOO法做的測試(也就是隻留一個作為測試)

下面剖析程式碼,然後轉化成自己實際可用的——即最終要給訓練的使用者推items列表,這個很多rp都不能實現,包括PaddleRectf官方多GPU訓練NCF。實用好用才是最終目的,空有一個花架子沒啥用。

從tmp檔案中可知,編碼後的資料又儲存了,經過檢視發現LOO測試的user個數與test檔案中最大編碼user不等(相差很大)如下

#test檔案最後幾行
563380,18584
563381,317
563382,282291
563384,6313
563385,6053
563386,10545
563387,3450
563390,6366
563391,54
563392,409

#test檔案行數
318620

所以寡人將不採用作者的分割方式,而是直接給定train和test檔案(作者也給出了這種資料讀取方式)

另外編碼方式也不是採用的LabelEncoder,與其結果也不同,即不符合sort的排序方式。如下示例:

#.item2id檔案
7vIByjkyG,0
80dRNWjCi,1
7yGTXZ0Rl,2
7trePsN4W,3
7vWh4RZca,4
7vipDneDA,5
7tsDiXNpM,6
7vI7gzdPo,7
7xh8X1Dx4,8

#user2id
0000851a8cca32,0
0000c50dcf49ab8f0825dce40473dc,1
0000e57cbd8840a33b0e982c468e70,2
0000ee90406d48b8f649ae303212cf,3

其中user符合是因為我本來儲存時已經按照sort方式對user進行了排序。

不知道其中的md5是啥操作,有點神奇。如下:

def check_md5(file_name):
    with open(file_name, "rb") as fin:
        bytes = fin.read()  # read file as bytes
        readable_hash = hashlib.md5(bytes).hexdigest()

    return readable_hash

#示例
ori_file_md5 = [check_md5(rating_file)]

        if os.path.isfile(saved_prefix + ".md5"):
            with open(saved_prefix + ".md5", 'r') as md5_fin:
                saved_md5 = [line.strip() for line in md5_fin.readlines()]
            if ori_file_md5 == saved_md5:
                check_state = True

        for postfix in [".train", ".test", ".user2id", ".item2id"]:
            if not os.path.isfile(saved_prefix + postfix):
                check_state = False

經過確認,這個讀取檔案生成的md5字串和儲存的md5檔案中的一樣,這種操作應該是為了確保處理的是同一個資料,下面的要求是確定4個檔案已經生成完畢。

這個有一定的作用,但我將去掉這個。刪繁就簡三秋木,標新立異二月花。將別人的讀懂並改為自己習慣的,這才是看懂了程式碼

重新對映id在我用的LOO是沒有必要的,我將訓練的全部用來test(即給每個user推薦items列表),如果有新的變化,可以在編碼之前就進行這些處理操作,編碼後不再進行二次對映id。原始碼直接採用的序列轉字典的編碼方式,可以。

self.userids = pd.Series(data=range(len(unique_user)), index=unique_user).to_dict()

直接將raw_id作為key,編碼後的為value

>>> kk=np.random.randint(12,17,size=(12,2))
>>> df=pd.DataFrame(kk,columns=['user_id','item_id'])
>>> uids=df['user_id'].unique()
>>> pd.Series(data=range(len(uids)), index=uids).to_dict()
{12: 0, 15: 1, 13: 2}

>>> raw_iid_dict=pd.Series(data=range(len(uids)), index=uids).to_dict()
>>> df['user_id']=df['user_id'].map(raw_iid_dict)
>>> df
    user_id  item_id
0         0       13
1         1       15
2         2       13
3         1       12
4         1       13
5         2       13
6         2       15
7         2       13
8         2       12
9         1       15
10        0       13
11        2       13

這速度比LabelEncoder快多了啊。

4-關注負取樣部分,原來的gcn程式碼沒有涉及(可能有但沒有特別注意)這裡重點關注

挨個append太慢?這裡extend來直接實現,省去了np.append等類似方法的麻煩。

>>> k
[1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0]
>>> k.extend([1,2,3,4,5])
>>> k
[1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1, 2, 3, 4, 5]

在看人程式碼中學習細碎的知識點。很明顯,其中的負取樣與Youtube中採用的方法相同,都是隨機選擇其他使用者的點選item作為負樣本,但這裡另外生成了一個資料集,即每個使用者都有相應的負樣本items列表。

                grouped_user = all_data.groupby(["user"])
                for user, u_data in grouped_user:
                    line = [user]
                    line.extend(randint_choice(self.num_items, size=number_neg,
                                               replace=False, exclusion=u_data["item"].tolist()))
                    neg_items.append(line)

隨機選擇5個負樣本,程式已經不動了,很慢了,在資料的生成方面有待優化啊。但在實際的LightGCN模型中卻沒有使用負取樣的資料,說明作者還沒有開發這一塊內容。如下:在Dataset類中及LightGCN類中均未找到下面函式的使用

    def get_user_test_neg_dict(self):
        test_neg_dict = None
        if self.negative_matrix is not None:
            test_neg_dict = csr_to_user_dict(self.negative_matrix)
        return test_neg_dict

dataset = Dataset(conf)

所以小明哥也暫時放棄吧,刪除這部分的程式碼。後來發現的在評估的時候用的這個玩意。所以這部分(其實也是作者重點開發的部分)我也不用了,暫時替換成自己的東西,因為我想在每個epoch只需計算loss,只在最後再計算HR/NDCG,我採用while迴圈做的,速度也不慢。

5-關於引數細節

by_time到底啥意思,其實這個不重要,只要將每個user的item按照time sort即可。split_by_loo

如果點選長度小於等於3(也就是2或者3,沒有2個就不參加訓練了,沒法計算LOO的指標),直接append到first_section中,沒有給他做LOO指標計算,這是上面的3中為啥最大編碼數與行數不同的原因之一(也可能是唯一原因)。

data.dropna(how="any", inplace=True)
dropna(axis=0, how='any', thresh=None, subset=None, inplace=False) 

    >>> df = pd.DataFrame({"name": ['Alfred', 'Batman', 'Catwoman'],
    ...                    "toy": [np.nan, 'Batmobile', 'Bullwhip'],
    ...                    "born": [pd.NaT, pd.Timestamp("1940-04-25"),
    ...                             pd.NaT]})
    >>> df
           name        toy       born
    0    Alfred        NaN        NaT
    1    Batman  Batmobile 1940-04-25
    2  Catwoman   Bullwhip        NaT

預設去掉其中每行有Na的行,只要行中有Na就去掉,如果how為all,那是行所有的都為Na才去的。

NaN是非數,pd.NaT是非時間,其實也是非數

class NaTType(_NaT)
 |  (N)ot-(A)-(T)ime, the time equivalent of NaN
train_dict = csr_to_user_dict_bytime(self.time_matrix, self.train_matrix)

        if file_format == "UIRT":
            self.time_matrix = csr_matrix((train_data["time"], (train_data["user"], train_data["item"])),
                                          shape=(self.num_users, self.num_items))

            print("split and save data...")
            by_time = config["by_time"] if file_format == "UIRT" else False

因為不是這個形式,而是直接UI形式,那麼這個time矩陣為空,最後一句則是說,只要不是UIRT形式,by_time均為False,手動設定也無效。

6-關於模型

鑑於上面的4,對比原來的GCN中的做法,發現此處程式碼似乎是直接將訓練得到的user和item的embedding做乘積,然後對得到的評分進行argsort,由此終於明白了LightGCN的中心思想,哈哈,有時候還是在程式碼中讀懂啊,這才是讀程式碼的意義。我自己都感覺到了昇華,彷彿一股真氣貫穿任督二脈,所有功力蓄勢待發(哈哈哈,真實體驗就是這樣,就是很開心的感覺)

若是簡單的乘積,那麼必然也可用距離來衡量,因而faiss也就順理成章可以在此使用,也因此可以將所有訓練得到的結果(embedding)儲存下來供faiss召回使用,那速度是相當厲害。從而不需要也不必每小時都要更新,我是這麼覺得。也進一步可以擴大使用者範圍,基本上可以覆蓋客戶端所有使用者,這樣的效果待驗證,敬請持續關注。

舊、新程式碼中的infer部分對比:

self.node_dropout = tf.placeholder(tf.float32, shape=[None])
self.mess_dropout = tf.placeholder(tf.float32, shape=[None])   
rate_batch = sess.run(model.batch_ratings, {model.users: user_batch,
                                                        model.pos_items: item_batch,
                                                        model.node_dropout: [0.] * len(eval(layer_size)),
                                                        model.mess_dropout: [0.] * len(eval(layer_size))})

    def predict(self, users, candidate_items=None):
        feed_dict = {self.users: users}
        ratings = self.sess.run(self.batch_ratings, feed_dict=feed_dict)
        if candidate_items is not None:
            ratings = [ratings[idx][u_item] for idx, u_item in enumerate(candidate_items)]
        return ratings

如果後者(新)正確的話,那麼我上面的融會貫通也必然正確,即:對每個使用者都可得到相應的item評分,選取其中最高評分的item即可,這就是推薦完成了。

而Loss的計算則無法體現,而這點不難,只需加個sess.run的引數即可。毫不費力。注意將最終的embedding也sess.run

終於調好了自己的輸入資料,但是有個類PairwiseSampler沒整好,決定放棄,直接引入吧。

下面計算下結果是否正確,HR等指標是否符合要求,隨隨便便也得0.18啊

30萬user結果如下:

[0.03178284 0.15516527 0.04257957 0.05609072 0.01102237]
感覺arg還是慢,不如faiss召回,明天可以試試效果如何。拜拜

 

 

 

相關文章