如何實現支持多值、稀疏、共享權重的DeepFM

緣起

DeepFM不算什麼新技術了,用TensorFlow實現DeepFM也有開源實現,那我爲什麼要炒這個冷飯,重複造輪子?

用Google搜索“TensorFlow+DeepFM”,一般都能搜索到 “tensorflow-DeepFM” [1] 和“TensorFlow Estimator of DeepFM” [2]這二位的實現。二位不僅用TensorFlow實現了DeepFM,還在Criteo數據集上,給出了完整的訓練、測試的代碼,的確給了我很大的啓發,在這裏要表示感謝。

但是,同樣是由於二位的實現都是根據Criteo簡單數據集的,使他們的代碼,如果移植到實際的推薦系統中,存在一定困難。比如:

稀疏要求。儘管criteo的原始數據集是排零存儲的,但是以上的兩個實現,都是用稠密矩陣來表示輸入,將0又都補了回來。這種做法,在criteo這種只有39列的簡單數據集上是可行的,但是實際系統中,特徵數量以千、萬計,這種稀疏轉稠密的方式是不可取的

一列多值的要求。Criteo數據集有13列numeric特徵+26列categorical特徵,所有列都只有一個值。但是,在實際系統中,一個field下往往有多個feature:value。比如,我們用三個field來描述一個用戶的手機 使用習慣,“近xxx天活躍app”+“近xxx天新安裝app”+“近xxx天卸載app”。每個field下,再有“微信:0.9,微博:0.5,淘寶:0.3,……”等一系列的feature和它們的數值。

這個要求固然可以通過,去除field這個“特徵單位”,只針對一個個獨立的feature來建模。但是,這樣一來,既憑空增加了模型的規模,又破壞模型的“層次化”與“模塊化”,使代碼不易擴展與維護。

權值共享的要求。Criteo數據集經過脫敏感處理,我們無法知道每列的具體含義,自然也就沒有列與列之間共享權重的需求,以上提到的兩個實現也就只用一整塊稠密矩陣來建模embedding矩陣。

但是,以上面提到的“近xxx天活躍app”+“近xxx天新安裝app”+“近xxx天卸載app”這三個field爲例,這些 field中的feature都來源於同一個”app字典”。如果不做權重共享,

  • 每個field都使用獨立的embedding矩陣來映射app向量,整個模型需要優化的變量是共享權重模型的3倍,既耗費了更多的計算資源,也容易導致過擬合。
  • 每個field的稀疏程度是不一樣的,同一個app,在“活躍列表”中出現得更頻繁,其embedding向量就有更多的訓練機會,而在“卸載列表”中較少出現,其embedding向量得不到足夠訓練,恐怕最後與隨機初始化無異。

因此,在實際系統中,“共享權重”是必須的

  • 減小優化變量的數目,既節省計算資源,又減輕“過擬合”風險
  • 同一個embedding矩陣,爲多個field提供映射向量,類似於“多任務學習”,使每個embedding向量得到更多的訓練機會,同時也要滿足多個field的需求(比如同一個app的向量,既要體現‘經常使用它’對y的影響,也要體現‘卸載它’對y值的影響),也降低了“過擬合”的風險。

正因爲在目前我能夠找到的基於TensorFlow實現的DeepFM中,沒有一個能夠滿足以上“稀疏”、“多值”、“共享權重”這三個要求的,所以,我自己動手實現了一個,代碼見我的Github[3]。接下來,我簡單講解一下我的代碼。

數據預處理

我依然用criteo數據集來做演示之用。爲了演示“一列多值”和“稀疏”,我把criteo中的特徵分爲兩個field,所有數值特徵I1~I13歸爲numeric field,所有類別特徵C1~C26歸爲categorical field。需要特別指出的是:

  • 這種處理方法,不是爲了提高criteo數據集上的模型性能,只是爲了模擬實際系統中將會遇到的“一列多值”和“稀疏”數據集。接下來會看到,DeepFM中,FM中的二階交叉,不會受拆分成兩個field的影響。受影響的主要是Deep側的輸入層,詳情見”DNN預測部分”一節 。
  • 另外,criteo數據集無法演示“權重共享”的功能。

