使用邊際特徵:特徵預處理

在 TensorFlow.org 上檢視 在 Google Colab 中執行 在 GitHub 上檢視原始碼 下載筆記本

使用深度學習架構來建構推薦模型的一大優勢,就是能自由建構豐富且彈性的特徵表示法。

這樣做的第一步是準備特徵,因為原始特徵通常無法立即在模型中使用。

例如

  • 使用者和項目 ID 可能是字串 (標題、使用者名稱) 或大型、非連續的整數 (資料庫 ID)。
  • 項目描述可能是原始文字。
  • 互動時間戳記可能是原始 Unix 時間戳記。

這些都需要經過適當的轉換,才能在建構模型時派上用場

  • 使用者和項目 ID 必須轉換為嵌入向量:高維度數值表示法,這些表示法會在訓練期間進行調整,以協助模型更準確地預測其目標。
  • 原始文字需要進行語詞切割 (分割成較小的部分,例如個別字詞) 並轉換為嵌入。
  • 數值特徵需要正規化,使其值落在 0 附近的小區間內。

幸運的是,透過使用 TensorFlow,我們可以將這類預處理納入模型的一部分,而不是單獨的預處理步驟。這不僅方便,也能確保我們的預處理在訓練和服務期間完全相同。這使得部署包含非常複雜預處理的模型變得安全又容易。

在本教學課程中,我們將專注於推薦系統以及我們需要在 MovieLens 資料集上進行的預處理。如果您對沒有推薦系統焦點的較大型教學課程感興趣,請參閱完整的 Keras 預處理指南

MovieLens 資料集

我們先來看看我們可以從 MovieLens 資料集使用哪些特徵

pip install -q --upgrade tensorflow-datasets
import pprint

import tensorflow_datasets as tfds

ratings = tfds.load("movielens/100k-ratings", split="train")

for x in ratings.take(1).as_numpy_iterator():
  pprint.pprint(x)
2022-12-14 12:28:42.209347: W tensorflow/compiler/xla/stream_executor/platform/default/dso_loader.cc:64] Could not load dynamic library 'libnvinfer.so.7'; dlerror: libnvinfer.so.7: cannot open shared object file: No such file or directory
2022-12-14 12:28:42.209434: W tensorflow/compiler/xla/stream_executor/platform/default/dso_loader.cc:64] Could not load dynamic library 'libnvinfer_plugin.so.7'; dlerror: libnvinfer_plugin.so.7: cannot open shared object file: No such file or directory
2022-12-14 12:28:42.209443: W tensorflow/compiler/tf2tensorrt/utils/py_utils.cc:38] TF-TRT Warning: Cannot dlopen some TensorRT libraries. If you would like to use Nvidia GPU with TensorRT, please make sure the missing libraries mentioned above are installed properly.
{'bucketized_user_age': 45.0,
 'movie_genres': array([7]),
 'movie_id': b'357',
 'movie_title': b"One Flew Over the Cuckoo's Nest (1975)",
 'raw_user_age': 46.0,
 'timestamp': 879024327,
 'user_gender': True,
 'user_id': b'138',
 'user_occupation_label': 4,
 'user_occupation_text': b'doctor',
 'user_rating': 4.0,
 'user_zip_code': b'53211'}
2022-12-14 12:28:48.039834: W tensorflow/core/kernels/data/cache_dataset_ops.cc:856] The calling iterator did not fully read the dataset being cached. In order to avoid unexpected truncation of the dataset, the partially cached contents of the dataset  will be discarded. This can happen if you have an input pipeline similar to `dataset.cache().take(k).repeat()`. You should use `dataset.take(k).cache().repeat()` instead.

這裡有幾個重要的特徵

  • 電影標題可用於電影識別。
  • 使用者 ID 可用於使用者識別。
  • 時間戳記讓我們能夠模擬時間的影響。

前兩個是類別特徵;時間戳記是連續特徵。

將類別特徵轉換為嵌入

類別特徵是一種不表示連續量的特徵,而是採用一組固定值中的一個值。

大多數深度學習模型透過將這些特徵轉換為高維度向量來表示。在模型訓練期間,會調整該向量的值,以協助模型更準確地預測其目標。

例如,假設我們的目標是預測哪個使用者會觀看哪部電影。為此,我們使用嵌入向量來表示每個使用者和每部電影。最初,這些嵌入會採用隨機值,但在訓練期間,我們會調整它們,使使用者及其觀看電影的嵌入最終更接近。

將原始類別特徵轉換為嵌入通常是一個兩步驟的過程

  1. 首先,我們需要將原始值轉換為連續整數的範圍,通常是透過建構一個對應 (稱為「詞彙表」) 將原始值 ("Star Wars") 對應到整數 (例如,15)。
  2. 其次,我們需要取得這些整數並將它們轉換為嵌入。

