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

 

이번 튜토리얼에서는 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'와 같은 병합 특성이나 고객 주소, 영화 개봉년도와 같은 다른 특성을 포함해볼 수도 있습니다.

이 글은 Data Scientist이신 Roman Orac님의 '5 Reasons You Don't Need to Learn Machine Learning'(link click) 글을 번역한 것입니다.

  • 저자 동의하에 번역을 진행하였다는 점을 알려드립니다.

머신러닝 분야에 몸담고 있는 많은 인플루언서는 1) 우리가 왜 머신러닝 학습을 시작해야하는지에 대한 이유와 2) 일단 시작해보면 머신러닝을 얼마나 쉽게 배울 수 있는지 설명하는 글들을 작성하고 있습니다. 트위터, 링크드인, Medium, Reddit 등 이를 위한 엄청난 양의 글이 있어, 이 중에서도 자신에게 도움이 되지 않는 글을 걸러내는 것도 실력이라는 말이 나올 정도입니다.

글을 읽다보면 마치 우리가 머지않아 전문가가 될 것 같고, 머신러닝을 적용해서 다양한 문제를 해결할 수 있을 것 같은 느낌을 받게 됩니다. 하지만 이번 글에서는 이를 다른 관점으로 바라보고자 합니다. 관련된 다른 글을 비난하거나 비웃자는 글이 아니며, 지극히 주관적인 생각을 다룹니다.

많은 머신러닝 전문가(또는 여기서 부르는 인플루언서)가 작성한 글을 보면서, '왜 많은 사람들이 처음에 머신러닝부터 배우려고 할까?'에 대한 의문이 떠올랐습니다.

아마 주요한 이유는 많은 사람들이 머신러닝을 공부할 때 주로 접할 수 있는 환경과 실제 머신러닝 엔지니어가 머신러닝을 수행하고 있는 환경의 차이를 모르기 때문일 것입니다. 실제로 일부를 제외한 다수의 머신러닝 엔지니어는 뉴스에서 자주 접할 수 있는 알파고와 같은 인공지능이나 테슬라의 전기차와 같은 자율주행을 연구하고 있지 않습니다.

많은 인플루언서가 작성한 글을 공부한다고 해서 머신러닝 전문가가 될 수 없습니다. 또, 하나에 집중하지 않고, 많은 부분을 습득하려 한다면 절대로 이를 달성할 수 없습니다("A Jack of all trades and master of none").

 

Why do so many wish to learn Machine Learning?

Photo by Ariana Prestes on Unsplash

대학을 다니면서 머신러닝 엔지니어가 되기로 결심했습니다. 어렵고, 도전적이며, 특히 매우 재밌어보였기 때문입니다. iOS 게임 개발자가 되겠다고 선택하기 전까진 말이죠.

누군가 나에게 ML 엔지니어로서의 어떠한 것을 권유하더라도, iOS 게임 개발자라는 커리어을 버리지 않을 것입니다. iOS 게임 개발로 커리어를 쌓는게 매우 행복하고, 직업 선택이 예전처럼 극단적(black and white) 결과로 나아가지 않기 때문에 이에 대한 나의 선택이 온전히 틀렸다고 말할 수 없습니다.

Photo by KOBU Agency on Unsplash

왜 iOS 게임 개발로 행복을 느낄 수 있을까요? 그 이유는 머신러닝을 통해 학습할 때의 재미와 iOS 게임 개발 할때의 재미가 같기 때문입니다. 또는 백엔드, 프론트엔드 애플리케이션 개발을 포함해서 말이죠. 여기서 언급한 세 가지도 충분히 도전적인 분야입니다.

대학을 다니면서 머신러닝을 바라보는 관점은 아래와 같았습니다.(아마도 머신러닝을 얕게 알고있는 많은 사람들이 이와 같은 생각을 하지 않을까 싶습니다. 아래와 같은 생각이 틀렸다는 것은 아니고 어떻게 보면 머신러닝을 학습하고자 하는 사람이 겪을 수 있는 당연한 과정인 것 같습니다.)

머신러닝은 어렵지만(Seems hard), 일자리를 쉽게 얻을 수 있다(ML Job).

재밌고(Fun), 고액 연봉을 받을 수 있으며(High wages),

모든 작업이 자동화될 것인 웹 개발자와 다르게 미래가 보장되어 있다(Future proof).

하지만 나는 이 생각이 틀렸다고 느꼈고, 그 이유를 차근차근 설명해보겠습니다.

1. Machine Learning seems hard

Photo by NOAA on Unsplash

인플루언서들이 작성한 글을 보면 머신러닝을 굉장히 쉽게 다루고 있습니다. 타이타닉 데이터셋을 다운로드하고, 단 10줄의 파이썬 코드로 머신러닝 튜토리얼을 끝마칠 수 있습니다.

실제로 해보면 쉽게 타이타닉 사망자/생존자를 예측할 수 있습니다. 하지만 이를 할 수 있다고해서 돈을 주고 우리를 고용할 필요가 있을까요? 더욱 매력적인 머신러닝 엔지니어임을 어필하려면, 더욱 깊은 수준의 기술을 배우고 사용할 수 있어야 합니다.

모든 것을 다 이해할 수 없을뿐만 아니라 그럴 필요도 없기 때문에 머신러닝을 더 깊이 배우는 것은 더욱 어렵습니다. 더 깊이 배우는 것에 좀 더 쉽게 다가가기 위해선 자신 주변에 훌륭한 멘토가 있다는 것이 매우 중요한 요소인데 현실에서 이를 구한다는 것은 매우 힘든 일입니다. 아마도 훌륭한 멘토를 얻을 수 있는 가장 좋은 방법은 훌륭한 인턴쉽을 수행하는 것이 아닐까요?

(머신러닝 배우는게 매우 어렵고, 멘토 구하기도 힘들기 때문에) 내가 첫 커리어를 시작할 때, 누군가가 이에 대해 말해줬다면 매우 좋았을 것 같습니다. Computer Science가 아닌 다른 분야의 동료와 함께 일할 때, 그와 관련된 것(프론트엔드, 백엔드, 모바일 등)을 배우기 위해 상당한 시간이 투자되었기 때문입니다.