對criteo中數值特徵與類別特徵,都是最常規的預處理,不是這次演示的重點

  • 數值特徵,因爲多數表示"次數",因此先做了一個log變化,減弱長尾數據的影響,再做了一個min/max scaling,畢竟底層還是線性算法,要排除特徵間不同scale的影響。注意,千萬不能做“zero mean, unit variance”的standardize,因爲那樣會破壞數據的稀疏性。
  • 類別特徵,剔除了一些生僻的tag,建立字典,將原始數據中的字符串tag轉化爲整數的index

預處理的代碼見criteo_data_preproc.py,處理好的數據文件如下所示,圖中的亮塊是列分隔符。可以看到,每列是由多個tag_index:value“鍵值對”組成的,而不同行中“鍵值對”個數互不同,而value絕沒有0,實現排零、稀疏存儲。

輸入數據

input_fn

爲了配合TensorFlow Estimator,我們需要定義input_fn來讀取上圖所示的數據。看似簡單的任務,實現起來,卻很花費了我一番功夫:

  • 網上能夠搜到的TensorFlow讀文本文件的代碼,都是讀“每列只有一個值的csv”這樣規則的數據格式。但是,上圖所示的數據,卻非常不規則,每行先是由“\t”分隔,第列中再由“,”分隔成數目不同的“鍵值對”,每個‘鍵值對’再由“:”分隔。
  • 我希望提供給model稀疏矩陣,方便model中排零計算,提升效率。

最終,解析一行文本的代碼如下。

def _decode_tsv(line):
    columns = tf.decode_csv(line, record_defaults=DEFAULT_VALUES, field_delim='\t')
    y = columns[0]

    feat_columns = dict(zip((t[0] for t in COLUMNS_MAX_TOKENS), columns[1:]))
    X = {}
    for colname, max_tokens in COLUMNS_MAX_TOKENS:
        # 調用string_split時,第一個參數必須是一個list,所以要把columns[colname]放在[]中
        # 這時每個kv還是'k:v'這樣的字符串
        kvpairs = tf.string_split([feat_columns[colname]], ',').values[:max_tokens]

        # k,v已經拆開, kvpairs是一個SparseTensor,因爲每個kvpair格式相同,都是"k:v"
        # 既不會出現"k",也不會出現"k:v1:v2:v3:..."
        # 所以,這時的kvpairs實際上是一個滿陣
        kvpairs = tf.string_split(kvpairs, ':')

        # kvpairs是一個[n_valid_pairs,2]矩陣
        kvpairs = tf.reshape(kvpairs.values, kvpairs.dense_shape)

        feat_ids, feat_vals = tf.split(kvpairs, num_or_size_splits=2, axis=1)
        feat_ids = tf.string_to_number(feat_ids, out_type=tf.int32)
        feat_vals = tf.string_to_number(feat_vals, out_type=tf.float32)

        # 不能調用squeeze, squeeze的限制太多, 當原始矩陣有1行或0行時,squeeze都會報錯
        X[colname + "_ids"] = tf.reshape(feat_ids, shape=[-1])
        X[colname + "_values"] = tf.reshape(feat_vals, shape=[-1])

    return X, y

然後,將整個文件轉化成TensorFlow Dataset的代碼如下所示。每一個field“xxx”在dataset中將由兩個SparseTensor表示,“xxx_ids”表示sparse ids,“xxx_values”表示sparse values。


def input_fn(data_file, n_repeat, batch_size, batches_per_shuffle):
    # ----------- prepare padding
    pad_shapes = {}
    pad_values = {}
    for c, max_tokens in COLUMNS_MAX_TOKENS:
        pad_shapes[c + "_ids"] = tf.TensorShape([max_tokens])
        pad_shapes[c + "_values"] = tf.TensorShape([max_tokens])

        pad_values[c + "_ids"] = -1  # 0 is still valid token-id, -1 for padding
        pad_values[c + "_values"] = 0.0

    # no need to pad labels
    pad_shapes = (pad_shapes, tf.TensorShape([]))
    pad_values = (pad_values, 0)

    # ----------- define reading ops
    dataset = tf.data.TextLineDataset(data_file).skip(1)  # skip the header
    dataset = dataset.map(_decode_tsv, num_parallel_calls=4)

    if batches_per_shuffle > 0:
        dataset = dataset.shuffle(batches_per_shuffle * batch_size)

    dataset = dataset.repeat(n_repeat)
    dataset = dataset.padded_batch(batch_size=batch_size,
                                   padded_shapes=pad_shapes,
                                   padding_values=pad_values)

    iterator = dataset.make_one_shot_iterator()
    dense_Xs, ys = iterator.get_next()

    # ----------- convert dense to sparse
    sparse_Xs = {}
    for c, _ in COLUMNS_MAX_TOKENS:
        for suffix in ["ids", "values"]:
            k = "{}_{}".format(c, suffix)
            sparse_Xs[k] = tf_utils.to_sparse_input_and_drop_ignore_values(dense_Xs[k])

    # ----------- return
    return sparse_Xs, ys

