多工學習模型之 ESMM 介紹與實現

語言: CN / TW / HK

多工學習背景

目前工業中使用的推薦演算法已不只侷限在單目標(ctr)任務上,還需要關注後續的轉換鏈路,如是否評論、收藏、加購、購買、觀看時長等目標。

本文介紹的是阿里巴巴團隊發表在 SIGIR’2018 的論文《Entire Space Multi-Task Model: An Effective Approach for Estimating Post-Click Conversion Rate》。文章基於 Multi-Task Learning (MTL) 的思路,提出一種名為ESMM的CVR預估模型,有效解決了真實場景中CVR預估面臨的資料稀疏以及樣本選擇偏差這兩個關鍵問題。後續還會陸續介紹MMoE,PLE,DBMTL等多工學習模型。

論文介紹

CVR預估面臨兩個關鍵問題:

1. Sample Selection Bias (SSB)

轉化是在點選之後才“有可能”發生的動作,傳統CVR模型通常以點選資料為訓練集,其中點選未轉化為負例,點選並轉化為正例。但是訓練好的模型實際使用時,則是對整個空間的樣本進行預估,而非只對點選樣本進行預估。即訓練資料與實際要預測的資料來自不同分佈,這個偏差對模型的泛化能力構成了很大挑戰,導致模型上線後,線上業務效果往往一般。

2. Data Sparsity (DS)

CVR預估任務的使用的訓練資料(即點選樣本)遠小於CTR預估訓練使用的曝光樣本。僅使用數量較小的樣本進行訓練,會導致深度模型擬合困難。

一些策略可以緩解這兩個問題,例如從曝光集中對unclicked樣本抽樣做負例緩解SSB,對轉化樣本過取樣緩解DS等。但無論哪種方法,都沒有從實質上解決上面任一個問題。

由於點選=>轉化,本身是兩個強相關的連續行為,作者希望在模型結構中顯示考慮這種“行為鏈關係”,從而可以在整個空間上進行訓練及預測。這涉及到CTR與CVR兩個任務,因此使用多工學習(MTL)是一個自然的選擇,論文的關鍵亮點正在於“如何搭建”這個MTL。

首先需要重點區分下, CVR預估任務與CTCVR預估任務。

  • CVR = 轉化數/點選數 。是預測“假設item被點選,那麼它被轉化”的概率。 CVR預估任務,與CTR沒有絕對的關係 。一個item的ctr高,cvr不一定同樣會高,如標題黨文章的瀏覽時長往往較低。這也是不能直接使用全部樣本訓練CVR模型的原因,因為無法確定那些曝光未點選的樣本,假設他們被點選了,是否會被轉化。如果直接使用0作為它們的label,會很大程度上誤導CVR模型的學習。
  • CTCVR = 轉換數/曝光數 。是預測“item被點選,然後被轉化”的概率。

其中x,y,z分別表示曝光,點選,轉換。注意到,在全部樣本空間中,CTR對應的label為click,而CTCVR對應的label為click & conversion,這兩個任務是可以使用全部樣本的。 因此,ESMM通過學習CTR,CTCVR兩個任務,再根據上式隱式地學習CVR任務。 具體結構如下:

網路結構上有兩點值得強調:

  1. 共享Embedding。 CVR-task和CTR-task使用相同的特徵和特徵embedding,即兩者從Concatenate之後才學習各自獨享的引數;
  2. 隱式學習pCVR。 這裡pCVR 僅是網路中的一個 variable,沒有顯示的監督訊號。

具體地,反映在目標函式中:

程式碼實現

基於EasyRec推薦演算法框架,我們實現了ESMM演算法,具體實現可移步至github:EasyRec-ESMM。

EasyRec介紹:EasyRec是阿里雲端計算平臺機器學習PAI團隊開源的大規模分散式推薦演算法框架,EasyRec 正如其名字一樣,簡單易用,集成了諸多優秀前沿的推薦系統論文思想,並且有在實際工業落地中取得優良效果的特徵工程方法,整合訓練、評估、部署,與阿里雲產品無縫銜接,可以藉助 EasyRec 在短時間內搭建起一套前沿的推薦系統。作為阿里雲的拳頭產品,現已穩定服務於數百個企業客戶。

模型前饋網路:

def build_predict_graph(self):
    """Forward function.

    Returns:
      self._prediction_dict: Prediction result of two tasks.
    """
    # 此處從Concatenate後的tensor(all_fea)開始,省略其生成邏輯

    cvr_tower_name = self._cvr_tower_cfg.tower_name
    dnn_model = dnn.DNN(
        self._cvr_tower_cfg.dnn,
        self._l2_reg,
        name=cvr_tower_name,
        is_training=self._is_training)
    cvr_tower_output = dnn_model(all_fea)
    cvr_tower_output = tf.layers.dense(
        inputs=cvr_tower_output,
        units=1,
        kernel_regularizer=self._l2_reg,
        name='%s/dnn_output' % cvr_tower_name)

    ctr_tower_name = self._ctr_tower_cfg.tower_name
    dnn_model = dnn.DNN(
        self._ctr_tower_cfg.dnn,
        self._l2_reg,
        name=ctr_tower_name,
        is_training=self._is_training)
    ctr_tower_output = dnn_model(all_fea)
    ctr_tower_output = tf.layers.dense(
        inputs=ctr_tower_output,
        units=1,
        kernel_regularizer=self._l2_reg,
        name='%s/dnn_output' % ctr_tower_name)

    tower_outputs = {
        cvr_tower_name: cvr_tower_output,
        ctr_tower_name: ctr_tower_output
    }
    self._add_to_prediction_dict(tower_outputs)
    return self._prediction_dict