왜냐고요? 음.. 프론트엔드(또는 백엔드, 모바일) 개발에서 멘토를 구하기도 쉽고, 많은 사람들이 이 분야를 하고 있으니까요.

2. Easier to get a Machine Learning Job

Photo by Hunters Race on Unsplash

프론트엔드(또는 백엔드, 모바일) 개발자보다 머신러닝 엔지니어로서 일자리를 구하기 매우 어렵다는 것은 확실하게 말할 수 있습니다.

자그마한 스타트업은 보통 ML Engieer를 감당할만한 자원(Resource)을 가지고 있지 않습니다. 이제 막 시작했기 때문에 데이터'도' 없기 때문이죠. 그렇다면 그들이 뭐가 필요한지 아세요? 소비자에게 비즈니스를 제공할 수 있는 프론트, 백엔드, 모바일 엔지니어입니다.

스타트업이 안되면, 대기업을 가면 되지 않을까요? 뭐 틀린 생각은 아니지만, 실제로 취업 시장에 뛰어들어보면 ML Engineer를 필요로하는 대기업이 많지 않고, 찾아보기도 힘듭니다.

3. Higher wages

Photo by Sharon McCutcheon on Unsplash

Senior ML Engineer는 다른 Senior보다 더 많은 돈을 벌고 있지 않습니다.

미국에는 머신러닝 슈퍼스타가 그들의 사고방식(mindset)에 따라, 또는 돈에 구애받지 않고 자신이 원하는 곳에서 일을 하는 것도 있지만, 사실 미국에서는 이보다 더 높은 돈을 받는 소프트웨어 엔지니어가 다수 존재합니다.

4. Machine Learning is future proof

Photo by Tomasz Frankowski on Unsplash

미래를 보장한다는 머신러닝과 같이 프론트엔드, 백엔드, 모바일 개발도 동일하다고 말할 수 있습니다.

프론트엔드 개발자로서 매우 만족한다면, 그대로 하세요! 머신러닝 모델로 웹사이트를 만들고 싶다면, 이에 대한 지식을 보유하고 있는 동료와 함께 일하면 됩니다. 반대로 그 동료도 프론트엔드 개발자가 필요하겠죠. 혹은 혼자 다 하거나.

5. Machine Learning is Fun

Photo by Braydon Anderson on Unsplash

머신러닝은 항상 재밌지만은 않습니다.

많은 사람들은 머신러닝 엔지니어가 인공지능이나 자율주행차를 연구하는 멋진 일을 할거라고 생각하는데 그렇지 않습니다. 이들 대부분은 학습 데이터를 만들거나 머신러닝 인프라 구축을 위한 작업을 수행하고 있을 것입니다.

또, 머신러닝 엔지니어가 딥러닝 모델을 다양하게 바꿔본다던가, 하이퍼파라미터를 튜닝하는 일을 할 것 같지만 그렇지 않습니다. 오해하지 마세요. 일부는 이와 같은 일을 하겠지만 많지는 않을 거에요.

ML 엔지니어는 현실 세계의 문제와 닮아있는, 또 이를 해결할 수 있는 학습 데이터를 어떻게 구축할 것인지에 대한 고민에 대부분의 시간을 사용한다는 것이 진실입니다. 이 고민이 성공적으로 끝난다면, 복잡한 딥러닝 모델도 사용할 수 있겠지만 대부분의 고전적의 머신러닝 모델도 충분히 잘 작동하고, 좋은 성능을 보여줄 겁니다.

Conclusion

Photo by Johannes Plenio on Unsplash

본문은 자칫하면 오해의 소지가 있을 수 있지만, 앞서 언급했듯이 누군가의 생각을 비하하거나 다른 생각과 싸우자는 글이 아닙니다.

머신러닝이 자신의 길이라고 생각하면, 하세요! 직접 연락해서 (저자에게) 조언을 구해도 좋습니다.

하지만 머신러닝은 모두를 위한 기술도 아니고, 모두가 알 필요도 없습니다. 만약 소프트웨어 엔지니어로서 훌륭하고, 재미를 느낀다면, 계속 소프트웨어 엔지니어 커리어를 쌓아가세요. 몇몇 머신러닝 튜토리얼은 소프트웨어 엔지니어 커리어에 도움이 되지 않을 것입니다.

이 글의 목적은 많은 인플루언서의 글과 다르게 이에 대한 비판적 견해를 말해주기 위함입니다.


대부분 공감하는 말이다. 이 글도 수용할 수 있는 한 가지 방법은 ML 전문가이면서 프론트엔드(백엔드, 모바일)도 전문가이면 되지만..... 쉽지 않다.

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


Introduction

이번 예제는 CSV 파일에 포함되어 있는 구조 데이터(structured data)를 활용하여 분류 문제를 해결합니다. 데이터는 numerical과 categorical 특성들로 구성되어 있습니다. 우리는 Keras Preprocessing Layer를 사용해서 numerical 특성을 정규화하고 categorical 특성을 벡터화할 것입니다.

주의! 이번 예제는 TensorFlow 2.3 이상의 버전에서 실행해야 합니다.

The Dataset

사용할 데이터셋은 Cleveland Clinic Foundation에서 제공한 심장병 관련 데이터셋입니다. 환자 정보를 포함하고 있는 303개의 데이터(sample)로 이루어져 있으며, 각 열에서 해당 속성(feature)을 살펴볼 수 있습니다. 이를 활용하여 심장병의 여부(binary classification)를 예측해 볼 것입니다.

Age Age in years Numerical
Sex (1 = male; 0 = female) Categorical
CP Chest pain type (0, 1, 2, 3, 4) Categorical
Trestbpd Resting blood pressure (in mm Hg on admission) Numerical
Chol Serum cholesterol in mg/dl Numerical
FBS fasting blood sugar in 120 mg/dl (1 = true; 0 = false) Categorical
RestECG Resting electrocardiogram results (0, 1, 2) Categorical
Thalach Maximum heart rate achieved Numerical
Exang Exercise induced angina (1 = yes; 0 = no) Categorical
Oldpeak ST depression induced by exercise relative to rest Numerical
Slope Slope of the peak exercise ST segment Numerical
CA Number of major vessels (0-3) colored by fluoroscopy Both numerical & categorical
Thal 3 = normal; 6 = fixed defect; 7 = reversible defect Categorical
Target Diagnosis of heart disease (1 = true; 0 = false) Target

 