其中也不得不調用padded_batch補齊,這一步也將稀疏格式轉化成了稠密格式,不過只是在一個batch(batch_size=128已經算很大了)中臨時稠密一下,很快就又通過調用to_sparse_input_and_drop_ignore_values這個函數重新轉化成稀疏格式了。to_sparse_input_and_drop_ignore_values實際上是從feature_column.py這個module中的_to_sparse_input_and_drop_ignore_values 函數拷貝而來,因爲原函數不是public的,無法在featurecolumn.py以外調用,所以我將它的代碼拷貝到tf_utils.py中。

建立共享權重

重申幾個概念。比如我們的特徵集中包括active_pkgs(app活躍情況)、install_pkgs(app安裝情況)、uninstall_pkgs(app卸載情況)。每列包含的內容是一系列feature和其數值,比如qq:0.1, weixin:0.9, taobao:1.1, ……。這些feature都來源於同一份名爲package的字典

  • field就是active_pkgs、install_pkgs、uninstall_pkgs這些大類,是DataFrame中的每一列
  • feature就是每個field下包含的具體內容,一個field下允許存在多個feature,比如前面提到的qq, weixin, taobao這樣的app名稱。
  • vocabulary對應例子中的“package字典”。不同field下的feature可以來自同一個vocabulary,即若干field共享vocabulary

建立共享權重的代碼如下所示:

  • 一個vocab對應兩個embedding矩陣,一個對應FM中的線性部分的權重,另一個對應FM與DNN共享的隱向量(用於二階與高階交叉)。
  • 所有embedding矩陣,以”字典名”存入dict。不同field只要指定相同的“字典名”,就可以共享同一套embedding矩陣。

class EmbeddingTable:
    def __init__(self):
        self._weights = {}

    def add_weights(self, vocab_name, vocab_size, embed_dim):
        """
        :param vocab_name: 一個field擁有兩個權重矩陣,一個用於線性連接,另一個用於非線性(二階或更高階交叉)連接
        :param vocab_size: 字典總長度
        :param embed_dim: 二階權重矩陣shape=[vocab_size, order2dim],映射成的embedding
                          既用於接入DNN的第一屋,也是用於FM二階交互的隱向量
        :return: None
        """
        linear_weight = tf.get_variable(name='{}_linear_weight'.format(vocab_name),
                                        shape=[vocab_size, 1],
                                        initializer=tf.glorot_normal_initializer(),
                                        dtype=tf.float32)

        # 二階(FM)與高階(DNN)的特徵交互,共享embedding矩陣
        embed_weight = tf.get_variable(name='{}_embed_weight'.format(vocab_name),
                                       shape=[vocab_size, embed_dim],
                                       initializer=tf.glorot_normal_initializer(),
                                       dtype=tf.float32)

        self._weights[vocab_name] = (linear_weight, embed_weight)

    def get_linear_weights(self, vocab_name): return self._weights[vocab_name][0]

    def get_embed_weights(self, vocab_name): return self._weights[vocab_name][1]


def build_embedding_table(params):
    embed_dim = params['embed_dim']  # 必須有統一的embedding長度

    embedding_table = EmbeddingTable()
    for vocab_name, vocab_size in params['vocab_sizes'].items():
        embedding_table.add_weights(vocab_name=vocab_name, vocab_size=vocab_size, embed_dim=embed_dim)

    return embedding_table

線性預測部分


