推薦模型PNN: 原理介紹與TensorFlow2.0實現


1. 簡介

學習用戶響應在信息檢索領域有非常重要的應用,但是這些領域中有大量的類別特征,每個大類叫做一個域即field(城市域,性別域,id域等)。這些不同域之間的特征模式的表示不能簡單的采用onehot,一方面是過擬合,數據過於稀疏,另一方面也是會帶來參數量巨大的問題。

所以,乘積網絡——Product-based Neural Network,PNN是一個基於神經網絡的推薦模型,主要的改進和創新點在於乘積層的應用。乘積層的內積和外積操作能很大程度上增加多類別特征的(高階)交叉能力,這是傳統模型LR, FM以及GBDT所不能及的。

目前已有模型的局限性:FNN模型(矩陣分解機的神經網絡)初始化采用了預訓練的FM數據,CCPM(基於卷積的預測模型)卷積操作只能觀察相鄰特征的關系,不能觀察非鄰域特征的交叉模式。而PNN模型技能學習局部特征,也能學習高階交叉模式。

在特征交叉的相關模型中FM, FFM都證明了特征交叉的重要性,FNN將神經網絡的高階隱式交叉加到了FM的二階特征交叉上,一定程度上說明了DNN做特征交叉的有效性。但是對於DNN這種“add”操作的特征交叉並不能充分挖掘類別特征的交叉效果。PNN雖然也用了DNN來對特征進行交叉組合,但是並不是直接將低階特征放入DNN中,而是設計了Product層先對低階特征進行充分的交叉組合之后再送入到DNN中去。

其中,PNN以乘積層是內積還是外積分為IPNN,和OPNN(I是inner,O是outer)。

2. 模型原理

模型結構圖

image

從模型上看,CTR,L2, L1以及Embedding層都是常規層。

  • CTR部分就是目標函數為0-1損失函數logloss,激活函數為sigmoid。\(\hat y = \sigma(W_3l_2+b_3)\)

  • L2層為ReLu激活函數的層。\(l_2=ReLu(W_2l_1+b_2)\)

  • L1層為\(l_1 = ReLu(l_z + l_p + b1)\).

  • 這個模型創新部分就在於product layer 的設計。product分為線性部分和非線性部分\(z,p\),下面詳細介紹(不懂的部分可以結合代碼部分一起看)。

1. 線性部分

一階特征(未經過顯式特征交叉處理),對應論文中的\(l_z=(l_z^1,l_z^2, ..., l_z^{D_1})\)。(D1為L1的神經元數量)

\(l_z\)所求就是\(l_z^n\)矩陣內積(形狀相同的矩陣對應位置元素,對應相乘再相加得到一個標量),排列為D1列。代碼實現可以Flatten。

\[l_z=(l_z^1,l_z^2, ..., l_z^{D_1})\\ z = (z_1, z_2, ..., z_N) \\ l_z^n = W_z^n \odot{z} \\ l_z^n = W_z^n \odot{z} = \sum_{i=1}^N \sum_{j=1}^M (W_z^n)_{i,j}z_{i,j} \]

總之,用D1個W權重矩陣與N個向量長度為M的EmbeddingVector相乘得到的D1個數字作為線性部分的結果\(l_z\)

2. 非線性部分

整體上看,\(l_p\)的計算為D1個\(l_p^n\)排列而成的矩陣,具體為,

\[l_p=(l_p^1,l_p^2, ..., l_p^{D_1}) \\ l_p^n = W_p^n \odot{p} \\ p = \{p_{i,j}\}, i=1,2,...,N,j=1,2,...,N \]

\(p\)分為兩種方式:內積和外積。

2.1 IPNN

使用內積計算特征交叉,類似於FM(向量兩兩內積)即,

\[g(f_i,f_j) = <f_i, f_j> \]

代入\(l_p^n\)得到,

\[\begin{align} l_p^n &= W_p^n \odot{p} \\ &= \sum_{i=1}^N \sum_{j=1}^N (W_p^n)_{i,j}p_{i,j} \\ &= \sum_{i=1}^N \sum_{j=1}^N (W_p^n)_{i,j}<f_i, f_j> \end{align} \]

總的到L1層所需要的復雜度為:

時間復雜度解釋:\(p_{ij}\)其實是一個數,得到一個\(p_{ij}\)的時間復雜度為M,p的大小為\(NN\),因此計算得到p的時間復雜度為\(NNM\)。而再由p得到\(l_p\)的時間復雜度是\(N*N*D_1\)。因此 對於IPNN來說,總的時間復雜度為\(N*N(D_1+M)\)