Setup

import tensorflow as tf
import numpy as np
import pandas as pd
from tensorflow import keras
from tensorflow.keras import layers

Preparing the data

데이터를 다운로드받습니다.

file_url = "http://storage.googleapis.com/download.tensorflow.org/data/heart.csv"
dataframe = pd.read_csv(file_url)

303개 데이터와 14개 column(13 feature + 1 target label)으로 이루어져 있습니다.

dataframe.shape
# (303, 14)

head() 함수로 간단히 살펴봅니다.

dataframe.head()

마지막 속성은 "target"으로 '1'이면 심장병, '0'이면 심장병이 아닌 환자를 의미합니다.

training, validation set으로 나누겠습니다.

# 20% 샘플을 뽑고,
val_dataframe = dataframe.sample(frac=0.2, random_state=1337)
# 위에서 뽑힌 샘플을 제거해서 훈련 데이터셋으로 활용합니다.
train_dataframe = dataframe.drop(val_dataframe.index)

print(
    "Using %d samples for training and %d for validation"
    % (len(train_dataframe), len(val_dataframe))
)

tf.data.Dataset 객체를 만들어줍니다.

def dataframe_to_dataset(dataframe):
    dataframe = dataframe.copy()
    labels = dataframe.pop("target")
    ds = tf.data.Dataset.from_tensor_slices((dict(dataframe), labels))
    ds = ds.shuffle(buffer_size=len(dataframe))
    
    return ds


train_ds = dataframe_to_dataset(train_dataframe)
val_ds = dataframe_to_dataset(val_dataframe)

각 Dataset은 (input, target)을 반환합니다.

  • input: dictionary of feature
  • target: 1 or 0 value
for x, y in train_ds.take(1):
    print("Input:", x)
    print("Target:", y)
    
# Input: {'age': <tf.Tensor: shape=(), dtype=int64, numpy=60>, 'sex': <tf.Tensor: shape=(), dtype=int64, numpy=1>, 'cp': <tf.Tensor: shape=(), dtype=int64, numpy=4>, 'trestbps': <tf.Tensor: shape=(), dtype=int64, numpy=117>, 'chol': <tf.Tensor: shape=(), dtype=int64, numpy=230>, 'fbs': <tf.Tensor: shape=(), dtype=int64, numpy=1>, 'restecg': <tf.Tensor: shape=(), dtype=int64, numpy=0>, 'thalach': <tf.Tensor: shape=(), dtype=int64, numpy=160>, 'exang': <tf.Tensor: shape=(), dtype=int64, numpy=1>, 'oldpeak': <tf.Tensor: shape=(), dtype=float64, numpy=1.4>, 'slope': <tf.Tensor: shape=(), dtype=int64, numpy=1>, 'ca': <tf.Tensor: shape=(), dtype=int64, numpy=2>, 'thal': <tf.Tensor: shape=(), dtype=string, numpy=b'reversible'>}
# Target: tf.Tensor(1, shape=(), dtype=int64)

Dataset에 배치 크기를 설정합니다.

train_ds = train_ds.batch(32)
val_ds = val_ds.batch(32)

 

Feature preprocessing with Keras layers

정수형태로 인코딩되어 있는 categorical feature은 다음과 같습니다.

  • sex, cp, fbs, restecg, exang, ca

CategoryEncoding() layer를 사용해서 one-hot encoding 형태로 바꿔줄 것입니다. 또, thal은 문자형태로 인코딩되어 있는 categorical feature입니다. 먼저 StringLookup() layer를 사용해서 해당 속성이 가지고 있는 값들을 전부 index 형태로 변환하고, CategoryEncoding() layer를 사용해서 one-hot 인코딩을 동일하게 진행해줄 것입니다.

마지막으로, 연속적인 정수 형태인 feature는 다음과 같습니다.

  • age, trestbps, chol, thalach, oldpeak, slope

이들에게는 Normalization() layer를 사용해서 평균 0, 표준 편차를 1로 만들어 줄 것입니다.

이 작업을 수행하기 위해 세 가지 유틸리티 함수를 정의해서 사용합니다.

  • encode_numerical_feature: Normalization을 적용하기 위한 함수
  • encode_string_categorical_feature: 문자 형태로 된 열에 대해 one-hot encoding을 적용
  • encode_integer_categorical_feature: 숫자 형태로 된 열에 대해 one-hot encoding을 적용
from tensorflow.keras.layers.experimental.preprocessing import Normalization
from tensorflow.keras.layers.experimental.preprocessing import CategoryEncoding
from tensorflow.keras.layers.experimental.preprocessing import StringLookup


def encode_numerical_feature(feature, name, dataset):
    # Normalization layer 정의
    normalizer = Normalization()

    # 적용할 열만 가져옵니다.
    feature_ds = dataset.map(lambda x, y: x[name])
    feature_ds = feature_ds.map(lambda x: tf.expand_dims(x, -1))

    # 데이터의 통계치 학습
    normalizer.adapt(feature_ds)

    # 정규화 진행
    encoded_feature = normalizer(feature)
    
    return encoded_feature