def output_logits_from_linear(features, embedding_table, params):
    field2vocab_mapping = params['field_vocab_mapping']
    combiner = params.get('multi_embed_combiner', 'sum')

    fields_outputs = []
    # 當前field下有一系列的<tag:value>對,每個tag對應一個bias(待優化),
    # 將所有tag對應的bias,按照其value進行加權平均,得到這個field對應的bias
    for fieldname, vocabname in field2vocab_mapping.items():
        sp_ids = features[fieldname + "_ids"]
        sp_values = features[fieldname + "_values"]

        linear_weights = embedding_table.get_linear_weights(vocab_name=vocabname)

        # weights: [vocab_size,1]
        # sp_ids: [batch_size, max_tags_per_example]
        # sp_weights: [batch_size, max_tags_per_example]
        # output: [batch_size, 1]
        output = embedding_ops.safe_embedding_lookup_sparse(linear_weights, sp_ids, sp_values,
                                                            combiner=combiner,
                                                            name='{}_linear_output'.format(fieldname))

        fields_outputs.append(output)

    # 因爲不同field可以共享同一個vocab的linear weight,所以將各個field的output相加,會損失大量的信息
    # 因此,所有field對應的output拼接起來,反正每個field的output都是[batch_size,1],拼接起來,並不佔多少空間
    # whole_linear_output: [batch_size, total_fields]
    whole_linear_output = tf.concat(fields_outputs, axis=1)
    tf.logging.info("linear output, shape={}".format(whole_linear_output.shape))

    # 再映射到final logits(二分類,也是[batch_size,1])
    # 這時,就不要用任何activation了,特別是ReLU
    return tf.layers.dense(whole_linear_output, units=1, use_bias=True, activation=None)

二階交互預測部分

二階交互部分與DeepFM論文中稍有不同,而是使用了《Neural Factorization Machines for Sparse Predictive Analytics》中Bi-Interaction的公式。這也是網上實現的通用做法。

而我的實現與上邊公式最大的不同,就是不再只有一個embedding矩陣V,而是每個feature根據自己所在的field,再根據超參指定的field與vocabulary的映射關係,找到自己對應的embedding矩陣。某個field對應的embedding矩陣有可能是與另外一個field共享的。

另外,實現了稀疏矩陣相乘,基於embedding_ops. safe_embedding_lookup_sparse實現。


def output_logits_from_bi_interaction(features, embedding_table, params):
    field2vocab_mapping = params['field_vocab_mapping']

    # 論文上的公式就是要求sum,而且我也試過mean和sqrtn,都比用mean要差上很多
    # 但是,這種情況,僅僅是針對criteo數據的,還是理論上就必須用sum,而不能用mean和sqrtn
    # 我還不太確定,所以保留一個接口能指定其他combiner的方法
    combiner = params.get('multi_embed_combiner', 'sum')

    # 見《Neural Factorization Machines for Sparse Predictive Analytics》論文的公式(4)
    fields_embeddings = []
    fields_squared_embeddings = []

    for fieldname, vocabname in field2vocab_mapping.items():
        sp_ids = features[fieldname + "_ids"]
        sp_values = features[fieldname + "_values"]

        # --------- embedding
        embed_weights = embedding_table.get_embed_weights(vocabname)
        # embedding: [batch_size, embed_dim]
        embedding = embedding_ops.safe_embedding_lookup_sparse(embed_weights, sp_ids, sp_values,
                                                               combiner=combiner,
                                                               name='{}_embedding'.format(fieldname))
        fields_embeddings.append(embedding)

        # --------- square of embedding
        squared_emb_weights = tf.square(embed_weights)

        squared_sp_values = tf.SparseTensor(indices=sp_values.indices,
                                            values=tf.square(sp_values.values),
                                            dense_shape=sp_values.dense_shape)

        # squared_embedding: [batch_size, embed_dim]
        squared_embedding = embedding_ops.safe_embedding_lookup_sparse(squared_emb_weights, sp_ids, squared_sp_values,
                                                                       combiner=combiner,
                                                                       name='{}_squared_embedding'.format(fieldname))
        fields_squared_embeddings.append(squared_embedding)

    # calculate bi-interaction
    sum_embedding_then_square = tf.square(tf.add_n(fields_embeddings))  # [batch_size, embed_dim]
    square_embedding_then_sum = tf.add_n(fields_squared_embeddings)  # [batch_size, embed_dim]
    bi_interaction = 0.5 * (sum_embedding_then_square - square_embedding_then_sum)  # [batch_size, embed_dim]
    tf.logging.info("bi-interaction, shape={}".format(bi_interaction.shape))

    # calculate logits
    logits = tf.layers.dense(bi_interaction, units=1, use_bias=True, activation=None)

    # 因爲FM與DNN共享embedding,所以除了logits,還返回各field的embedding,方便搭建DNN
    return logits, fields_embeddings

