Ssul's Blog

[추천시스템#8] DeepFM 추천시스템 본문

AI & ML

[추천시스템#8] DeepFM 추천시스템

Ssul 2024. 1. 17. 20:37

0. 최신 추천시스템은?

- 지금까지 컨텐츠기반 추천, 협업필터링 추천, 딥러닝 등 다양한 추천 방법을 알아봤다.

- 협업필터링에는 메모리기반과 모델 기반으로 나눠지고,

- 메모리기반은 유저기반의 협업필터링, 아이템기반의 협업필터링으로 구분된다.

- 또한 모델기반의 협업필터링은 MF, FM 등이 있었다.

- 최근에는 추천에도 많은 딥러닝 기술들이 들어왔고, 가장 기본으로는 데이터를 임베딩한후 신경망에 단순히 넣는 Neural CF가 있다.

- Wide and Deep은 구글 플레이스토어 추천 알고리즘으로 사용되어서 유명했으며,

- 기존의 FM과 딥러닝이 합쳐저서 구현된 DeepFM

- 오토인코더를 활용한 추천시스템도 있으며, 최근에 SOTA를 찍었다는 Graph기반의 추천시스템도 있다.

다양한 추천시스템의 분류(자체제작)

 

1. 최신 딥러닝 추천시스템을 구현해보기

- 대학원 프로젝트 미션은 주어진 과제는 아마존 데이터를 기반으로 최상의 추천성능을 달성하기

- 단 모델에 입력되는 데이터는 사용자 id, user id, 이외에 사용자를 특정할만한 데이터(나이, 성별, 직업 등)는 입력되어서는 안됨

- 그럼 다양한 추천 시스템 중에 어떤 것을 활용할 것인가?

- CF, CB는 벤치마킹 데이터로 활용하자.

- Neural CF는 기본이니 스킵, Wide and deep보다 나중에 나온 DeepFM으로 최상의 성능을 내보는 것을 목표로 하자!

 

2. DeepFM이란 무엇인가?

DeepFM은 말 그대로, FM과 딥러닝을 합친 모델이다. 이론상으로 FM의 특성과 Deep러닝의 특성을 함께 담아내는 모델이다.

 

2-1. FM(Factorization) 간단 복습

- FM은 각 요소를 임베딩하여,

- 요소의 직접적인 영향과 요소들간의 간접적인 영향을 반영하여 예측값을 가져오는 것이다.

각 요소를 임베딩, userid/itemid이외에 영향을 준다고 판단되는 요소를 임베딩하여 입력

- FM알고리즘을 활용하면, user/item/... 각각의 요소가 target에 반영

- 또한 user-item, item-other 등 요소들간의 간접적인 영향도 target에 반영

 

2-2. DeepFM이란(이론적)

- Deep러닝 + FM이 결합된 알고리즘

- FM에서 요소의 직접적인 영향, 요소간 간접적인 영향 중 선형 특성 반영

- Deep에서 요소간 간접적인 영향 중 비선형 특성 반영

- FM에서 못 잡아냈던, 비선형적인 특성을 Deep러닝 신경망을 통해서 잡아낸다.

DeepFM개념(출처: researchgate.net)

 

3. 프로젝트 데이터셋 살펴보기

# reviews (82943, 8), ratings(99742,4)
reviews = pd.read_csv('./Amazon_reviews.csv', encoding='latin-1')
ratings = pd.read_csv('./Amazon_ratings.csv', encoding='latin-1')

- reviews데이터는 82,943개, ratings데이터는 99,742개로 구성

- reviews데이터는 아이템id, 리뷰텍스트, userid, 리뷰남긴 시간, 해당리뷰에 대한 공감(votes)등이 있음

review데이터는 아이템에 대한 사용자들의 리뷰가 담겨있음

 

- ratings데이터는 우리가 익숙한, userid, itemid, 평점, timestamp로 구성되어 있음

ratings데이터.. 익숙

- 기타 data EDA는 알아서 진행해보자. 특정 아이템에 상당히 많은 리뷰가 몰려있다는 것과 특정 user가 굉장히 많은 리뷰를 남긴것을 확인할 수 있음(데이터 분석가는 이 데이터를 충성고객으로 볼지, outlier로 볼지 고민해야 함)

- rating데이터의 대부분이 5점에 몰려있는데... 어떻게 바라보고 해석할 것인가? 이런 고민들을 하면서 문제를 풀어내야 한다

 

4. 데이터 전처리

4-1. timestamp데이터를 년/월/일로 변환하고, 원핫벡터로 임베딩

ratings['datetime'] = pd.to_datetime(ratings['timestamp'], unit='s')

# 여러 시간적 특성 추출
ratings['year'] = ratings['datetime'].dt.year
ratings['month'] = ratings['datetime'].dt.month
ratings['day'] = ratings['datetime'].dt.day
ratings['weekday'] = ratings['datetime'].dt.weekday  # 월요일: 0, 일요일: 6
ratings['hour'] = ratings['datetime'].dt.hour

from sklearn.preprocessing import MinMaxScaler
# Min-Max 스케일링
scaler = MinMaxScaler()
ratings_scaled = pd.DataFrame(scaler.fit_transform(ratings[['year', 'month', 'day']]), columns=['scaled_year', 'scaled_month', 'scaled_day'])

# ratings 데이터프레임과 ratings_scaled 데이터프레임을 열 방향으로 합치기
ratings = pd.concat([ratings, ratings_scaled], axis=1)

- timestamp데이터를 datetime으로 변환해서, 년/월/일/요일 정보 가져오기

- 위 그림과 같이 원핫벡터로 임베딩. 월/일/요일은 쉽고, 년도만 최소년도부터 최고년도 사이즈로 만들기

 

4-2. userid, itemid, timestamp 임베딩하고 concat

# User encoding : user index: 0-3647(3648개)
user_dict = {}
for i in set(ratings['user_id']):
    user_dict[i] = len(user_dict)
n_user = len(user_dict)
# Item encoding : item index: 0-6242(6243개)
item_dict = {}
for i in set(ratings['item_id']):
    item_dict[i] = len(item_dict)
n_item = len(item_dict)
# Item encoding : item index: 0-6242(6243개)
weekday_dict = {}
for i in set(ratings['weekday']):
    weekday_dict[i] = len(weekday_dict)
n_weekday = len(weekday_dict)


x = ratings
# (99742,13)
x.shape

# Generate X data
data = []
y = []
w0 = np.mean(x['rating'])
# 각 변수의 idx로 data, rating값-평균을 y로 셋팅
# 데이터 구성예시: data = [[[1, 10, 1],[1,1,1]],[[.....
for i in range(len(x)):
    case = x.iloc[i]
    x_index = []
    x_value = []
    x_index.append(user_dict[case['user_id']])     # User id encoding
    x_value.append(1)
    x_index.append(item_dict[case['item_id']])    # Movie id encoding
    x_value.append(1)
    x_index.append(weekday_dict[case['weekday']])   # verified id encoding
    x_value.append(1)

    data.append([x_index, x_value])
    y.append(case['rating'] - w0)
    if (i % 10000) == 0:
        print('Encoding ', i, ' cases...')

# 연속형 변수 데이터 준비
continuous_data = x[['year', 'month', 'day']].values

# NaN 값 체크
nan_count = np.sum(np.isnan(continuous_data))
nan_positions = np.where(np.isnan(continuous_data))[0]

- 데이터를 rating별 userid임베딩, itemid임베딩, timestamp임베딩을 concat해서 매칭

DeepFM입력된 데이터는 그림과 같이 각요소 임베딩해서 concat
각 feature 임베딩 concat, target=rating

 

4-3. Dataset, DataLoader를 통해 학습시 입력될 데이터 구조 셋팅

from torch.utils.data import Dataset, DataLoader
import tqdm

# 1. 데이터셋 클래스 정의
class CustomDataset(Dataset):
    def __init__(self, data, labels, continuous_data):
        self.data = data
        self.labels = labels
        # 연속형 변수 추가
        self.conti_data = continuous_data

        # 받은 데이터 중, value는 1로 구성되어서 사용 안함
        # [[[1,1400,2],[1,1,1]],[[...],[...]],[[....] > [[...],[...],[..]
        self.items = np.array(self.data)[:, 0, :]
        self.targets = np.array(self.labels)
        # [1, 1400, 2]...각 열에서 최대값 > 각 변수의 idx갯수 [3648개, 3648개, 2개]
        self.field_dims = np.max(self.items, axis=0) + 1
        # 입력값에서 각 변수의 위치
        self.user_field_idx = np.array((0,), dtype=np.int64)
        self.item_field_idx = np.array((1,), dtype=np.int64)
        self.weekday_field_idx = np.array((2,), dtype=np.int64)

    def __len__(self):
        return self.targets.shape[0]

    def __getitem__(self, index):
        # [1, 1400, 2] [4.0-3.3(평균)]
        return self.items[index], self.targets[index], self.conti_data[index]

# 2. 데이터셋 작동여부 체크(연속형변수 1개 추가)
dataset = CustomDataset(data, y, continuous_data)
# item의 위치 2번째
print(dataset.item_field_idx)
# userid 3648, itemid 6243, verified 2
print(dataset.field_dims)
# FM의 1개 행의 사이즈 3648+6243+2 = 9893
print(sum(dataset.field_dims))
print(torch.nn.Embedding(sum(dataset.field_dims), 16))
print(torch.nn.Parameter(torch.zeros((1,))))
# 각 변수의 시작위치 idx [0, 3648, 9891]
print(np.array((0, *np.cumsum(dataset.field_dims)[:-1]), dtype=np.int64))
print(dataset.__getitem__(1))

# 3. 데이터 로더 생성
# 배치 크기, 셔플 여부, 병렬 처리 worker 수 등을 설정할 수 있습니다.
batch_size = 32  # 예시로 32 설정
train_data_loader = DataLoader(dataset, batch_size=batch_size, shuffle=True, num_workers=4)


# 4. train, valid, test 데이터 구성
train_length = int(len(dataset) * 0.8)
valid_length = int(len(dataset) * 0.1)
test_length = len(dataset) - train_length - valid_length
train_dataset, valid_dataset, test_dataset = torch.utils.data.random_split(
    dataset, (train_length, valid_length, test_length))

train_data_loader = DataLoader(train_dataset, batch_size=16, drop_last=True)
valid_data_loader = DataLoader(valid_dataset, batch_size=16, drop_last=True)
test_data_loader = DataLoader(test_dataset, batch_size=16, drop_last=True)
print(dataset.items)
print(dataset.targets)
print(dataset.conti_data)

- Dataset, DataLoader 셋팅

- 연속형 변수의 경우, 기존 데이터와는 다른 구조로 작동하기 때문에 따로 입력이 들어가야 함

- 배치사이즈 32로 쪼개고,

- train data 80%, valid data 10%, test data 10%로 구성

 

4-4. (참고) 다른 변수들도 입력하는 코드(DeepFM에 적용했을때 오히려 성능이 안좋아서 뺌)

# other encoding
# 1.verified encoding : verified index: 0-1(2개)
len(set(reviews['verified'])) # 2
verified_dict = {}
for i in set(reviews['verified']):
    verified_dict[i] = len(verified_dict)
n_verified = len(verified_dict)

- 리뷰 남긴사람의 인증 여부를 입력데이터에 사용하는 경우

 

 

5. DeepFM 코드 구현

- 총 3가지 박스를 구성해야 한다(레드박스2개, 블루박스1개)

 

5-1. FM에서 변수의 직접적인 영향(1번째 레드박스)

# FM의 변수의 직접영향 부분(w)
class FeaturesLinear(torch.nn.Module):
    def __init__(self, field_dims, output_dim=1):
        super().__init__()
        # 입력 변수의 임베딩, field_dims = (user의 갯수, item의 갯수, verified의 갯수, other...) = (3648, 6243, 2)
        self.fc = torch.nn.Embedding(sum(field_dims), output_dim)
        # 임베딩시 bias
        self.bias = torch.nn.Parameter(torch.zeros((output_dim,)))
        # 누적합계를 통한 각 변수 임베딩 시작점 체크 [3648, 3648+6243, 3648+6243+2] > 그중 끝에 idx는 필요없어서 빼고 [0, 3648, 3648+6243]
        self.offsets = np.array((0, *np.cumsum(field_dims)[:-1]), dtype=np.int64)

    def forward(self, x):
        # 1번째 user, 2번째 item, 3번째 verified :[1, 2, 2] > 본래 idx로 [1, 3648+2, 3648+6243+2]
        x = x + x.new_tensor(self.offsets).unsqueeze(0)
        # [1, 3648+2, 3648+6243+2]입력 > 임베딩 하고 > bias더해서, w스칼라값 구하기
        return torch.sum(self.fc(x), dim=1) + self.bias

- 주석참고

 

5-2. FM에서 변수의 상호간 선형 영향(2번째 레드박스)

# FM의 변수 상호간 영향 부분(v)생성
class FeaturesEmbedding(torch.nn.Module):
    def __init__(self, field_dims, embed_dim):
        super().__init__()
        # 동일
        self.embedding = torch.nn.Embedding(sum(field_dims), embed_dim)
        # 동일
        self.offsets = np.array((0, *np.cumsum(field_dims)[:-1]), dtype=np.int64)
        # 임베딩 레이어의 가중치를 초기화
        torch.nn.init.xavier_uniform_(self.embedding.weight.data)

    def forward(self, x):
        # 본래 idx로 변환
        x = x + x.new_tensor(self.offsets).unsqueeze(0)
        return self.embedding(x)
        
# FM w, v 합치기
class FactorizationMachine(torch.nn.Module):
    def __init__(self, reduce_sum=True):
        super().__init__()
        self.reduce_sum = reduce_sum

    def forward(self, x):
        # 합의 제곱
        square_of_sum = torch.sum(x, dim=1) ** 2
        # 제곱의 합
        sum_of_square = torch.sum(x ** 2, dim=1)
        ix = square_of_sum - sum_of_square
        if self.reduce_sum:
            ix = torch.sum(ix, dim=1, keepdim=True)
        return 0.5 * ix

- 변수 상호간 선형적인 영향부분

- 변수의 직접 영향, 간접영향(선형)부분 합치는 FM구현

 

5-3. Deep 구현

# DeepFM의 Deep부분
class MultiLayerPerceptron(torch.nn.Module):
    def __init__(self, input_dim, embed_dims, dropout, output_layer=True):
        super().__init__()
        layers = list()
        # embed_dims=(4096, 2048, 1024) > 3개층 DNN, 4096/2048/1024노드
        for embed_dim in embed_dims:
            layers.append(torch.nn.Linear(input_dim, embed_dim))
            layers.append(torch.nn.BatchNorm1d(embed_dim))
            layers.append(torch.nn.ReLU())
            layers.append(torch.nn.Dropout(p=dropout))
            input_dim = embed_dim
        if output_layer:
            layers.append(torch.nn.Linear(input_dim, 1))
        self.mlp = torch.nn.Sequential(*layers)

    def forward(self, x):
        return self.mlp(x)

- DeepFM에서 Deep부분으로 비선형성을 학습한다

 

5-4. DeepFM 완성

# DeepFM 계산부분: 직접변수(FM)+변수간영향(FM)+신경망(Deep)
class DeepFactorizationMachineModel(torch.nn.Module):
    def __init__(self, field_dims, embed_dim, mlp_dims, dropout, num_continuous=1):
        super().__init__()
        self.linear = FeaturesLinear(field_dims)
        self.fm = FactorizationMachine(reduce_sum=True)
        self.embedding = FeaturesEmbedding(field_dims, embed_dim)
        # 변수 갯수 * embed_dim > 신경망에 입력될 input차원 = 변수1*임베딩차원 + 변수2*임베팅차원 + 변수3*임베딩차원
        self.embed_output_dim = len(field_dims) * embed_dim
        # 신경망 input: (변수갯수*임베딩차원), mlp_dims: 신경망 구조
        self.mlp = MultiLayerPerceptron(self.embed_output_dim, mlp_dims, dropout)

        # 연속형 변수는 선형변환을 통해 모델에 통합(범주형과 다름)
        self.linear_cont = torch.nn.Linear(num_continuous, 1)

    def forward(self, x, x_cont):
        # 변수간 상호관계 임베딩
        embed_x = self.embedding(x)
        # DeepFM 계산
        x = self.linear(x) + self.fm(embed_x) + self.mlp(embed_x.view(-1, self.embed_output_dim))

        # 연속형 변수 처리
        cont_val = self.linear_cont(x_cont)

        return (x + cont_val).squeeze(1)

- FM(직접, 간접)과 Deep을 합친다

 

 

6. 학습전 주요 함수 셋팅

def RMSE(y_true, y_pred):
    return torch.sqrt(torch.mean((y_true - y_pred) ** 2))

def evaluate_model(model, data_loader, criterion):
    model.eval()  # 모델을 평가 모드로 설정
    total_loss = 0
    total_rmse = 0
    total_count = 0

    with torch.no_grad():  # 기울기 계산 비활성화
        for fields, target, conti_data in data_loader:
            y_pred = model(fields, conti_data.unsqueeze(1).float())
            loss = criterion(y_pred, target.float())
            rmse = RMSE(target.float(), y_pred)

            total_loss += loss.item() * target.size(0)
            total_rmse += rmse.item() * target.size(0)
            total_count += target.size(0)

    average_loss = total_loss / total_count
    average_rmse = total_rmse / total_count
    return average_loss, average_rmse

# 체크포인트 파일 경로 설정
checkpoint_dir = './finalproj/'
checkpoint_filename = os.path.join(checkpoint_dir, 'deepfm_with_cont_var_checkpoint.pth')

# 체크포인트 저장을 위한 함수
def save_checkpoint(model, epoch, min_test_rmse, optimizer, filename=checkpoint_filename):
    state = {
        'epoch': epoch,
        'model_state': model.state_dict(),
        'optimizer_state': optimizer.state_dict(),
        'min_test_rmse': min_test_rmse
    }
    torch.save(state, filename)

# 체크포인트 로드를 위한 함수
def load_checkpoint(model, optimizer, filename=checkpoint_filename):
    if os.path.isfile(filename):
        print(f"Loading checkpoint '{filename}'")
        checkpoint = torch.load(filename)
        model.load_state_dict(checkpoint['model_state'])
        optimizer.load_state_dict(checkpoint['optimizer_state'])
        return checkpoint['epoch'], checkpoint['min_test_rmse']
    else:
        print(f"No checkpoint found at '{filename}'")
        return 0, float('inf')

min_test_rmse = float('inf')
start_epoch = 0

- RMSE함수

- checkpoint 저장, 로드하는 함수 셋팅

 

7. 학습

# 모델 정의
model = DeepFactorizationMachineModel(dataset.field_dims, embed_dim=16, mlp_dims=(16, 16), dropout=0.2)

# 손실 함수
criterion = torch.nn.MSELoss()

# 옵티마이저
optimizer = torch.optim.Adam(params=model.parameters(), lr=0.001, weight_decay=1e-6)

log_interval = 100

num_epochs = 120  # 에폭 수

# 이전 체크포인트가 있는 경우 로드
# start_epoch, min_valid_loss = load_checkpoint(model, optimizer)

for epoch in range(start_epoch, num_epochs):
    # 학습 루프
    model.train()
    total_loss = 0
    total_rmse = 0

    tk0 = tqdm.tqdm(train_data_loader, smoothing=0, mininterval=1.0)

    for i, (fields, target, conti_data) in enumerate(tk0):
        # fields, target = fields.to(device), target.to(device) (필요한 경우 GPU 사용을 위한 코드)

        y_pred = model(fields, conti_data.unsqueeze(1).float())  # 모델 예측
        loss = criterion(y_pred, target.float())  # 손실 계산
        rmse = RMSE(target.float(), y_pred)  # RMSE 계산

        model.zero_grad()
        loss.backward()
        optimizer.step()

        total_loss += loss.item()
        total_rmse += rmse.item()

        if (i + 1) % log_interval == 0:
            average_loss = total_loss / log_interval
            average_rmse = total_rmse / log_interval
            tk0.set_postfix(loss=average_loss, rmse=average_rmse)
            total_loss = 0
            total_rmse = 0

    # 학습이 완료된 후 모델의 성능을 검증 및 테스트 데이터셋으로 평가
    train_loss, train_rmse = evaluate_model(model, train_data_loader, criterion)
    test_loss, test_rmse = evaluate_model(model, test_data_loader, criterion)

    # 결과 출력
    print(f'Epoch {epoch + 1}/{num_epochs} completed.')
    print(f'Train Loss: {train_loss:.4f}, Train RMSE: {train_rmse:.4f}')
    print(f'Test Loss: {test_loss:.4f}, Test RMSE: {test_rmse:.4f}')

    # 베스트 모델 저장 및 성능 출력
    if test_rmse < min_test_rmse:
        print(f'Test RMSE decreased ({min_test_rmse:.6f} --> {test_rmse:.6f}). Saving model ...')
        save_checkpoint(model, epoch, min_test_rmse, optimizer)
        min_test_rmse = test_rmse
    else:
        print(f'Test RMSE did not decrease (Best: {min_test_rmse:.6f}, Current: {test_rmse:.6f})')

# 가장 성능좋은 모델 확인하기
# 체크포인트 파일 경로
checkpoint_filename = os.path.join(checkpoint_dir, 'deepfm_with_cont_var_checkpoint.pth')

# 체크포인트 로드
if os.path.isfile(checkpoint_filename):
    print(f"Loading best model from '{checkpoint_filename}'")
    checkpoint = torch.load(checkpoint_filename)
    model.load_state_dict(checkpoint['model_state'])
else:
    print(f"No best model found at '{checkpoint_filename}'")
    # 여기서는 로드할 모델이 없으므로 추가 처리가 필요할 수 있습니다.

# 모델을 평가 모드로 설정
model.eval()

# 테스트 데이터셋에 대한 손실 및 RMSE 계산
test_loss, test_rmse = evaluate_model(model, test_data_loader, criterion)

print(f'Test Loss: {test_loss:.4f}, Test RMSE: {test_rmse:.4f}')

- 주요 하이퍼 파라미터 셋팅해주고, 학습 실행

 

8. (중요) 추천 결과 및 성능향상 포인트

8-1. 학습할때는 범주형, 연속형 등 많은 데이터 넣고, 예측시에는 userid, itemid만 넣는 모델(프로젝트 요건: 입력시 개인정보 입력안됨)

- 학습시 입력데이터: user_id, item_id, verified, votes

- 예측시 입력데이터: user_id, item_id

- 결과: RMSE 1.0 이상(별로 않좋음)

- 학습시 입력되는 데이터와 예측시 입력되는 데이터가 다르니 별로 않좋은 결과값이 나옴

 

 

8-2. userid, itemid, timestamp 데이터 활용하여 DeepFM

 

8-3. 리뷰 텍스트 데이터 추가해서 예측

 

8-4. DeepFM 마지막에 feature직접+feature간접+feature비선형(deep)을 신경망에 통과시켜 rating예측

- 위에 보는 것처럼, DeepFM가장 잘나온 것이 1.01

- 벤치마킹으로 생각한 MF+Deep이 0.90이 나왔다. 이 결과를 가지고 가면 파이널 프로젝트는 망 ㅜㅜ

- 그래서 이것저것 계속 변화시키다 찾은 방법. 가장 마지막 FM과 Deep의 결과를 단순히 합치는 구조가 아니라, 신경망을 한번더 통과시켜보면 어떨까? 결과는 0.92

- 그리고, batch size, adamw등 다른 파라미터도 약간씩 조정하면서 최종적으로 0.90돌파하여, 0.86!!

입력은 그림과 같은 데이터, 마지막에 다 더해주는 곳에 신경망 추가. 최종 결과는 0.86의 RMSE을 뽑을수 있었다.

 

8-5. 정리

- DeepFM의 여정은 다음과 같았다

- 입력되는 변수를 범주형, 연속형, 기본 등으로 해보고

- 리뷰 텍스트데이터를 감성으로 바꿔서 rating에 가중치를 주어보고,

- 그러다, 기존 DeepFM구조에 마지막 신경망을 추가해서 성능향상

- 그리고 다른 하이퍼파라미터 조정하면서 성능향상

 

 

9. 정리

- DeepFM은 정통적인 모델기반의 CF인 FM의 장점과 비선형성을 반영하는 Deep러닝의 장점을 담아내는 모델이 가능

- 하지만, 이론과 다르게 안정적인 성능이 나오지 않음

- 기존 DeepFM구조를 그대로 따르지 않고, 추가 변형하여 성능 향상

- 하이퍼파라미터를 적절히 조정함으로 추가 성능향상이 가능했음

- 프로젝트를 통해서, 어떻게 추천이 이뤄지는지 확인할 수 있었고, 딥러닝 추천은 어떻게 이뤄지는지도 확인할 수 있었음

- 또한, 성능향상을 위해서, 어떤 아이디어들을 적용하고 시도할 수 있는지도 배웠음