空間復雜度解釋:也就是參數的數量\(D_1*(NN+MN)=D_1N(M+N)\)

由於N是比較大的需要簡化

計算的內積矩陣\(p\)是對稱的,那么與其對應元素做矩陣內積的矩陣\(W_p^n\)也是對稱的,對於可學習的權重來說如果是對稱的是不是可以只使用其中的一半就行了呢,

所以基於這個思考,對Inner Product的權重定義及內積計算進行優化,首先將權重矩陣分解\(W_p^n=\theta^n \theta^{nT}\),此時\(\theta^n \in R^N\)(參數從原來的\(N^2\)變成了\(N\)),將分解后的\(W_p^n\)帶入\(l_p^n\)的計算公式有:

\[\begin{align} l_p^n &= W_p^n \odot{p} \\ &= \sum_{i=1}^N \sum_{j=1}^N (W_p^n)_{i,j}p_{i,j} \\ &= \sum_{i=1}^N \sum_{j=1}^N \theta^n \theta^n <f_i, f_j> \\ &= \sum_{i=1}^N \sum_{j=1}^N <\theta^n f_i, \theta^n f_j> \\ &= <\sum_{i=1}^N \theta^n f_i, \sum_{j=1}^N \theta^n f_j> \\ &= ||\sum_{i=1}^N \theta^n f_i||^2 \end{align} \]

所以優化后的\(l_p\)的計算公式為:

\[l_p = (||\sum_{i=1}^N \theta^1 f_i||^2, ||\sum_{i=1}^N \theta^2 f_i||^2, ..., ||\sum_{i=1}^N \theta^{D_1} f_i||^2) \]

其中,\(\theta\)是一個標量 ,每一個\(l_p^n\)需要計算MN次(N個特征類別,每個特征有M維度)變成數字,共有D1個,所以復雜度均降為\(D_1MN\)

2.2 OPNN

使用向量的外積來計算矩陣\(p\),首先定義向量的外積計算

\[g(i,j) = f_i f_j^T \]

從外積公式可以發現兩個向量的外積得到的是一個矩陣,與上面介紹的內積計算不太相同,內積得到的是一個數值。內積實現的Product層是將計算得到的內積矩陣,乘以一個與其大小一樣的權重矩陣,然后求和,按照這個思路的話,通過外積得到的\(p\)計算\(W_p^n \odot{p}\)相當於之前的內積值乘以權重矩陣對應位置的值求和就變成了,外積矩陣乘以權重矩陣中對應位置的子矩陣然后將整個相乘得到的大矩陣對應元素相加,用公式表示如下:

\[\begin{align} l_p^n &= W_p^n \odot{p} \\ &= \sum_{i=1}^N \sum_{j=1}^N (W_p^n)_{i,j}p_{i,j} \\ &= \sum_{i=1}^N \sum_{j=1}^N (W_p^n)_{i,j} f_i f_j^T \end{align} \]

需要注意的是此時的\((W_p^n)_{i,j}\)表示的是一個矩陣,而不是一個值,此時計算\(l_p\)的復雜度是\(O(D_1*N^2*M^2)\), 其中\(N^2\)表示的是特征的組合數量,\(M^2\)表示的是計算外積的復雜度。這樣的復雜度肯定是無法接受的,所以為了優化復雜度,PNN的作者重新定義了\(p\)的計算方式:

\[p=\sum_{i=1}^N \sum_{j=1}^N f_i f_j^T = f_{\sum}(f_\sum)^T\\ f_\sum = \sum_{i=1}^N f_i \]

相當於先將原來的embedding向量在特征維度上先求和,變成一個向量之后再計算外積(相當於池化操作,抹平了不同特征的異化,其實會增加不准確度)。

若原embedding向量表示為\(E \in R^{N\times M}\),其中\(N\)表示特征的數量,M表示的是所有特征的總維度,即\(N*emb\_dim\),。在特征維度上進行求和就是將\(E \in R^{N\times M}\)矩陣壓縮成了\(E \in R^M\), 然后兩個\(M\)維的向量計算外積得到最終所有特征的外積交叉結果\(p\in R^{M\times M}\),最終的\(l_p^n\)可以表示為:

\[l_p^n = W_p^n \odot{p} = \sum_{i=1}^N \sum_{j=1}^N (W_p^n)_{i,j}p_{i,j} \\ \]

最終的計算方式和\(l_z\)的計算方式看起來差不多,但是需要注意外積優化后的\(W_p^n\)的維度是\(R^{M \times M}\)的,\(M\)表示的是特征矩陣的維度,即\(N*emb\_dim\)

