이 글은 다음 예제를 번역한 것입니다.

 

이번 튜토리얼에서는 Embedding Layer를 굉장히 자주 사용하는데, 작동 방식을 간단하게라도 알고 가면 좋을 것 같다. 물론 아래 정리한 글은 매우 정확하게 확인한 것은 아니므로 틀릴 수 있다. 만약 틀리다면 댓글로 정정좀 부탁드립니다 : )

Embedding Layer를 사용했을 때, 결과물 shape가 만들어지는 방식을 보자면

  • data_shape: (None, 500)
  • Embedding(input_dim = 10,000, output_dim = 32)을 사용했을 때,

결과적으로 (None, 500, 32) 형태를 얻게 된다.

여기서 한 단계 더 들어가면 어떻게 (500, 32) shape이 만들어지는지 궁금해질 수 있다. 추측하건대 (500, 32)가 만들어지는 방식은 다음과 같다.

  1. 먼저, Embedding Vector를 (10,000, 32) 형태로 Random weight를 가지도록 초기화한다.
  2. 다음으로 tokenizing된 [0, 1, 20, 54, ..., 100] 으로 이루어져 있는 데이터를 index로 사용해서 인덱스가 존재하는 부분은 활성화하여 Gradient Descent에 반영되도록 한다.
  3. 이 때문에 Embedding_input_dim은 주로 vacab_size를 사용하는데, index를 통해 해당 단어가 들어왔다는 것을 알려주어야 하기 때문인 것 같다.
  4. 이와 같은 방법으로 GD를 통해 계속 업데이트해서 특정 단어나 feature가 가지고 있는 특성을 Vector화시키는 것 같다.

Introduction

Movielens dataset을 활용하여 Behavior Sequence Transformer (BST) model by Qiwei Chen et al.을 사용해보는 예제입니다. BST 모델은 대상 영화에 대한 유저의 평점을 예측하기 위해 유저 프로파일, 영화 특성뿐만 아니라 영화를 보고 평가할 때 유저의 순차적인 행동을 활용합니다.

더 구체적으로는 BST 모델이 아래 input을 사용하면서 대상 영화의 평점을 예측하게 됩니다.

  1. 유저가 시청한 영화를 나타내는 고정 길이 시퀀스 movie_ids
  2. 유저가 시청한 영화의 평점을 나타내는 고정 길이 시퀀스 ratings
  3. user_id, sesx, occupation, age_group을 포함한 유저 특성
  4. 각 영화의 장르를 나타내는 genre
  5. 평점을 예측하기 위한 대상 영화를 나타내는 target_movie_id

이번 예제에서는 논문에서 나타난 BST 모델 그대로를 사용하지 않고 다음처럼 변형하여 사용합니다.

  1. 영화 특성을 "other_features"로 정의하여 transformer layer input으로 사용하지 않고 따로 concat하여 사용하는 대신, 대상 영화와 인풋 시퀀스에서의 각 영화를 임베딩을 활용하여 통합합니다.
  2. 영화 평점과 position 임베딩을 활용하여 self-attention layer에 입력할 transformer feature를 만듭니다.

TensorFlow 2.4 또는 그 이상 버전에서 정상적으로 작동합니다.


The dataset

1M version of the Movielens dataset을 활용하여 예제를 진행합니다. 데이터셋은 유저 특성, 영화 장르를 포함하여 4,000개 영화에 대해 6,000명 유저가 평가한 백만개의 평점을 포함하고 있습니다. 또, 사용자가 영화를 평가한 timestamp를 포함하고 있기 때문에 학습을 위해 BST 모델이 필요로 하는 시퀀스 형태의 영화 평점 데이터를 만들 수 있습니다.


Setup

import os
import math
from zipfile import ZipFile
from urllib.request import urlretrieve
import numpy as np
import pandas as pd
import tensorflow as tf
from tensorflow import keras
from tensorflow.keras import layers
from tensorflow.keras.layers.experimental.preprocessing import StringLookup

Prepare the data

Download and prepare the DataFrames

데이터를 다운로드합니다.

users.dat, movies.dat, ratings.dat 파일을 얻을 수 있습니다.

urlretrieve("http://files.grouplens.org/datasets/movielens/ml-1m.zip", "movielens.zip")
ZipFile("movielens.zip", "r").extractall()

사용할 column을 명시하고, 데이터를 로드합니다.

users = pd.read_csv(
    "ml-1m/users.dat",
    sep="::",
    names=["user_id", "sex", "age_group", "occupation", "zip_code"],
)

ratings = pd.read_csv(
    "ml-1m/ratings.dat",
    sep="::",
    names=["user_id", "movie_id", "rating", "unix_timestamp"],
)

movies = pd.read_csv(
    "ml-1m/movies.dat", sep="::", names=["movie_id", "title", "genres"]
)

각 column의 타입에 대해 아래와 같이 간단한 전처리 작업을 수행합니다.

users["user_id"] = users["user_id"].apply(lambda x: f"user_{x}")
users["age_group"] = users["age_group"].apply(lambda x: f"group_{x}")
users["occupation"] = users["occupation"].apply(lambda x: f"occupation_{x}")

movies["movie_id"] = movies["movie_id"].apply(lambda x: f"movie_{x}")

ratings["movie_id"] = ratings["movie_id"].apply(lambda x: f"movie_{x}")
ratings["user_id"] = ratings["user_id"].apply(lambda x: f"user_{x}")
ratings["rating"] = ratings["rating"].apply(lambda x: float(x))

다양한 영화 장르를 특성으로 다루기 위해 영화 장르를 각 컬럼으로 분리하도록 하겠습니다.
(Animation|Children's|Comedy -> Animation, Children's, Comedy) = one-hot encoding과 같습니다.

genres = [
    "Action", "Adventure", "Animation",
    "Children's", "Comedy", "Crime",
    "Documentary", "Drama", "Fantasy", "Film-Noir",
    "Horror", "Musical", "Mystery", "Romance",
    "Sci-Fi", "Thriller", "War", "Western"
]

for genre in genres:
    movies[genre] = movies["genres"].apply(
        lambda values: int(genre in values.split("|"))
    )

Transform the movie ratings data into sequences

unix_timestamp 특성을 기준으로 정렬한 뒤, user_id로 그룹핑하면, 각 유저별로 특정 timestamprating을 매긴 movie_ids를 얻을 수 있습니다.

ratings_group = ratings.sort_values(by=["unix_timestamp"]).groupby("user_id")

ratings_data = pd.DataFrame(
    data={
        "user_id": list(ratings_group.groups.keys()),
        "movie_ids": list(ratings_group.movie_id.apply(list)),
        "ratings": list(ratings_group.rating.apply(list)),
        "timestamps": list(ratings_group.unix_timestamp.apply(list)),
    }
)

모델 입력을 위해 시퀀스를 생성해야 합니다. movie_ids, ratings에 대해 고정 길이 시퀀스를 생성하는 작업을 수행합니다. sequence_length 변수에 따라 model input sequence의 길이가 달라집니다. 또한, step_size 변수를 통해 각 유저마다 보유하고 있는 시퀀스를 제어할 수 있습니다.

sequence_length = 4
step_size = 2

# [4, 4, 5, 5, 6, 6]이라면, 아래 함수를 통해
# [[4, 4, 5, 5], [5, 5, 6, 6]]으로 다시 만들어집니다.
def create_sequences(values, window_size, step_size):
    sequences = []
    start_index = 0
    while True:
        end_index = start_index + window_size
        seq = values[start_index:end_index]
        # 뒤에 남은 데이터가 window_size보다 작으면 중지합니다.
        if len(seq) < window_size:
            seq = values[-window_size:]
            # window_size와 같으면 list에 붙여줍니다.
            if len(seq) == window_size:
                sequences.append(seq)
            break
        sequences.append(seq)
        start_index += step_size
    return sequences


ratings_data.movie_ids = ratings_data.movie_ids.apply(
    lambda ids: create_sequences(ids, sequence_length, step_size)
)

ratings_data.ratings = ratings_data.ratings.apply(
    lambda ids: create_sequences(ids, sequence_length, step_size)
)

del ratings_data["timestamps"]

다음으로 유저 특성을 ratings data에 포함시킵니다. 뿐만 아니라 데이터를 시퀀스 형태로 변형합니다.

# list로 되어있는 movie_ids를 전부 펼칩니다.
ratings_data_movies = ratings_data[["user_id", "movie_ids"]].explode(
    "movie_ids", ignore_index=True
)

# ratings도 펼칩니다.
ratings_data_rating = ratings_data[["ratings"]].explode("ratings", ignore_index=True)

# movie_ids, ratings를 합칩니다.
ratings_data_transformed = pd.concat([ratings_data_movies, ratings_data_rating], axis=1)

print(ratings_data_transformed)

# user_id를 바탕으로 유저 특성을 합칩니다.
ratings_data_transformed = ratings_data_transformed.join(
    users.set_index("user_id"), on="user_id"
)

# [a, b, c, d] -> a, b, c, d
ratings_data_transformed.movie_ids = ratings_data_transformed.movie_ids.apply(
    lambda x: ",".join(x)
)

# [a, b, c, d] -> a, b, c, d
ratings_data_transformed.ratings = ratings_data_transformed.ratings.apply(
    lambda x: ",".join([str(v) for v in x])
)

del ratings_data_transformed["zip_code"]

ratings_data_transformed.rename(
    columns={"movie_ids": "sequence_movie_ids", "ratings": "sequence_ratings"},
    inplace=True,
)

sequence_length 4, step_size 2로 작업을 수행하면 총 498,623개 데이터를 만들 수 있습니다.

마지막으로 training/testing 데이터를 85%/15% 비율로 분리하고 csv로 저장합니다.


Define metadata

CSV_HEADER = list(ratings_data_transformed.columns)

CATEGORICAL_FEATURES_WITH_VOCABULARY = {
    "user_id": list(users.user_id.unique()),
    "movie_id": list(movies.movie_id.unique()),
    "sex": list(users.sex.unique()),
    "age_group": list(users.age_group.unique()),
    "occupation": list(users.occupation.unique()),
}

USER_FEATURES = ["sex", "age_group", "occupation"]

MOVIE_FEATURES = ["genres"]

Create tf.data.Dataset for training and evaluation

def get_dataset_from_csv(csv_file_path, shuffle=False, batch_size=128):
    def process(features):
        movie_ids_string = features["sequence_movie_ids"]
        # to_tensor() 변환을 해주지 않으면, RaggedTensor로 반환되기 때문에
        # 후에 indexing이 되지 않아 에러가 납니다.
        sequence_movie_ids = tf.strings.split(movie_ids_string, ",").to_tensor()

        # 마지막 movid_id를 target 값으로 사용합니다.
        features["target_movie_id"] = sequence_movie_ids[:, -1]
        features["sequence_movie_ids"] = sequence_movie_ids[:, :-1]

        ratings_string = features["sequence_ratings"]
        sequence_ratings = tf.strings.to_number(
            tf.strings.split(ratings_string, ","), tf.dtypes.float32
        ).to_tensor()

        # 마지막 rating을 target 값으로 사용합니다.
        target = sequence_ratings[:, -1]
        features["sequence_ratings"] = sequence_ratings[:, :-1]

        return features, target

    dataset = tf.data.experimental.make_csv_dataset(
        csv_file_path,
        batch_size=batch_size,
        column_names=CSV_HEADER,
        num_epochs=1,
        header=False,
        field_delim="|",
        shuffle=shuffle,
    ).map(process)

    return dataset

Create model inputs

def create_model_inputs():
    return {
        "user_id": layers.Input(name="user_id", shape=(1,), dtype=tf.string),
        "sequence_movie_ids": layers.Input(
            name="sequence_movie_ids", shape=(sequence_length - 1,), dtype=tf.string
        ),
        "target_movie_id": layers.Input(
            name="target_movie_id", shape=(1,), dtype=tf.string
        ),
        "sequence_ratings": layers.Input(
            name="sequence_ratings", shape=(sequence_length - 1,), dtype=tf.float32
        ),
        "sex": layers.Input(name="sex", shape=(1,), dtype=tf.string),
        "age_group": layers.Input(name="age_group", shape=(1,), dtype=tf.string),
        "occupation": layers.Input(name="occupation", shape=(1,), dtype=tf.string),
    }

Encode input features

encode_input_features 함수는 다음 기능을 가지고 있습니다.

  1. Categorical 특성은 vocabulary size(여기선 예를 들면 movie_id의 unique 갯수)^1/2만큼의 output_dim과 vocabulary size input_dim 크기를 가지는 layers.Embedding을 사용하여 인코딩합니다.
    --> embedding_encoder
  2. movie sequence에 포함되어 있는 영화와 target movie는 영화 갯수^1/2만큼의 output_dim과 movie_vocabulary size input_dim 크기를 가지는 layers.Embedding으로 인코딩합니다.
    --> movie_embedding_encoder
  3. movie feature에 포함되는 genre 특성은 임베딩을 통과시킨뒤, movie_embedding에 이어붙여 layers.Dense를 다시 통과시킵니다.
    --> movie_embedding_processor
  4. 그 다음, movie_embedding에 만들어진 position 임베딩을 더하고, rating을 곱합니다.(Positional Encoding 부분)
    --> encoded_sequence_movies_with_position_and_rating
  5. movie_embedding과 target_movie_embedding을 이어붙이고, 이렇게 만들어진 결과는 최종적으로 트랜스포머 attentioon layer의 입력으로 사용하기 위한 [batch size, sequence length, embedding size] 형태를 가집니다.
    --> encoded_transformer_features
  6. 이렇게 최종적으로 encoded_transformer_features, encoded_other_features 두 가지를 반환합니다.
def encode_input_features(
    inputs,
    include_user_id=True,
    include_user_features=True,
    include_movie_features=True,
):

    encoded_transformer_features = []

    # other_feature_names에 존재하는 특성들의 임베딩 표현을 담고 있습니다.
    encoded_other_features = []

    # 여기서는
    # ['user_id', 'sex', 'age_group', 'occupation']이 됩니다.
    other_feature_names = []
    if include_user_id:
        other_feature_names.append("user_id")
    if include_user_features:
        other_feature_names.extend(USER_FEATURES)

    ## 유저 정보를 인코딩합니다.
    for feature_name in other_feature_names:
        # string으로 된 값들을 전부 index 형식으로 변환합니다.
        # vocabulary가 만들어지면, input[feature_name]이 만들어진 사전에 따라
        # 해당 문자가 부여받은 index로 변환됩니다.
        vocabulary = CATEGORICAL_FEATURES_WITH_VOCABULARY[feature_name]
        idx = StringLookup(vocabulary=vocabulary, mask_token=None, num_oov_indices=0)(
            inputs[feature_name]
        )
        # 임베딩 차원을 만듭니다.
        embedding_dims = int(math.sqrt(len(vocabulary)))
        # 임베딩을 만듭니다.
        embedding_encoder = layers.Embedding(
            input_dim=len(vocabulary),
            output_dim=embedding_dims,
            name=f"{feature_name}_embedding",
        )
        # feature에 들어있는 각 값들을 임베딩 표현으로 변환합니다.
        encoded_other_features.append(embedding_encoder(idx))

    # 위에서 만들어진 임베딩 표현을 하나로 만들어줍니다.
    # 여러 개면 concatenate를 사용해서 펼쳐줍니다.
    if len(encoded_other_features) > 1:
        encoded_other_features = layers.concatenate(encoded_other_features)
    elif len(encoded_other_features) == 1:
        encoded_other_features = encoded_other_features[0]
    else:
        encoded_other_features = None

    # movie_id를 가져와서 StringLookup으로 index화 시켜줍니다.
    movie_vocabulary = CATEGORICAL_FEATURES_WITH_VOCABULARY["movie_id"]
    movie_embedding_dims = int(math.sqrt(len(movie_vocabulary)))
    movie_index_lookup = StringLookup(
        vocabulary=movie_vocabulary,
        mask_token=None, num_oov_indices=0,
        name="movie_index_lookup")
    
    # movie_id로 만든 영화 임베딩입니다.
    movie_embedding_encoder = layers.Embedding(
        input_dim=len(movie_vocabulary),
        output_dim=movie_embedding_dims,
        name=f"movie_embedding")

    # genre 임베딩을 만듭니다.
    genre_vectors = movies[genres].to_numpy()
    movie_genres_lookup = layers.Embedding(
        input_dim=genre_vectors.shape[0],
        output_dim=genre_vectors.shape[1],
        embeddings_initializer=tf.keras.initializers.Constant(genre_vectors),
        trainable=False,
        name="genres_vector")
    
    # genre를 임베딩한 후, 통과시킬 Dense layer를 선언합니다.
    movie_embedding_processor = layers.Dense(
        units=movie_embedding_dims,
        activation="relu",
        name="process_movie_embedding_with_genres")

    ## Define a function to encode a given movie id.
    def encode_movie(movie_id):
        # 해당 movie_id의 index를 얻습니다.
        movie_idx = movie_index_lookup(movie_id)
        # movie 임베딩에 통과시켜 index의 임베딩 표현을 얻습니다.
        movie_embedding = movie_embedding_encoder(movie_idx)
        encoded_movie = movie_embedding
        # 영화 특성을 포함시킬거라면,(예를 들어, 장르 등)
        if include_movie_features:
            # movie_id의 index를 genre 임베딩에 통과시킵니다.
            # 그럼 어떤 값을 얻을텐데,
            movie_genres_vector = movie_genres_lookup(movie_idx)
            # 그 값을 movie_embedding과 concat한 후, Dense layer에 통과시킵니다.
            encoded_movie = movie_embedding_processor(
                layers.concatenate([movie_embedding, movie_genres_vector])
            )
        # 최종적으로 인코딩된 movie_id와 genre 특성을 합한 값 encoded_movie를 얻습니다.
        return encoded_movie

    ## target movie id를 인코딩합니다.
    target_movie_id = inputs["target_movie_id"]
    encoded_target_movie = encode_movie(target_movie_id)

    ## 시퀀스 형태로 이뤄진 movie_id를 인코딩합니다.
    sequence_movies_ids = inputs["sequence_movie_ids"]
    encoded_sequence_movies = encode_movie(sequence_movies_ids)

    # position 처리를 위한 임베딩을 만듭니다.
    position_embedding_encoder = layers.Embedding(
        input_dim=sequence_length,
        output_dim=movie_embedding_dims,
        name="position_embedding")
    
    # 단순 포지션 번호를 만들고, 임베딩에 통과시켜 임베딩 표현을 얻습니다.
    # positions가 [0, 1, 2], 즉 길이가 3이고, movie_embedding_dims가 62라면,
    positions = tf.range(start=0, limit=sequence_length - 1, delta=1)
    # 아래 임베딩 통과 후, (None, 3, 62) 형태를 얻습니다.
    encodded_positions = position_embedding_encoder(positions)
    # 인코딩된 영화와 평점을 multiply하기 위한 형태로 바꿔줍니다.
    # (None, 3, 1) 형태로 Multiply가 가능합니다.
    sequence_ratings = tf.expand_dims(inputs["sequence_ratings"], -1)
    # 인코딩된 영화 값과 포지션 값을 더해주고, 평점과 곱해줍니다.
    # 위 형태라면 최종적으로 (None, 3, 62) 형태가 됩니다.
    encoded_sequence_movies_with_poistion_and_rating = layers.Multiply()(
        [(encoded_sequence_movies + encodded_positions), sequence_ratings]
    )

    # 위 형태를 그대로 가져와서 설명하면,
    # (1, 62)로 tf.unstack을 통해 찢고, encoded_transformer_features에 넣어줍니다.
    for encoded_movie in tf.unstack(
        encoded_sequence_movies_with_poistion_and_rating, axis=1
    ):
        encoded_transformer_features.append(tf.expand_dims(encoded_movie, 1))
    # target_movie랑 기존 movie랑 합쳐줍니다.
    encoded_transformer_features.append(encoded_target_movie)

    # 각각 따로있는 상태이므로 concat을 통해, (None, 4, 62)로 변환합니다.
    encoded_transformer_features = layers.concatenate(encoded_transformer_features, axis=1)

    return encoded_transformer_features, encoded_other_features

Create a BST model

include_user_id = False
include_user_features = False
include_movie_features = False

hidden_units = [256, 128]
dropout_rate = 0.1
num_heads = 3


def create_model():
    inputs = create_model_inputs()
    transformer_features, other_features = encode_input_features(
        inputs, include_user_id, include_user_features, include_movie_features
    )

    # MultiHead Attention 사용
    attention_output = layers.MultiHeadAttention(
        num_heads=num_heads, key_dim=transformer_features.shape[2], dropout=dropout_rate
    )(transformer_features, transformer_features)

    # Transformer block.
    attention_output = layers.Dropout(dropout_rate)(attention_output)
    x1 = layers.Add()([transformer_features, attention_output])
    x1 = layers.LayerNormalization()(x1)
    x2 = layers.LeakyReLU()(x1)
    x2 = layers.Dense(units=x2.shape[-1])(x2)
    x2 = layers.Dropout(dropout_rate)(x2)
    transformer_features = layers.Add()([x1, x2])
    transformer_features = layers.LayerNormalization()(transformer_features)
    features = layers.Flatten()(transformer_features)

    # 임베딩 표현으로 이루어진 other_features를 포함합니다.
    # Concat 시에는 shape[-1]을 통해 Flatten 후에 Concat합니다.
    if other_features is not None:
        features = layers.concatenate(
            [features, layers.Reshape([other_features.shape[-1]])(other_features)]
        )

    # Fully-connected layers, Linear 부분입니다.
    for num_units in hidden_units:
        features = layers.Dense(num_units)(features)
        features = layers.BatchNormalization()(features)
        features = layers.LeakyReLU()(features)
        features = layers.Dropout(dropout_rate)(features)

    outputs = layers.Dense(units=1)(features)
    model = keras.Model(inputs=inputs, outputs=outputs)
    return model


model = create_model()

# 모델 컴파일
model.compile(
    optimizer=keras.optimizers.Adagrad(learning_rate=0.01),
    loss=keras.losses.MeanSquaredError(),
    metrics=[keras.metrics.MeanAbsoluteError()],
)

# 학습 데이터
train_dataset = get_dataset_from_csv("train_data.csv", shuffle=True, batch_size=265)

# 학습
model.fit(train_dataset, epochs=5)

# 테스트 데이터
test_dataset = get_dataset_from_csv("test_data.csv", batch_size=265)

# 평가
_, rmse = model.evaluate(test_dataset, verbose=0)
print(f"Test MAE: {round(rmse, 3)}")

대략 0.7 MAE를 얻을 수 있습니다.


Conclusion

BST 모델은 시퀀스 형태의 유저 행동을 통해 추천할 때 필요한 연속적인 신호를 잡아내는 Transformer 구조를 사용합니다.

sequence length, epoch을 높이는 등 다양한 configuration으로 학습을 시도해볼 수 있습니다. 또, 'sex * genre'와 같은 병합 특성이나 고객 주소, 영화 개봉년도와 같은 다른 특성을 포함해볼 수도 있습니다.