DNN預測部分

再次聲明,將criteo中原來的39列,拆分成2個field,並不是爲了提升預測性能,只是爲了模擬實際場景。導致的後果就是,Deep側第一層的輸入由原來的[batch_size, 39*embed_dim]變成了[batch_size, 2*embed_dim],使Deep側交叉不足。

儘管在criteo數據集上,deep側的輸入由feature_size*embed_dim變成了field_size*embed_dim,限制了交叉能力。但是,在實際系統中,field_size已經是成千上萬了,而每個field下的feature又是成千上萬,而且,因爲embedding是稠密的,沒有稀疏優化的可能性。因此,在接入deep側之前,每個field內部先做一層pooling,將deep側輸入由feature_size*embed_dim壓縮成field_size*embed_dim,對於大規模機器學習,是十分必要的

DNN的代碼如下所示。可以看到,其中沒有加入L1/L2 regularization,這是模仿TensorFlow自帶的Wide & Deep實現DNNLinearCombinedClassifier的寫法。L1/L2正則將通過設置optimizer的參數來實現。


def output_logits_from_dnn(fields_embeddings, params, is_training):
    dropout_rate = params['dropout_rate']
    do_batch_norm = params['batch_norm']

    X = tf.concat(fields_embeddings, axis=1)
    tf.logging.info("initial input to DNN, shape={}".format(X.shape))

    for idx, n_units in enumerate(params['hidden_units'], start=1):
        X = tf.layers.dense(X, units=n_units, activation=tf.nn.relu)
        tf.logging.info("layer[{}] output shape={}".format(idx, X.shape))

        X = tf.layers.dropout(inputs=X, rate=dropout_rate, training=is_training)
        if is_training:
            tf.logging.info("layer[{}] dropout {}".format(idx, dropout_rate))

        if do_batch_norm:
            # BatchNormalization的調用、參數,是從DNNLinearCombinedClassifier源碼中拷貝過來的
            batch_norm_layer = normalization.BatchNormalization(momentum=0.999, trainable=True,
                                                                name='batchnorm_{}'.format(idx))
            X = batch_norm_layer(X, training=is_training)

            if is_training:
                tf.logging.info("layer[{}] batch-normalize".format(idx))

    # connect to final logits, [batch_size,1]
    return tf.layers.dense(X, units=1, use_bias=True, activation=None)

model_fn

前面的代碼完成了“線性預測”+“二次交叉預測”+“深度預測”,則model_fn的實現就非常簡單了,只不過將三個部分得到的logits相加就可以了。

