Ssul's Blog
[추천시스템#8] DeepFM 추천시스템 본문
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은 각 요소를 임베딩하여,
- 요소의 직접적인 영향과 요소들간의 간접적인 영향을 반영하여 예측값을 가져오는 것이다.
- FM알고리즘을 활용하면, user/item/... 각각의 요소가 target에 반영
- 또한 user-item, item-other 등 요소들간의 간접적인 영향도 target에 반영
2-2. DeepFM이란(이론적)
- Deep러닝 + FM이 결합된 알고리즘
- FM에서 요소의 직접적인 영향, 요소간 간접적인 영향 중 선형 특성 반영
- Deep에서 요소간 간접적인 영향 중 비선형 특성 반영
- FM에서 못 잡아냈던, 비선형적인 특성을 Deep러닝 신경망을 통해서 잡아낸다.
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)등이 있음
- ratings데이터는 우리가 익숙한, userid, itemid, 평점, timestamp로 구성되어 있음
- 기타 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해서 매칭
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!!
8-5. 정리
- DeepFM의 여정은 다음과 같았다
- 입력되는 변수를 범주형, 연속형, 기본 등으로 해보고
- 리뷰 텍스트데이터를 감성으로 바꿔서 rating에 가중치를 주어보고,
- 그러다, 기존 DeepFM구조에 마지막 신경망을 추가해서 성능향상
- 그리고 다른 하이퍼파라미터 조정하면서 성능향상
9. 정리
- DeepFM은 정통적인 모델기반의 CF인 FM의 장점과 비선형성을 반영하는 Deep러닝의 장점을 담아내는 모델이 가능
- 하지만, 이론과 다르게 안정적인 성능이 나오지 않음
- 기존 DeepFM구조를 그대로 따르지 않고, 추가 변형하여 성능 향상
- 하이퍼파라미터를 적절히 조정함으로 추가 성능향상이 가능했음
- 프로젝트를 통해서, 어떻게 추천이 이뤄지는지 확인할 수 있었고, 딥러닝 추천은 어떻게 이뤄지는지도 확인할 수 있었음
- 또한, 성능향상을 위해서, 어떤 아이디어들을 적용하고 시도할 수 있는지도 배웠음
'AI & ML' 카테고리의 다른 글
[추천시스템#7] 딥러닝 추천 (0) | 2024.01.15 |
---|---|
[추천시스템#6] FM(Factorization Machines) (2) | 2024.01.03 |
[추천시스템#5] MF(Matrix Factorization) (1) | 2023.12.29 |
[추천시스템#4]CB(콘텐츠 기반 필터링) (0) | 2023.12.22 |
[추천시스템#3]CF-UBCF(유저기반 협업필터링) (0) | 2023.12.21 |