loss計算:

注意:計算CVR的指標時需要mask掉曝光資料。

def build_loss_graph(self):
    """Build loss graph.

    Returns:
      self._loss_dict: Weighted loss of ctr and cvr.
    """
    cvr_tower_name = self._cvr_tower_cfg.tower_name
    ctr_tower_name = self._ctr_tower_cfg.tower_name
    cvr_label_name = self._label_name_dict[cvr_tower_name]
    ctr_label_name = self._label_name_dict[ctr_tower_name]

    ctcvr_label = tf.cast(
        self._labels[cvr_label_name] * self._labels[ctr_label_name], 
        tf.float32)
    cvr_loss = tf.keras.backend.binary_crossentropy(
        ctcvr_label, self._prediction_dict['probs_ctcvr'])
    cvr_loss = tf.reduce_sum(cvr_losses, name="ctcvr_loss")

    # The weight defaults to 1.
    self._loss_dict['weighted_cross_entropy_loss_%s' %
                      cvr_tower_name] = self._cvr_tower_cfg.weight * cvr_loss

    ctr_loss = tf.reduce_sum(tf.nn.sigmoid_cross_entropy_with_logits(
        labels=tf.cast(self._labels[ctr_label_name], tf.float32),
        logits=self._prediction_dict['logits_%s' % ctr_tower_name]
        ), name="ctr_loss")

    self._loss_dict['weighted_cross_entropy_loss_%s' %
                    ctr_tower_name] = self._ctr_tower_cfg.weight * ctr_loss
    return self._loss_dict

note: 這裡loss是 weighted_cross_entropy_loss_ctr + weighted_cross_entropy_loss_cvr, EasyRec框架會自動對self._loss_dict中的內容進行加和。

metric計算:

注意:計算CVR的指標時需要mask掉曝光資料。

def build_metric_graph(self, eval_config):
    """Build metric graph.

    Args:
      eval_config: Evaluation configuration.

    Returns:
      metric_dict: Calculate AUC of ctr, cvr and ctrvr.
    """
    metric_dict = {}

    cvr_tower_name = self._cvr_tower_cfg.tower_name
    ctr_tower_name = self._ctr_tower_cfg.tower_name
    cvr_label_name = self._label_name_dict[cvr_tower_name]
    ctr_label_name = self._label_name_dict[ctr_tower_name]
    for metric in self._cvr_tower_cfg.metrics_set:
      # CTCVR metric
      ctcvr_label_name = cvr_label_name + '_ctcvr'
      cvr_dtype = self._labels[cvr_label_name].dtype
      self._labels[ctcvr_label_name] = self._labels[cvr_label_name] * tf.cast(
          self._labels[ctr_label_name], cvr_dtype)
      metric_dict.update(
          self._build_metric_impl(
              metric,
              loss_type=self._cvr_tower_cfg.loss_type,
              label_name=ctcvr_label_name,
              num_class=self._cvr_tower_cfg.num_class,
              suffix='_ctcvr'))

      # CVR metric
      cvr_label_masked_name = cvr_label_name + '_masked'
      ctr_mask = self._labels[ctr_label_name] > 0
      self._labels[cvr_label_masked_name] = tf.boolean_mask(
          self._labels[cvr_label_name], ctr_mask)
      pred_prefix = 'probs' if self._cvr_tower_cfg.loss_type == LossType.CLASSIFICATION else 'y'
      pred_name = '%s_%s' % (pred_prefix, cvr_tower_name)
      self._prediction_dict[pred_name + '_masked'] = tf.boolean_mask(
          self._prediction_dict[pred_name], ctr_mask)
      metric_dict.update(
          self._build_metric_impl(
              metric,
              loss_type=self._cvr_tower_cfg.loss_type,
              label_name=cvr_label_masked_name,
              num_class=self._cvr_tower_cfg.num_class,
              suffix='_%s_masked' % cvr_tower_name))

    for metric in self._ctr_tower_cfg.metrics_set:
      # CTR metric
      metric_dict.update(
          self._build_metric_impl(
              metric,
              loss_type=self._ctr_tower_cfg.loss_type,
              label_name=ctr_label_name,
              num_class=self._ctr_tower_cfg.num_class,
              suffix='_%s' % ctr_tower_name))
    return metric_dict

實驗及不足

我們基於開源AliCCP資料,進行了大量實驗,實驗部分請期待下一篇文章。實驗發現,ESMM的蹺蹺板現象較為明顯,CTR與CVR任務的效果較難同時提升。

參考文獻

  1. Entire Space Multi-Task Model: An Effective Approach for Estimating Post-Click Conversion Rate
  2. 阿里CVR預估模型之ESMM
  3. EasyRec-ESMM使用介紹多工學習模型之ESMM介紹與實現

注:本文圖片及公示均引用自論文:Entire Space Multi-Task Model: An Effective Approach for Estimating Post-Click Conversion Rate。

原文連結

本文為阿里雲原創內容,未經允許不得轉載。