def encode_string_categorical_feature(feature, name, dataset):
    # 문자열로 되어 있는 값을 integer 형태로 변환하기 위한 StringLookup() 함수 정의
    index = StringLookup()
    # one-hot encoding 진행을 위한 함수 정의
    encoder = CategoryEncoding(output_mode="binary")
    
    # 적용할 열만 가져옵니다.
    feature_ds = dataset.map(lambda x, y: x[name])
    feature_ds = feature_ds.map(lambda x: tf.expand_dims(x, -1))
    
    # 전체 순서: index adapt -> index 형태로 변환 -> categorical encoding adapt -> categorical 형태로 변환
    
    # feature_ds가 반환하는 문자열 데이터가 어떤 것이 있는지 확인합니다.
    # 문자열로 된 값과, integer index를 부여하기 위한 작업입니다.
    index.adapt(feature_ds)

    # (실제 반환 데이터) integer 형태로 변환합니다.
    encoded_feature = index(feature)
    
    # Dataset 객체가 index 변환 작업을 수행하도록 적용해줍니다.
    feature_ds = feature_ds.map(index)
    
    # feature_ds가 반환하는 index 데이터가 어떤 것이 있는지 확인합니다.
    encoder.adapt(feature_ds)

    # (실제 반환 데이터) one-hot encoding을 적용하고 return합니다.
    # 데이터셋 객체에 다시 map 해줄 필요는 없습니다.
    encoded_feature = encoder(encoded_feature)
    
    return encoded_feature

# 위와 동일
def encode_integer_categorical_feature(feature, name, dataset):
    # Create a CategoryEncoding for our integer indices
    encoder = CategoryEncoding(output_mode="binary")

    # Prepare a Dataset that only yields our feature
    feature_ds = dataset.map(lambda x, y: x[name])
    feature_ds = feature_ds.map(lambda x: tf.expand_dims(x, -1))

    # Learn the space of possible indices
    encoder.adapt(feature_ds)

    # Apply one-hot encoding to our indices
    encoded_feature = encoder(feature)
    return encoded_feature

 

Build a model

end-to-end 모델을 만들어봅니다.

# Numerical -> one-hot encoding
sex = keras.Input(shape=(1,), name="sex", dtype="int64")
cp = keras.Input(shape=(1,), name="cp", dtype="int64")
fbs = keras.Input(shape=(1,), name="fbs", dtype="int64")
restecg = keras.Input(shape=(1,), name="restecg", dtype="int64")
exang = keras.Input(shape=(1,), name="exang", dtype="int64")
ca = keras.Input(shape=(1,), name="ca", dtype="int64")

# String -> one-hot encoding
thal = keras.Input(shape=(1,), name="thal", dtype="string")

# Numerical features
age = keras.Input(shape=(1,), name="age")
trestbps = keras.Input(shape=(1,), name="trestbps")
chol = keras.Input(shape=(1,), name="chol")
thalach = keras.Input(shape=(1,), name="thalach")
oldpeak = keras.Input(shape=(1,), name="oldpeak")
slope = keras.Input(shape=(1,), name="slope")

all_inputs = [sex, cp, fbs,
                restecg, exang, ca,
                thal, age, trestbps,
                chol, thalach, oldpeak,slope]

# Integer categorical features
sex_encoded = encode_integer_categorical_feature(sex, "sex", train_ds)
cp_encoded = encode_integer_categorical_feature(cp, "cp", train_ds)
fbs_encoded = encode_integer_categorical_feature(fbs, "fbs", train_ds)
restecg_encoded = encode_integer_categorical_feature(restecg, "restecg", train_ds)
exang_encoded = encode_integer_categorical_feature(exang, "exang", train_ds)
ca_encoded = encode_integer_categorical_feature(ca, "ca", train_ds)

# String categorical features
thal_encoded = encode_string_categorical_feature(thal, "thal", train_ds)

# Numerical features
age_encoded = encode_numerical_feature(age, "age", train_ds)
trestbps_encoded = encode_numerical_feature(trestbps, "trestbps", train_ds)
chol_encoded = encode_numerical_feature(chol, "chol", train_ds)
thalach_encoded = encode_numerical_feature(thalach, "thalach", train_ds)
oldpeak_encoded = encode_numerical_feature(oldpeak, "oldpeak", train_ds)
slope_encoded = encode_numerical_feature(slope, "slope", train_ds)

all_features = layers.concatenate(
    [
        sex_encoded,
        cp_encoded,
        fbs_encoded,
        restecg_encoded,
        exang_encoded,
        slope_encoded,
        ca_encoded,
        thal_encoded,
        age_encoded,
        trestbps_encoded,
        chol_encoded,
        thalach_encoded,
        oldpeak_encoded,
    ]
)

x = layers.Dense(32, activation="relu")(all_features)
x = layers.Dropout(0.5)(x)

output = layers.Dense(1, activation="sigmoid")(x)

# 모델 생성
model = keras.Model(all_inputs, output)
model.compile("adam", "binary_crossentropy", metrics=["accuracy"])

 

Train the model

model.fit(train_ds, epochs=50, validation_data=val_ds)

약 82% 정확도를 얻을 수 있었습니다.

Inference on new data

model.predict()를 활용해서 새로운 환자의 심장병 여부를 확인해보죠.

  1. batch dimension을 까먹지 않도록 주의해야 합니다. 모델은 batch_dimension에서 과정에 수행됩니다.
  2. 새로운 샘플에서 값을 tensor 형태로 변환하기 위해 convert_to_tensor 활용
sample = {
    "age": 60,
    "sex": 1,
    "cp": 1,
    "trestbps": 145,
    "chol": 233,
    "fbs": 1,
    "restecg": 2,
    "thalach": 150,
    "exang": 0,
    "oldpeak": 2.3,
    "slope": 3,
    "ca": 0,
    "thal": "fixed",
}

input_dict = {name: tf.convert_to_tensor([value]) for name, value in sample.items()}
predictions = model.predict(input_dict)

print(
    "This particular patient had a %.1f percent probability "
    "of having a heart disease, as evaluated by our model." % (100 * predictions[0][0],)
)

# This particular patient had a 20.8 percent probability of having a heart disease, 
# as evaluated by our model.

 

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


Introduction

이 예제는 JPEG 파일을 직접 저장하고, 미리 학습된 모델을 사용하지 않으면서 이미지 분류를 어떻게 행하는지 알아보는 예제입니다. 'Cats vs Dogs' 데이터셋을 케라스를 활용하여 분류해봅니다.

데이터셋 생성을 위해 image_dataset_from_directory를 사용하고, 표준화와 augmentation을 위해 Keras Preprocessing Layer를 사용해봅니다.

Setup

import tensorflow as tf
from tensorflow import keras
from tensorflow.keras import layers

 

Load the data: the Cats vs Dogs dataset

Raw data download