def model_fn(features, labels, mode, params):
    for featname, featvalues in features.items():
        if not isinstance(featvalues, tf.SparseTensor):
            raise TypeError("feature[{}] isn't SparseTensor".format(featname))

    # ============= build the graph
    embedding_table = build_embedding_table(params)

    linear_logits = output_logits_from_linear(features, embedding_table, params)

    bi_interact_logits, fields_embeddings = output_logits_from_bi_interaction(features, embedding_table, params)

    dnn_logits = output_logits_from_dnn(fields_embeddings, params, (mode == tf.estimator.ModeKeys.TRAIN))

    general_bias = tf.get_variable(name='general_bias', shape=[1], initializer=tf.constant_initializer(0.0))

    logits = linear_logits + bi_interact_logits + dnn_logits
    logits = tf.nn.bias_add(logits, general_bias)  # bias_add,獲取broadcasting的便利

    # reshape [batch_size,1] to [batch_size], to match the shape of 'labels'
    logits = tf.reshape(logits, shape=[-1])

    probabilities = tf.sigmoid(logits)

    # ============= predict spec
    if mode == tf.estimator.ModeKeys.PREDICT:
        return tf.estimator.EstimatorSpec(
            mode=mode,
            predictions={'probabilities': probabilities})

    # ============= evaluate spec
    # STUPID TENSORFLOW CANNOT AUTO-CAST THE LABELS FOR ME
    loss = tf.reduce_mean(tf.nn.sigmoid_cross_entropy_with_logits(logits=logits, labels=tf.cast(labels, tf.float32)))

    eval_metric_ops = {'auc': tf.metrics.auc(labels, probabilities)}
    if mode == tf.estimator.ModeKeys.EVAL:
        return tf.estimator.EstimatorSpec(
            mode=mode,
            loss=loss,
            eval_metric_ops=eval_metric_ops)

    # ============= train spec
    assert mode == tf.estimator.ModeKeys.TRAIN
    train_op = params['optimizer'].minimize(loss, global_step=tf.train.get_global_step())
    return tf.estimator.EstimatorSpec(mode,
                                      loss=loss,
                                      train_op=train_op,
                                      eval_metric_ops=eval_metric_ops)

訓練與評估

完成了model_fn之後,拜TensorFlow Estimator框架所賜,訓練與評估變得非常簡單,設定超參數之後(注意在指定optimizer時設置了L1/L2的正則權重),調用tf.estimator.train_and_evaluate即可。

def get_hparams():
    vocab_sizes = {
        'numeric': 13,
        # there are totally 14738 categorical tags occur >= 200
        # since 0 is reserved for OOV, so total vocab_size=14739
        'categorical': 14739
    }

    optimizer = tf.train.ProximalAdagradOptimizer(
        learning_rate=0.01,
        l1_regularization_strength=0.001,
        l2_regularization_strength=0.001)

    return {
        'embed_dim': 128,
        'vocab_sizes': vocab_sizes,
        # 在這個case中,沒有多個field共享同一個vocab的情況,而且field_name和vocab_name相同
        'field_vocab_mapping': {'numeric': 'numeric', 'categorical': 'categorical'},
        'dropout_rate': 0.3,
        'batch_norm': False,
        'hidden_units': [64, 32],
        'optimizer': optimizer
    }


if __name__ == "__main__":
    tf.logging.set_verbosity(tf.logging.INFO)
    tf.set_random_seed(999)

    hparams = get_hparams()
    deepfm = tf.estimator.Estimator(model_fn=model_fn,
                                    model_dir='models/criteo',
                                    params=hparams)

    train_spec = tf.estimator.TrainSpec(input_fn=lambda: input_fn(data_file='dataset/criteo/whole_train.tsv',
                                                                  n_repeat=10,
                                                                  batch_size=128,
                                                                  batches_per_shuffle=10))

    eval_spec = tf.estimator.EvalSpec(input_fn=lambda: input_fn(data_file='dataset/criteo/whole_test.tsv',
                                                                n_repeat=1,
                                                                batch_size=128,
                                                                batches_per_shuffle=-1))

    tf.estimator.train_and_evaluate(deepfm, train_spec, eval_spec)

測試集上的部分結果所下所示,測試集上的AUC在0.765左右,沒有Kaggle solution上0.8+的AUC高。正如前文所說的,將原來criteo數據集中的39列拆分成2個field,只是爲了演示“一列多值”、“稀疏”的DeepFM實現,但限制了Deep側的交叉能力,對最終模型的性能造成一定負面影響。不過,仍然證明,文中展示的DeepFM實現是正確的。

小結

本文展示了我寫的一套基於TensorFlow的DeepFM的實現。重點闡述了“一列多值”、“稀疏”、“權重共享”在實際推薦系統中的重要性,和我是如何在DeepFM中實現以上需求的。歡迎各位看官指正。

參考鏈接:

[1] https://github.com/ChenglongChen/tensorflow-DeepFM

[2] https://zhuanlan.zhihu.com/p/33699909

[3] https://github.com/stasi009/Recommend-Estimators/ blob/master/deepfm.py

原文鏈接:

https://zhuanlan.zhihu.com/p/48057256

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章