雖然疊加概念的引入可以降低計算開銷,但是中間的精度損失也是很大的,性能與精度之間的tradeoff

3.代碼實現

Product層需要自定義實現,其他部分可以借用TensorFlow的API實現。

3.1 乘積層

lz線性部分的實現是由D1個權重矩陣分別與特征矩陣(權重矩陣和特征矩陣同維度為N*M,N 為特征個數,M為embedding維度)點積得到的D1個標量,然后把這個D1個標量連接在一起構成一個向量。

代碼部分簡化為:直接將權重維度設置為(N*M, D1),則不需要循環D1次分別做點積,而是兩個矩陣的直接做矩陣乘法得到D1維度的向量:

# 先將所有的embedding拼接起來計算線性信號部分的輸出
concat_embed = Concatenate(axis=1)(inputs) # B x feat_nums x embed_dims
# 將兩個矩陣都拉成二維的,然后通過矩陣相乘得到最終的結果
concat_embed_ = tf.reshape(concat_embed, shape=[-1, self.feat_nums * self.embed_dims])
lz = tf.matmul(concat_embed_, self.linear_w) # B x units

lp非線性部分

首先要理解的是lp是由D1個標量組成的D1維向量,每次非線性操作都將得到D1向量的一個元素,即標量

這部分包含兩個內積和外積,分別介紹。

1. 內積

內積的原理已經說明清楚,這里再提一點,lp向量的每一個維度的元素所求為N*N維度的點積,所以論文簡化了這個操作,直接降低為N維度的點積,即為每個特征(總共為N*M每個維度為1*M)乘以一個權重數字,所以權重維度為N*1,具體展開如下圖,

image

然后基於特征維度壓縮求和,得到M*1的向量即論文中的\(\delta^n_i\),最終平方求和得到一個標量數字。

delta = tf.multiply(concat_embed, tf.expand_dims(self.inner_w[i], axis=1)) # B x feat_nums x embed_dims
# 在特征之間的維度上求和
delta = tf.reduce_sum(delta, axis=1) # B x embed_dims
# 最終在特征embedding維度上求二范數得到p
lpi = tf.reduce_sum(tf.square(delta), axis=1, keepdims=True) # B x 1

重復D1次, 得到D1個維度的向量。

2. 外積

外積同樣做了簡化。

首先將向量沿着特征維度進行求和(類似於不同類型 特征做池化(平均池化,求和池化))。

tf.reduce_sum(N*M的特征矩陣, axis=1)

然后對簡化之后的特征做矩陣乘法(即外積運算), 類似論文中的fi和fj。

tf.matmul(f1, f2) # B * embed_dims * embed_dims

最后對外積結果添加權重求和得到一個數字。

理解了內積部分,同樣很容易理解外積部分。主要是對權重的把握 以及如何得到一個標量。

具體代碼部分為:

初始化:glorot_normal
* 各個層的激活值h(輸出值)的方差要保持一致
* 各個層對狀態Z的梯度的方差要保持一致
參見:https://blog.csdn.net/qq_27825451/article/details/88707423
class ProductLayer(keras.layers.Layer):
    def __init__(self, units, use_inner=True, use_outer=False, **kwargs):
        super(ProductLayer, self).__init__(**kwargs)
        self.units = units # 論文中D1
        self.use_inner = use_inner
        self.use_outer = use_outer

    #build在執行call函數時執行一次,獲得輸入的形狀;
    #定義輸入X時為列表,每個元素為一個類別的Embeding所以,每個元素的形狀為(batch_size, 1, emb_dim),因此沒有被flatten
    def build(self, input_shape):
        self.feat_nums = len(input_shape) # 列表長度為所有類別
        self.embed_dims = input_shape[0].as_list()[-1] # (batch_size, 1, emb_dim)
        flatten_dims = self.feat_nums * self.embed_dims
        
        self.linear_w = self.add_weight(name='linear_w', shape=(flatten_dims, self.units), initializer='glorot_normal')

        if self.use_inner:
            # 優化之后的內積權重是未優化時的一個分解矩陣,未優化時的矩陣大小為:D x N x N 
            # 優化后的內積權重大小為:D x N
            self.inner_w = self.add_weight(name='inner_w', shape=(self.units, self.feat_nums), initializer='glorot_normal')
        if self.use_outer:
            # 優化為 每個向量矩陣 外積權重大小為:D x M x M
            self.outer_w = self.add_weight(name='outer_w', shape=(self.units, self.embed_dims, self.embed_dims), initializer='glorot_normal')

    def call(self, inputs):
        concat_emb = tf.concat(inputs, axis=1) # B* feat_nums*emb_dim
        # lz
        _concat_emb = tf.reshape(concat_emb, shape=[-1, self.feat_nums*self.embed_dims])
        lz = tf.matmul(_concat_emb, self.linear_w) # B * D1

        #lp: 一個元素一個元素的計算
        lp_list = []
        #inner: 每個元素都是內積成權重的結果
        if self.use_inner:
            for i in range(self.units):
                # self.inner_w[i] : (embed_dims, ) 添加一個維度變成 (embed_dims, 1)
                lpi = tf.multiply(concat_emb, tf.expand_dims(self.inner_w[i], axis=1)) # 論文的delta:B * feat_nums* emb_dims
                # 求范數:先求和再開方
                lpi = tf.reduce_sum(lpi, axis=1) # B * emb_dims
                lpi = tf.square(lpi) # B * emb_dims A Tensor. Has the same type as x.
                lpi = tf.reduce_sum(lpi, axis=1, keepdims=True) # B * 1 這里沒有再次進行開方,因為不影響結果, 必須要有keepdims=True參數否則維度變成B
                lp_list.append(lpi)
        #outer: 每個元素都是 特征維度求和的外積 乘以權重
        if self.use_outer:
            feat_sum = tf.reduce_sum(concat_emb, axis=1) # B*emb_dims
            # 為了求外積,構造轉置向量
            f1 = tf.expand_dims(feat_sum, axis=1) # B* 1* emb_dims
            f2 = tf.expand_dims(feat_sum, axis=2) # B* emb_dims * 1
            # 外積
            product = tf.matmul(f2, f1) # B * emb_dims * emb_dims
            for in range(self.units):
                # self.outer_w[i] 為emb_dims * emb_dims不必增添維度
                lpi = tf.multiply(product, self.outer_w[i]) # B * emb_dims * emb_dims
                # 求和
                lpi = tf.reduce_sum(lpi, axis=[1,2]) # 把emb_dims壓縮下去 (B,)
                # 沒法連接
                lpi = tf.expand_dims(lpi, axis=1) # B * 1
                lp_list.append(lpi)
        lp = tf.concat(lp_list, axis=1)

        product_out = tf.concat([lz, lp], axis=1)
        return product_out

3.2 PNN實現

除了乘積層之外沒有特別的部分,因此,不再進行Embedding的和類的封裝,而是通過Keras的Input做前向傳播的運算得到模型的輸出。

具體看代碼部分的注釋:

設置特征類型:SparseFeat和DenseFeat

from collections import namedtuple
SparseFeat = namedtuple('SparseFeat', ['name', 'vocabulary_size', 'embedding_size'])
DenseFeat = namedtuple('DenseFeat', ['name', 'dimension'])

構造Input字典,然后通過字典key得到對應的數據

# 構建Input字典:每個輸入特征構成一個Input,方便對不同的特征輸入
def build_input_layers(feat_cols):
    """
    feat_cols是列表,每個元素都是namedtuple表征是否是稀疏向量
    return: 稠密和稀疏兩個字典
    """
    sparse_dict, dense_dict = dict(), dict()
    
    for fc in feat_cols:
        if isinstance(fc, DenseFeat):
            dense_dict[fc.name] = keras.Input(shape=(1, ), name=fc.name)
        if isinstance(fc, SparseFeat):
            sparse_dict[fc.name] = keras.Input(shape=(1, ), name=fc.name)
    return dense_dict, sparse_dict

構建emb層和輸出列表

def build_emb_layers(feat_cols):
    """
    返回emb字典
    """ 
    emb_dict = {}
    #使用python內建函數,filter過濾出稀疏特征來進行Embedding
    sparse_feat = list(filter(lambda fc: isinstance(fc, SparseFeat), feat_cols)) if feat_cols else []
    for fc in sparse_feat:
        emb_dict[fc.name] = keras.layers.Embedding(input_dim=fc.vocabulary_size+1,
                                                   output_dim=fc.embedding_size,
                                                   name='emb_' + fc.name)
    return emb_dict


def concat_emb_layers(feat_cols, input_layer_dict, emb_layer_dict, flattern=False) :
    """
    將輸入層 經過emb層得到最終的輸出
    """
    sparse_feat = list(filter(isinstance(lambda fc: fc, SparseFeat), feat_cols)) if feat_cols else []
    emb_list = []
    for fc in sparse_feat:
        _input = input_layer_dict[fc.name] # 1 * None
        _emb = emb_layer_dict[fc.name] # B*1*emb_dim
        embed = _emb(_input)

        if flattern:
            embed = keras.layers.Flatten()(embed)
        emb_list.append(embed)
    
    return emb_list