定義詞彙表

第一步是定義詞彙表。我們可以透過使用 Keras 預處理層輕鬆完成此操作。

import numpy as np
import tensorflow as tf

movie_title_lookup = tf.keras.layers.StringLookup()

該層本身尚未有詞彙表,但我們可以使用我們的資料來建構它。

movie_title_lookup.adapt(ratings.map(lambda x: x["movie_title"]))

print(f"Vocabulary: {movie_title_lookup.get_vocabulary()[:3]}")
WARNING:tensorflow:From /tmpfs/src/tf_docs_env/lib/python3.9/site-packages/tensorflow/python/autograph/pyct/static_analysis/liveness.py:83: Analyzer.lamba_check (from tensorflow.python.autograph.pyct.static_analysis.liveness) is deprecated and will be removed after 2023-09-23.
Instructions for updating:
Lambda fuctions will be no more assumed to be used in the statement where they are used, or at least in the same block. https://github.com/tensorflow/tensorflow/issues/56089
WARNING:tensorflow:From /tmpfs/src/tf_docs_env/lib/python3.9/site-packages/tensorflow/python/autograph/pyct/static_analysis/liveness.py:83: Analyzer.lamba_check (from tensorflow.python.autograph.pyct.static_analysis.liveness) is deprecated and will be removed after 2023-09-23.
Instructions for updating:
Lambda fuctions will be no more assumed to be used in the statement where they are used, or at least in the same block. https://github.com/tensorflow/tensorflow/issues/56089
Vocabulary: ['[UNK]', 'Star Wars (1977)', 'Contact (1997)']

一旦我們有了這個,我們就可以使用該層將原始符記轉換為嵌入 ID

movie_title_lookup(["Star Wars (1977)", "One Flew Over the Cuckoo's Nest (1975)"])
<tf.Tensor: shape=(2,), dtype=int64, numpy=array([ 1, 58])>

請注意,該層的詞彙表包含一個 (或多個!) 未知 (或「詞彙表外」,OOV) 符記。這非常方便:這表示該層可以處理詞彙表中沒有的類別值。實際上,這表示模型可以繼續學習並做出建議,即使是使用在詞彙表建構期間未曾見過的特徵。

使用特徵雜湊

事實上,StringLookup 層允許我們設定多個 OOV 索引。如果我們這樣做,任何詞彙表中沒有的原始值都將確定性地雜湊到其中一個 OOV 索引。我們擁有的這類索引越多,兩個不同的原始特徵值雜湊到同一個 OOV 索引的可能性就越小。因此,如果我們有足夠的這類索引,模型應該能夠訓練得與具有明確詞彙表的模型一樣好,而沒有必須維護符記清單的缺點。

我們可以將其推向邏輯上的極致,完全依賴特徵雜湊,完全不使用詞彙表。tf.keras.layers.Hashing 層中實作了這一點。

# We set up a large number of bins to reduce the chance of hash collisions.
num_hashing_bins = 200_000

movie_title_hashing = tf.keras.layers.Hashing(
    num_bins=num_hashing_bins
)

我們可以像以前一樣進行查找,而無需建構詞彙表

movie_title_hashing(["Star Wars (1977)", "One Flew Over the Cuckoo's Nest (1975)"])
<tf.Tensor: shape=(2,), dtype=int64, numpy=array([101016,  96565])>

定義嵌入

現在我們有了整數 ID,我們可以利用 Embedding 層將它們轉換為嵌入。

嵌入層有兩個維度:第一個維度告訴我們可以嵌入多少個不同的類別;第二個維度告訴我們表示每個類別的向量可以有多大。

當為電影標題建立嵌入層時,我們將把第一個值設定為標題詞彙表的大小 (或雜湊桶的數量)。第二個值取決於我們:它越大,模型的容量就越高,但擬合和服務的速度就越慢。

movie_title_embedding = tf.keras.layers.Embedding(
    # Let's use the explicit vocabulary lookup.
    input_dim=movie_title_lookup.vocab_size(),
    output_dim=32
)
WARNING:tensorflow:vocab_size is deprecated, please use vocabulary_size.
WARNING:tensorflow:vocab_size is deprecated, please use vocabulary_size.

我們可以將兩者放在一個單一層中,該層接收原始文字並產生嵌入。

movie_title_model = tf.keras.Sequential([movie_title_lookup, movie_title_embedding])

就像這樣,我們可以直接取得電影標題的嵌入

movie_title_model(["Star Wars (1977)"])
WARNING:tensorflow:Layers in a Sequential model should only have a single input tensor. Received: inputs=['Star Wars (1977)']. Consider rewriting this model with the Functional API.
WARNING:tensorflow:Layers in a Sequential model should only have a single input tensor. Received: inputs=['Star Wars (1977)']. Consider rewriting this model with the Functional API.
<tf.Tensor: shape=(1, 32), dtype=float32, numpy=
array([[ 0.03062222,  0.04599922,  0.02472514, -0.00171293, -0.03286115,
         0.01045175, -0.0012536 , -0.00229961,  0.00553482, -0.03525121,
         0.01329443,  0.01554203,  0.02376631, -0.01887287,  0.00513816,
        -0.04662405, -0.04039361,  0.03888489, -0.02348953,  0.03543044,
         0.04810036, -0.00186436, -0.01540039,  0.00501189,  0.04872096,
         0.02183789, -0.03257982, -0.04470251,  0.02888315, -0.04022648,
         0.0046916 , -0.04307072]], dtype=float32)>

我們可以使用使用者嵌入執行相同的操作

user_id_lookup = tf.keras.layers.StringLookup()
user_id_lookup.adapt(ratings.map(lambda x: x["user_id"]))

user_id_embedding = tf.keras.layers.Embedding(user_id_lookup.vocab_size(), 32)

user_id_model = tf.keras.Sequential([user_id_lookup, user_id_embedding])
WARNING:tensorflow:vocab_size is deprecated, please use vocabulary_size.
WARNING:tensorflow:vocab_size is deprecated, please use vocabulary_size.

正規化連續特徵

連續特徵也需要正規化。例如,timestamp 特徵太大,無法直接在深度模型中使用

for x in ratings.take(3).as_numpy_iterator():
  print(f"Timestamp: {x['timestamp']}.")
Timestamp: 879024327.
Timestamp: 875654590.
Timestamp: 882075110.

我們需要先處理它才能使用它。雖然有很多方法可以做到這一點,但離散化和標準化是兩種常見的方法。

標準化

標準化透過減去特徵的平均值並除以其標準差來重新調整特徵,以正規化其範圍。這是一種常見的預處理轉換。

這可以使用 tf.keras.layers.Normalization 層輕鬆完成

timestamp_normalization = tf.keras.layers.Normalization(
    axis=None
)
timestamp_normalization.adapt(ratings.map(lambda x: x["timestamp"]).batch(1024))

for x in ratings.take(3).as_numpy_iterator():
  print(f"Normalized timestamp: {timestamp_normalization(x['timestamp'])}.")
Normalized timestamp: [-0.8429372].
Normalized timestamp: [-1.4735202].
Normalized timestamp: [-0.27203265].

離散化

另一種常見的轉換是將連續特徵轉換為多個類別特徵。如果我們有理由懷疑特徵的效果是非連續的,那麼這樣做是合理的。

為此,我們首先需要建立我們將用於離散化的儲存區界限。最簡單的方法是識別特徵的最小值和最大值,並均勻劃分產生的區間

max_timestamp = ratings.map(lambda x: x["timestamp"]).reduce(
    tf.cast(0, tf.int64), tf.maximum).numpy().max()
min_timestamp = ratings.map(lambda x: x["timestamp"]).reduce(
    np.int64(1e9), tf.minimum).numpy().min()

timestamp_buckets = np.linspace(
    min_timestamp, max_timestamp, num=1000)

print(f"Buckets: {timestamp_buckets[:3]}")
Buckets: [8.74724710e+08 8.74743291e+08 8.74761871e+08]

給定儲存區界限,我們可以將時間戳記轉換為嵌入

timestamp_embedding_model = tf.keras.Sequential([
  tf.keras.layers.Discretization(timestamp_buckets.tolist()),
  tf.keras.layers.Embedding(len(timestamp_buckets) + 1, 32)
])

for timestamp in ratings.take(1).map(lambda x: x["timestamp"]).batch(1).as_numpy_iterator():
  print(f"Timestamp embedding: {timestamp_embedding_model(timestamp)}.")
Timestamp embedding: [[-0.00684877 -0.00895538  0.00957515 -0.01513529  0.0387269   0.00683295
   0.02544342 -0.02451316 -0.04256866  0.01276667 -0.04428785  0.02050248
  -0.01246307  0.0345451   0.02073885 -0.01192726 -0.01197611  0.0368802
   0.01981271  0.04876235 -0.00646602 -0.01923322 -0.00054507  0.03711143
   0.04613707  0.0188375   0.04404927 -0.00687717  0.01918397  0.03958556
  -0.01230479 -0.02550827]].

處理文字特徵

我們可能還想將文字特徵新增到我們的模型中。通常,產品描述之類的東西是自由形式的文字,我們可以期望我們的模型可以學習使用它們包含的資訊來做出更好的建議,尤其是在冷啟動或長尾情境中。

雖然 MovieLens 資料集沒有提供豐富的文字特徵,但我們仍然可以使用電影標題。這可能有助於我們捕捉到標題非常相似的電影可能屬於同一系列的事實。

我們需要應用於文字的第一個轉換是語詞切割 (分割成組成字詞或字詞片段),然後是詞彙表學習,然後是嵌入。

Keras tf.keras.layers.TextVectorization 層可以為我們完成前兩個步驟

title_text = tf.keras.layers.TextVectorization()
title_text.adapt(ratings.map(lambda x: x["movie_title"]))

讓我們試試看

for row in ratings.batch(1).map(lambda x: x["movie_title"]).take(1):
  print(title_text(row))
tf.Tensor([[ 32 266 162   2 267 265  53]], shape=(1, 7), dtype=int64)

每個標題都會轉換成一系列符記,每個符記對應我們語詞切割的一個片段。

我們可以檢查學習到的詞彙表,以驗證該層是否使用正確的語詞切割

title_text.get_vocabulary()[40:45]
['first', '1998', '1977', '1971', 'monty']

這看起來是正確的:該層正在將標題語詞切割成個別字詞。

為了完成處理,我們現在需要嵌入文字。由於每個標題都包含多個字詞,因此我們將為每個標題取得多個嵌入。為了在下游模型中使用,這些嵌入通常會壓縮成單一嵌入。RNN 或 Transformer 之類的模型在這裡很有用,但將所有字詞的嵌入平均在一起是一個好的起點。

整合所有元件

有了這些元件,我們可以建構一個將所有預處理整合在一起的模型。

使用者模型

完整的使用者模型可能如下所示

class UserModel(tf.keras.Model):

  def __init__(self):
    super().__init__()

    self.user_embedding = tf.keras.Sequential([
        user_id_lookup,
        tf.keras.layers.Embedding(user_id_lookup.vocab_size(), 32),
    ])
    self.timestamp_embedding = tf.keras.Sequential([
      tf.keras.layers.Discretization(timestamp_buckets.tolist()),
      tf.keras.layers.Embedding(len(timestamp_buckets) + 2, 32)
    ])
    self.normalized_timestamp = tf.keras.layers.Normalization(
        axis=None
    )

  def call(self, inputs):

    # Take the input dictionary, pass it through each input layer,
    # and concatenate the result.
    return tf.concat([
        self.user_embedding(inputs["user_id"]),
        self.timestamp_embedding(inputs["timestamp"]),
        tf.reshape(self.normalized_timestamp(inputs["timestamp"]), (-1, 1))
    ], axis=1)

讓我們試試看

user_model = UserModel()

user_model.normalized_timestamp.adapt(
    ratings.map(lambda x: x["timestamp"]).batch(128))

for row in ratings.batch(1).take(1):
  print(f"Computed representations: {user_model(row)[0, :3]}")
WARNING:tensorflow:vocab_size is deprecated, please use vocabulary_size.
WARNING:tensorflow:vocab_size is deprecated, please use vocabulary_size.
Computed representations: [-0.02608593  0.0144566   0.01150807]

電影模型

我們可以對電影模型執行相同的操作

class MovieModel(tf.keras.Model):

  def __init__(self):
    super().__init__()

    max_tokens = 10_000

    self.title_embedding = tf.keras.Sequential([
      movie_title_lookup,
      tf.keras.layers.Embedding(movie_title_lookup.vocab_size(), 32)
    ])
    self.title_text_embedding = tf.keras.Sequential([
      tf.keras.layers.TextVectorization(max_tokens=max_tokens),
      tf.keras.layers.Embedding(max_tokens, 32, mask_zero=True),
      # We average the embedding of individual words to get one embedding vector
      # per title.
      tf.keras.layers.GlobalAveragePooling1D(),
    ])

  def call(self, inputs):
    return tf.concat([
        self.title_embedding(inputs["movie_title"]),
        self.title_text_embedding(inputs["movie_title"]),
    ], axis=1)

讓我們試試看

movie_model = MovieModel()

movie_model.title_text_embedding.layers[0].adapt(
    ratings.map(lambda x: x["movie_title"]))

for row in ratings.batch(1).take(1):
  print(f"Computed representations: {movie_model(row)[0, :3]}")
WARNING:tensorflow:vocab_size is deprecated, please use vocabulary_size.
WARNING:tensorflow:vocab_size is deprecated, please use vocabulary_size.
Computed representations: [ 0.00771372 -0.03204167 -0.02703585]

後續步驟

透過上述兩個模型,我們已經邁出了在推薦模型中表示豐富特徵的第一步:若要進一步瞭解如何使用這些特徵來建構有效的深度推薦模型,請參閱我們的深度推薦系統教學課程。