먼저, 765M 크기의 데이터를 다운로드합니다.
예제와 다르게 그냥 쥬피터 노트북 상에서 zip파일을 다운받고 싶으면, 아래 코드를 활용하면 됩니다.

import requests

target_path = 'datasets.zip'

url = 'https://download.microsoft.com/download/3/E/1/3E1C3F21-ECDB-4869-8368-6DEBA77B919F/kagglecatsanddogs_3367a.zip'
response = requests.get(url, stream = True)
handle = open(target_path, 'wb')

for chunk in response.iter_content(chunk_size = 512):
    if chunk:
        handle.write(chunk)
        
handle.close()   

압축을 풀었다면, 최상위 폴더에는 Cat, Dog 하위 폴더가 존재합니다.

Filter out corrupted images

손상된 이미지는 어디에나 존재합니다. 여기서는 "JFIF" 헤더를 가진 이미지를 필터링해보겠습니다.
필터링 시, 해당 헤더가 아니면 손상된 이미지이므로 제거합니다.

import os

num_skipped = 0
for folder_name in ("Cat", "Dog"):
    folder_path = os.path.join("PetImages", folder_name)
    for fname in os.listdir(folder_path):
        fpath = os.path.join(folder_path, fname) # PetImages/Cat/63.jpg
        try:
            fobj = open(fpath, "rb")
            # fobj.peek(10)은 10바이트를 가져오는데, 여기서는 보통
            # 헤더를 읽기 위해 전체를 가져온다고 생각해도 무방합니다.
            is_jfif = tf.compat.as_bytes("JFIF") in fobj.peek(10)
        finally:
            fobj.close()

        if not is_jfif:
            num_skipped += 1
            # Delete corrupted image
            os.remove(fpath)

print("Deleted %d images" % num_skipped)

 

Generate a Dataset

image_size = (180, 180)
batch_size = 32

train_ds = tf.keras.preprocessing.image_dataset_from_directory(
    "PetImages",
    validation_split=0.2,
    subset="training",
    seed=1337,
    image_size=image_size,
    batch_size=batch_size,
)
val_ds = tf.keras.preprocessing.image_dataset_from_directory(
    "PetImages",
    validation_split=0.2,
    subset="validation",
    seed=1337,
    image_size=image_size,
    batch_size=batch_size,
)

Visualize the data

9개 이미지를 그려봅니다. '1'은 Dog이고, '0'은 Cat에 해당합니다.

import matplotlib.pyplot as plt

# figure 크기를 조절합니다.
plt.figure(figsize=(10, 10))

# 배치 하나를 가져옵니다.
for images, labels in train_ds.take(1):
    for i in range(9):
        ax = plt.subplot(3, 3, i + 1)
        plt.imshow(images[i].numpy().astype("uint8"))
        plt.title(int(labels[i]))
        plt.axis("off")

Using Image data augmentation

데이터가 다양하지 않거나, 데이터 개수가 충분하지 않을때 랜덤으로 회전시키거나 fliping하는 등 augmentation을 활용하면 이를 해결할 수 있습니다. 이는 모델이 다양한 데이터를 볼 수 있게 도와줌과 동시에 이를 통해 Overfitting을 (어느정도) 극복할 수 있습니다.

data_augmentation = keras.Sequential(
    [
        layers.experimental.preprocessing.RandomFlip("horizontal"),
        layers.experimental.preprocessing.RandomRotation(0.1),
    ]
)

data_augmentation을 통해 변형된 이미지를 그려봅니다.

plt.figure(figsize=(10, 10))
for images, _ in train_ds.take(1):
    for i in range(9):
        augmented_images = data_augmentation(images)
        ax = plt.subplot(3, 3, i + 1)
        plt.imshow(augmented_images[0].numpy().astype("uint8"))
        plt.axis("off")

 

Standardizing the data

현재 우리가 사용할 이미지는 픽셀 type float32, 크기는 180x180으로 이미 어느정도 표준화가 되어 있습니다. 하지만 RGB channel의 값들이 [0, 255] 값을 가지고 있어, 신경망을 사용하기엔 적합하지 않습니다. 따라서 Rescaling layer를 활용해서 [0, 1] 범위로 변환해줄 것입니다.

 

Two options to preprocess the data

augmentation을 적용할 수 있는 두 가지 방법이 있습니다.

>> Option 1: Make it part of the model like this:

inputs = keras.Input(shape=input_shape)
x = data_augmentation(inputs)
x = layers.experimental.preprocessing.Rescaling(1./255)(x)
...  # Rest of the model

이 방법은 모델 실행에서 동시에 사용될 수 있기 때문에 GPU를 사용한다는 큰 장점을 활용할 수 있습니다.

이 방법은 test time에는 동작하지 않기 때문에, evaluate(), predict()에서는 활용되지 않고, 오로지 fit() 동안에만 사용됩니다. 또한, GPU를 보유하고 있다면 이 방법을 긍정적으로 고려해볼 수 있습니다.

>> Option 2: apply it to the dataset, so as to obtain a dataset that yields batches of augmented images, like this:

augmented_train_ds = train_ds.map(
  lambda x, y: (data_augmentation(x, training=True), y))

이 방법은 CPU에서 주로 사용되고, 버퍼를 통해 모델에 입력됩니다.

CPU를 활용하여 학습하는 경우라면 이 방법이 더 효과적입니다.

 

Configure the dataset for performance

prefetch 옵션을 활용하면 더욱 효율적으로 데이터를 생성하여 모델에 입력할 수 있습니다.

train_ds = train_ds.prefetch(buffer_size=32)
val_ds = val_ds.prefetch(buffer_size=32)

 

Build a model

Xception의 small version을 작성해보겠습니다. 이 모델에 대해서는 최적화를 진행하지 않을 것이고, 만약 최적화에 관심이 있다면 Keras Tuner를 고려해보세요.

강조 사항:

  • data_augmentation을 사용하고, 추가로 Rescaling layer를 활용합니다.
  • 마지막 classification layer에 Dropout을 추가합니다.
def make_model(input_shape, num_classes):
    inputs = keras.Input(shape=input_shape)
    # 본격적으로 모델에 입력하기 전에 여기서 augmentation이 진행됩니다.
    # inference time에는 동작하지 않습니다.
    x = data_augmentation(inputs)

    # [0, 1] 변환을 위해 Rescaling Layer를 활용합니다.
    x = layers.experimental.preprocessing.Rescaling(1.0 / 255)(x)
    x = layers.Conv2D(32, 3, strides=2, padding="same")(x)
    x = layers.BatchNormalization()(x)
    x = layers.Activation("relu")(x)

    x = layers.Conv2D(64, 3, padding="same")(x)
    x = layers.BatchNormalization()(x)
    x = layers.Activation("relu")(x)

    previous_block_activation = x  # Set aside residual

    for size in [128, 256, 512, 728]:
        x = layers.Activation("relu")(x)
        x = layers.SeparableConv2D(size, 3, padding="same")(x)
        x = layers.BatchNormalization()(x)

        x = layers.Activation("relu")(x)
        x = layers.SeparableConv2D(size, 3, padding="same")(x)
        x = layers.BatchNormalization()(x)

        x = layers.MaxPooling2D(3, strides=2, padding="same")(x)

        # Project residual
        residual = layers.Conv2D(size, 1, strides=2, padding="same")(
            previous_block_activation)
        x = layers.add([x, residual])  # Add back residual
        previous_block_activation = x  # Set aside next residual

    x = layers.SeparableConv2D(1024, 3, padding="same")(x)
    x = layers.BatchNormalization()(x)
    x = layers.Activation("relu")(x)

    x = layers.GlobalAveragePooling2D()(x)
    if num_classes == 2:
        activation = "sigmoid"
        units = 1
    else:
        activation = "softmax"
        units = num_classes

    x = layers.Dropout(0.5)(x)
    outputs = layers.Dense(units, activation=activation)(x)
    
    return keras.Model(inputs, outputs)

# image size에 (3, ) 채널을 추가합니다.
model = make_model(input_shape=image_size + (3,), num_classes=2)

 

Train the model

epochs = 50

callbacks = [
    keras.callbacks.ModelCheckpoint("save_at_{epoch}.h5"),
]
model.compile(optimizer=keras.optimizers.Adam(1e-3),
              loss="binary_crossentropy",
              metrics=["accuracy"])
model.fit(train_ds, epochs=epochs, 
          callbacks=callbacks, validation_data=val_ds)

학습을 진행해보세요.

 

Run inference on new data

inference time에는 Dropout과 data augmentation이 비활성화 상태가 됩니다.
하지만 Rescaling layer는 그대로 활용되기 때문에 테스트용 데이터로 추론을 진행할 때 올바른 결과를 얻을 수 있습니다.

img = keras.preprocessing.image.load_img("PetImages/Cat/6779.jpg", 
                                         target_size=image_size)
img_array = keras.preprocessing.image.img_to_array(img)
img_array = tf.expand_dims(img_array, 0)  # Create batch axis

predictions = model.predict(img_array)
score = predictions[0]
print(
    "This image is %.2f percent cat and %.2f percent dog."
    % (100 * (1 - score), 100 * score)
)

신경망 기술은 모든 업계에 큰 변화를 가져다 주고 있습니다.

우리 실생활에 직접적으로 맞닿아있는 금융업계에서도 다양한 변화가 일어나고 있는 것또한 자명한 사실입니다.

내부적으로 리얼(real)한 기술이 적용되었는지는 까봐야 알 수 있지만, 챗봇(Chatbot), 금융 사기 탐지(Fraud Detection), 대출, 보험과 같은 다양한 상품을 초개인화로 제공해주기 위한 추천 시스템(Recommendation System) 기술 등 딥러닝이 화두가 되기 이전에도 다양한 알고리즘으로 문제를 해결하고 있던 것들이 이제금 딥러닝 기술 적용으로 새로운 성능을 맛보고 있습니다. 심지어 이제 많은 사람들이 당연하고, 편하게 이용하고 있는 음성/얼굴 인식, 생체 인식 기술도 딥러닝 기술에 포함됩니다.

  • 딥러닝으로 활발하게 다뤄지는 분야들
    - 고객 서비스
    - 가격 예측
    - 포트폴리오 관리
    - 사기 탐지
    - 트레이딩 자동화
    - 리스크 관리
    - 신용 평가
    - ...

이번 글에서는 '특정' 주제를 다룰 것이다!라고 단정짓지 않고 금융에서 나타나고 있는 전반적인 변화에서, 특히 딥러닝 기술이 어떻게 활용될 수 있는지 서베이(Survey)해보기 위해 작성, 정리한 글입니다. 또, 딥러닝에 관점을 맞추는 이유는 더욱더 불확실해지고 있는 데이터를 다루기 위한 해결 요소로서 딥러닝 기술이 최적화되어 있다고 판단하기 때문입니다. 뿐만 아니라 금융업계는 지금까지 엄청난 고객 데이터를 쌓아왔기 때문에 이를 적극 활용하여 포스트 코로나 시대에서의 또다른 가치를 만들어내기 위해 힘쓰고 있습니다.
물론 아직까지는 알고리즘, 머신러닝을 활용한 기술을 무시할 수 없다는 점도 알려드립니다.

  • 이야기 순서
    • 금융업계의 숨겨진 힘: 고객 데이터
    • 고객 데이터에 딥러닝을?(금융업 관점)

금융업계의 숨겨진 힘: 고객 데이터

우리가 금융과 딥러닝을 함께 생각했을 때 가장 쉽게 떠오를 수 있는 것은 트레이딩(Trading) 관련 내용입니다. 고객은 돈만 주면 알아서 돈을 벌어오는 그런 구조요. 이에 관련해서는 대표적으로 크래프트 테크놀로지스라는 회사가 눈에 띕니다.

'적절한 타이밍에 사고/팔고 싶어! 근데 내가하긴 싫고, AI 너가 해. 그리고 돈 벌어와!'

딥러닝을 잘 모르는 고객이라면 매우 흥미로운 서비스입니다. 수시로 주가를 모니터링하지 않아도 AI가 알아서 적절한 상황을 판단하여 사고, 팔아 이익을 가져다주기 때문이죠. 여기서 잠깐, 고객의 입장과 트레이딩에 관한 기술도 매우 흥미롭지만, 우리는 전사적 차원으로 접근할 것입니다. 인공지능 기술을 탑재한 트레이딩처럼 엄청난 서비스를 고객에게 제공함으로써 자사 플랫폼에 머물게 하고 더 많은 기능을 사용하게끔 유도하게 됩니다(최근 금융업계는 플랫폼으로의 전환을 꾀하고 있다는 점을 무시할 수 없습니다). 또, 이를 달성하기 위해 먹이를 찾아나서는 기존의 행동 방식과 다르게 기술과 데이터를 적극적으로 활용하려는 움직임을 보이고 있죠.

  • 카카오의 카카오페이와 뱅크
  • 네이버의 네이버페이
  • 신한, KB, 우리 등 국내 금융업계

주식 예측, 펀딩 등을 수행하는 이런 기술은 매력적이지만 실제로 수많은 불가항적인 요소에 의해 적용, 구현하기 매우 어렵습니다. 향후 몇년 이후에는 진짜 상용화가 가능한, B2C가 가능한 Trading-Bot이 나올지도 모르겠지만, 지금은 아닐거에요.

주식 거래는 우리에게 익숙한 주제이고, 많은 사람이 이를 통해 큰 수익을 얻고자 하기 때문에 이를 통해 잠시 서론을 꾸며보았습니다. 사실 금융업계가 가지고 있는 진짜 힘은 이런 기술적인 부분이 아니라 수년간 쌓아오고, 정제해온 고객 데이터에 있습니다. 고객의 성별, 나이, 주소는 물론 거래 내역, 대출, 신용 정도 등 엄청나게 다양한 매력적인 특성(Feature)을 가지고 있습니다. 그리고 이들을 활용해서 마치 넷플릭스처럼, 유튜브처럼 뛰어난 추천 시스템을 개발해서 맞춤형 서비스를 제공하거나 미래의 행동을 미리 예측하여 그에 알맞는 고객 관리 방법을 적용할 수 있게끔 많은 노력을 기울이고 있습니다.

  • 고객 데이터로 다뤄볼 수 있는 것들
    • 추천을 통한 맞춤형 서비스 제공
    • AI 상담원 - 고객 서비스
    • 자산/리스크 관리
    • 이탈 고객 예측
    • 이 외에도 엄청 많아요.

쌓아둔 데이터도 많고, 딥러닝도 많이 발전했으니 이제 모델링을 통해 엄청 훌륭한 모델을 만들고,
고객에게 빨리 제공해서 이익을 창출할 수 있지 않을까요?

매우 힘들고 많은 시간이 소요될겁니다. 핵심적인 이유는 금융업계가 보유하고 있는 대부분의 고객 데이터가 정제되지 않은 상태라는 점 때문입니다. 또, 적재된 데이터의 특성과 양이 많아 이를 정제하는데 엄청난 비용이 소모되는 것도 사실입니다. 아마 우리가 데이터 분석을 배울때, 전체 시간의 80%는 데이터 파악에 활용한다!라고 배우는 것처럼 모델링에 투자하는 시간보다 기존 데이터를 정제하고 다시 쌓는 시간이 매우 오래 걸릴 것입니다. 하물며, 모델링을 수행한다고 해서 높은 성능을 기대하기란 매우 어려울거에요(빠른, 많은 A/B Test가 더욱 중요해지는 시점).

고객 데이터는 설계에 따라 지저분해진다면 제한없이 지저분해질 수 있는 장점(?)을 보유하고 있습니다. 이 때문에 고객 데이터를 다룸에 있어서 유명한 말인 'Garbage In, Garbage Out'이라는 말이 더욱 와닿는 것 같습니다. 부가설명이 필요없겠죠?

그렇다면 공격적인 투자도 좋지만, 선례를 조사해서 우리에게 알맞는 기술 적용을 검토해보자라는 느낌으로 금융업계에서 고객 데이터와 딥러닝을 활용해 어떤 서비스를 제공하고 있는지 더 살펴보죠.

 

고객 데이터에 딥러닝을?(금융업 관점)

1. 상품 추천과 마케팅

사실 이 부분은 따로 다루기 보다 유튜브나 넷플릭스의 추천 시스템 관점에서 생각하는 게 제일 적합하다고 생각합니다. 그럼에도 불구하고 조금만 코멘트를 추가해보죠.

우리가 자주 접하는 쇼핑몰에서의 의류, 전자 제품 등만 상품을 떠오를 수 있지만, 금융업에서 제공할 수 있는 상품은 이를테면, 예/적금 상품, 대출 상품, 보험 상품 등이 있습니다. 상품 추천이 중요한 이유는 고객을 끌어들이는 것도 있지만, 금융업계에서는 이탈 고객을 방지하는 것도 그에 못지않게 엄청 중요하기 때문입니다.

보통의 금융업계는 이탈 고객을 방지하기 위해 약간의 머신러닝 방법을 사용하는 경우나 그렇지 않은 경우를 포함해서 메일 또는 문자 메시지를 발송합니다. 일반적으로 '메일이나 문자를 주기적으로 보내서 규모의 경제처럼 고객 이탈을 방지하면 되지 않을까?'라고 생각할 수 있지만, 기업 입장에서는 이게 전부 '돈'입니다. 마케팅 비용이죠.

고객의 향후 패턴이나 특성을 고려하지 않은 내용을 포함한 메일/문자를 5번 보내서 이탈 고객 1명을 방지할 것을 딥러닝 모델을 사용해서 고객을 더욱 정확히 파악하고 이를 통해 1번의 문자로 고객의 이탈을 방지할 수 있습니다. 이를 위해서는 고객이 무엇을 원하는지를 정확히 추천할 필요가 있기 때문에 추천 시스템의 중요성은 더욱 높아지고 있습니다.

 

2. 이상 거래 탐지

이상 거래 탐지(Fraud Detection System)는 금융업계에서 아주 활발히 연구되고 있는 분야입니다. 신한, 핀크, 농협 등 FDS의 고도화를 진행하고 있습니다.

기존 룰 기반(Rule-base)으로 이상 거래를 탐지하던 방식은 시간이 지날수록 정교화되는 거래 사기를 잡아내기엔 제한적이기 때문에 많은 금융업계에서 머신러닝, 딥러닝 방법 적용에 큰 관심을 두고 개발하여 적용하고 있습니다. 여기서 사용될 모델은 기존 고객이 사용하던 거래 패턴과 사기 거래 패턴의 전체적인 맥락을 파악해서 차이점을 인식한 후 사기인지 아닌지를 우리에게 알려주어야 합니다. 그렇기에 불규칙적인 패턴을 잘잡아낼 수 있다는 큰 장점을 가진 딥러닝 모델이 관심을 받는 것이기도 합니다.
(물론 이를 위해 사람의 엄청난 노가다가..).

대표적으로 RF(Random Forest), SVM(Support Vector Machine), Machine&Deep Learning 방법을 활용해볼 수 있습니다. 이 방법들은 장점도 있는 반면, 정상/비정상 거래 데이터의 불균형, 데이터 전처리에서 사용되는 차원 축소의 효율성 문제, 실시간 탐지 특성을 유지하기 위한 컴퓨팅 파워 문제 등이 존재합니다.

대표적으로 캐글의 IEEE-CIS Fraud Detection 대회를 통해 이를 체험해 볼 수 있으며, Auto-Encoder나 LGBM과 같은 부스팅 계열 모델이 상위권을 차지하고 있는 것을 알 수 있습니다.

 

3. 이탈 고객 예측

1번에서 상품 추천 시스템을 매우 잘만들어 고객이 '이탈'이라는 생각을 하지 못하게 만들수도 있지만, 고객은 언제 변심할지 모릅니다.

이를 미연에 방지하고자 이탈 고객을 미리 예측하고, 이들에게 더욱 맞춤화된 서비스를 추천하고자하는 측면에서 기술을 적용할 수 있습니다.
- 기존 고객에게 A 제품을 마케팅할 때, 예측 모델을 통해 찾아낸 이탈 고객에게는 A 제품과 고객 특성을 파악해 얻어낸 B 제품을 동시에 추천!

고객 이탈을 예측하기 위해서는 고객의 행동적/통계적 특성을 동시에 활용해야 합니다. 물론 활용을 위해선 적재된 다양한 고객 데이터가 있어야 하는데, 이러한 점에서 금융업계는 금상첨화입니다.

기존의 Regression, Naive Bayes, Clustering, Boosting 모델과 자주 사용된 Random Forest 방법은 물론, 특성에 따라 CNN, RNN, LSTM, Auto-Encoder에서 GNN(Graph Neural Network)까지를 고려해볼 수 있습니다. 또한, Attention 모델을 사용해서 고객이 왜 이탈했는지 해석하려고 노력한 논문도 보였네요. 

 

4. 고객 서비스

고객이 서비스를 사용하다가 (간단한) 문제에 부딪혔습니다. 해결 방법을 물어보기 위해 상담원과 통화를 원하지만 상담 고객이 밀려 있어 5분, 10분, 15분, ... 하염없이 기다리다보면 회사의 서비스에 대한 고객의 불평은 커지기 마련입니다. 상담원, 마케터는 더 많은 고객의 궁금증을 내일로 미루지 않기를 원하지만, 반복적인 단순 질문으로 인해 처리가 지연될 뿐만 아니라 일의 효율성이 매우 떨어진다는 것을 자주 느낍니다.

고객 서비스를 위해 모든 금융업계가 챗봇 시스템 도입을 수행하고 있는 것은 이제금 자명한 사실이 되었습니다. 챗봇 시스템은 위의 문제를 해결해줄 수 있는 기술이자 고객에게 최고의 서비스를 제공할 수 있습니다. 챗봇은 유행성 기술이기도 하기 때문에 고객들이 필요한 정보와 얼마나 알맞는 정보를 제공할 수 있는지는 의문이라는 걱정어린 말도 엿들을 수 있습니다. 이에 대한 시나리오나 설계는 아직 모두 사람이 하고 있기 때문입니다.

아직 완벽하진 않지만, 이를 해결하기 위해 챗봇에도 추천, 랭킹 기술을 도입하는 방법이 상당 부분 고려되고 있습니다. 상담원에게 고객 대응을 위한 문장을 추천한 후, 상담원은 고객이 원하는 답변을 알맞게 변형하여 빠르게 답변할 수 있습니다.

고객 서비스에 정확한 대응을 하기 위해서는 통계적 특성과 상담 내용(음성 또는 텍스트) 등 다양한 데이터를 모델링에 사용할 수 있습니다. 또, 상담 내용 데이터는 챗봇 성능 고도화에 사용될 뿐만 아니라 추가적으로 분석해서 고객 성향과 향후 추천해주면 좋을 법한 제품까지 예측하도록 도와줄 수 있는 고급 데이터입니다.

딥러닝을 활용하여 다음과 같은 서비스를 제공할 수 있습니다.

  • 자연어 처리/생성에 사용되는 모델을 활용하여 고객의 특정 니즈를 파악하고, 감정을 분석하여 그에 알맞는 대응과 서비스를 제공할 수 있음
  • 24시간 항시 이용할 수 있는 AI 서비스

이 또한, 계속 생성되는 데이터를 실시간으로 학습하여 고객에게 더움 맞춤화된 해결 방법을 제공할 수 있기 때문에 고객 데이터의 확보가 매우 중요한 분야입니다.


사실 서비스 측면에서 파악하여 글을 작성하기로 한 것은 아니고, 세부적인 기술을 보고 싶었는데...

금융업 특성상 기술 공개가 그렇게 활발하지 않은 점과 동시에 많은 연구 논문들이 거의 비슷한 방법으로 method를 제안하고 있어 전혀 다른 결과의 글이 나온 점이 아쉽지만, 나름대로의 생각을 정리한 글입니다.

이번 글을 통해 어느 정도의 카테고리를 파악했으니, 다음 글은 데이터 처리나 모델링을 세부적으로 다뤄볼 수 있도록 노력을....