最后的MLP和打分層

def get_dnn_logit(dnn_inputs, units=(64, 32)):
    """
    MLP的部分,以及最終的評分函數
    """
    dnn_out = dnn_inputs
    for unit in units:
        dnn_out = keras.layers.Dense(unit, activation='relu')(dnn_out) # 不需要指定input_shape,Input里已經有了

    logit = keras.layers.Dense(1, activation='sigmoid')(dnn_out)

    return logit

PNN模型

def PNN(feat_cols, dnn_units=(64, 32), D1=32, inner=True, outer=False) :
    dense_input_dict, sparse_input_dict = build_input_layers(feat_cols)
    #Model的參數中 inputs是列表 和outputs
    input_layers = list(sparse_input_dict.values())

    # 前向過程
    emb_dict = build_emb_layers(feat_cols)
    emb_list = concat_emb_layers(feat_cols,sparse_input_dict, emb_dict, flattern=True) # 測試True的效果
    dnn_inputs = ProductLayer(units=D1, use_inner=inner, use_outer=outer)(emb_list)
    output_layer = get_dnn_logit(dnn_inputs, units=dnn_units)

    model = keras.layers.Model(input_layers, output_layer)
    return model

3.3 經過數據criteo_sample測試結果

def data_process(data_df, dense_features, sparse_features):
    data_df[dense_features] = data_df[dense_features].fillna(0.0)
    for f in dense_features:
        data_df[f] = data_df[f].apply(lambda x: np.log(x+1) if x > -1 else -1)
        
    data_df[sparse_features] = data_df[sparse_features].fillna("-1")
    for f in sparse_features:
        lbe = LabelEncoder()
        data_df[f] = lbe.fit_transform(data_df[f])
    
    return data_df[dense_features + sparse_features + ['label']]


path = 'criteo_sample.txt'
data = pd.read_csv(path)
columns = data.columns.values() # ndarray
dense_feats = [feat for feat in columns if 'I' in feat]
sparse_feats = [feat for feat in columns if 'C' in feat]
# 數據處理
train_data = data_process(data, dense_feats, sparse_feats)
#傳入類別特征
dnn_feat_cols = [SparseFeat(feat, vocabulary_size=data[feat].nunique(), embedding_size=4) for feat insparse_feats]
# 構建模型
history = PNN(dnn_feat_cols)
history.compile(optimizer="adam", loss="binary_crossentropy", metrics=['auc', 'binary_crossentropy'])
train_inputs = {name: data[name] for name in dense_feats+sparse_feats}
history.fit(train_inputs, train_data['label'].values,
    batch_size=64, epochs=5, validation_split=0.2, )

得到:

val_binary_crossentropy: 0.6666 - val_auc: 0.5912
Epoch 4/5
160/160 [==============================] - 0s 592us/sample - loss: 0.6411 - binary_crossentropy: 0.6411 - auc: 0.6830 - val_loss: 0.6575 - val_binary_crossentropy: 0.6575 - val_auc: 0.5926
Epoch 5/5
160/160 [==============================] - 0s 586us/sample - loss: 0.6214 - binary_crossentropy: 0.6214 - auc: 0.7478 - val_loss: 0.6479 - val_binary_crossentropy: 0.6479 - val_auc: 0.5755

4. 小結

本篇文章的主要目的是對於信息檢索領域多個類別稀疏特征的處理,由人工特征工程交叉,向復雜特征工程交叉實現,復雜特征工程通過乘積層實現,乘積層是比較難以理解的部分,且計算復雜度是十分巨大的,因此進行了簡化。

由於簡化尤其是外積,效果可能不會特別好,比如說不同類別所屬field不同,進行求和則過度池化。但這是效率和性能權衡的結果。

模型的成功之處是乘積層(內積,外積),相對於MLP網絡簡單交叉的更加多樣化,使得模型更容易捕獲交叉信息。局限性在於為了提高效率進行了一系列的簡化,可能一定程度上會忽略原始特征向量中的有價值信息。

REF:
ch_blog

https://mp.weixin.qq.com/s/-WEGvWfsJGbWkQS0FbWZhQ

author github

代碼參考datawhalechina

  • Product Layer中z中每個圈都是一個向量,向量大小為Embedding Vector大小向量個數 = Field個數 = Embedding向量的個數
  • Product Layer中如果是內積,p中每個圈都是一個值;如果是外積,p中每個圓圈都是一個二維矩陣


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM