Exploration 16
2022. 3. 7. 11:39
Exploration 16 다음에 볼 영화 예측하기
Session-Based Recommendation
Session-Based Recommendation이란?
세션 데이터를 기반으로 유저가 다음에 클릭 또는 구매할 아이템응ㄹ 예측하는 추천
Session: 유저가 서비스를 이용하면서 발생하는 중요한 정보를 담은 데이터를 말하며, 블라우저가 조료되기 전까지 유저의 행동을 담은 시퀀스 데이터
사용데이터: YOOCHOOSE, 추천 엔진 솔류션 회사에서 공개한 E-Commerce데이터
- 유저에 대한 정보를 전혀 알 수 없다.(성별, 나이, 장소, 마지막 접속 낳짜, 이전 구매 내역 등등)
- 아이템에 대한 정보도 전혀 알 수 없다.(실제로 어떤 물건인지, 사진이나 설명, 가격)
- 비로그인 상태로 탐색하는 유저가 많다.
- 로그인 상태로 탐색한다고 할지라도 접속할 때마다 탐색하는 의도가 뚜렷하게 다르다.
Data Preprocesss
Data Load
- 데이터를 불러와 살펴보고 주요 통계치들을 살펴보기
- 이번 자료에서는 Click데이터에 있는 Session Id, TimeStamp, ItemId만사용
import datetime as dt
from pathlib import Path
import os
import numpy as np
import pandas as pd
import warnings
warnings.filterwarnings('ignore')
data_path = Path(os.getenv('HOME')+'/aiffel/yoochoose/data')
train_path = data_path / 'yoochoose-clicks.dat'
train_path
def load_data(data_path: Path, nrows=None):
data = pd.read_csv(data_path, sep=',', header=None, usecols=[0, 1, 2],
parse_dates=[1], dtype={0: np.int32, 2: np.int32}, nrows=nrows)
data.columns = ['SessionId', 'Time', 'ItemId']
return data
# 시간이 좀 걸릴 수 있습니다. 메모리도 10GB 가까이 소요될 수 있으니 메모리 상태에 주의해 주세요.
data = load_data(train_path, None)
data.sort_values(['SessionId', 'Time'], inplace=True) # data를 id와 시간 순서로 정렬해줍니다.
data

data['SessionId'].nunique(), data['ItemId'].nunique()
'''
(9249729, 52739)
'''
Session Length
각 세션이 대략 몇 개의 클릭 데이터를 갖는지 확인
session_length = data.groupby('SessionId').size()
session_length
'''
SessionId
1 4
2 6
3 3
4 2
6 2
..
11562156 2
11562157 2
11562158 3
11562159 1
11562161 1
Length: 9249729, dtype: int64
'''
- session_length: SessionId를 공유한느 데이터 row의 개수를 의미
- SessionId란 브라우저에서 웹서버로 접속할 때 항상 포함하세 되는 유저 구분자
→ 로그인하지 않았기 때문에 이 사용자가 누군지느 알 수 없어도, 최소한 특정 사용자의 행동을 SessionId기준으로 모아서 분류해낼 수 있다. 따라서 session_length란 해당 세션의 사용자가 그 세션 동안 몇 개의 상품정보를 클릭했는지의 의미가 된다.
분석
session_length.min(), session_length.max()
'''
(1, 200)
'''
session_length.quantile(0.999)
'''
41.0
'''
- 각 세션의 길이는 보통 2~3이고 99.9% 세션은 길이가 41이하이다.
- 길이가 200인 세션은 전처리가 필요하다.
길이가 200인 세션 확인
long_session = session_length[session_length==200].index[0]
data[data['SessionId']==long_session]

세션 길이 기준 하위 99.9%까지의 분포 누적합
length_count = session_length.groupby(session_length).size()
length_percent_cumsum = length_count.cumsum() / length_count.sum()
length_percent_cumsum_999 = length_percent_cumsum[length_percent_cumsum < 0.999]
length_percent_cumsum_999
import matplotlib.pyplot as plt
plt.figure(figsize=(12, 10))
plt.bar(x=length_percent_cumsum_999.index,
height=length_percent_cumsum_999, color='red')
plt.xticks(length_percent_cumsum_999.index)
plt.yticks(np.arange(0, 1.01, 0.05))
plt.title('Cumsum Percentage Until 0.999', size=20)
plt.show()

Session Time
- 추천 시스템을 구축할 때에는 최근 소비 트렌드를 학습하는것이 중요하다.
데이터의 시간 관련 정보 확인
oldest, latest = data['Time'].min(), data['Time'].max()
print(oldest)
print(latest)
'''
2014-04-01 03:00:00.124000+00:00
2014-09-30 02:59:59.430000+00:00
'''
최종 날짜의 한 달전까지의 데이터만 사용
- 날짜끼리의 차이를 구하고 싶을 때는 datetime 라이브러리의 timedelta 객체를 사용
month_ago = latest - dt.timedelta(30) # 최종 날짜로부터 30일 이전 날짜를 구한다.
data = data[data['Time'] > month_ago] # 방금 구한 날짜 이후의 데이터만 모은다.
data

Data Cleaning
- 비정상적으로 많이 클릭된 아이템뿐만아니라 너무 적게 클릭된 아이템도 전처리를 해줘야한다.
# short_session을 제거한 다음 unpopular item을 제거하면 다시 길이가 1인 session이 생길 수 있습니다.
# 이를 위해 반복문을 통해 지속적으로 제거 합니다.
def cleanse_recursive(data: pd.DataFrame, shortest, least_click) -> pd.DataFrame:
while True:
before_len = len(data)
data = cleanse_short_session(data, shortest)
data = cleanse_unpopular_item(data, least_click)
after_len = len(data)
if before_len == after_len:
break
return data
def cleanse_short_session(data: pd.DataFrame, shortest):
session_len = data.groupby('SessionId').size()
session_use = session_len[session_len >= shortest].index
data = data[data['SessionId'].isin(session_use)]
return data
def cleanse_unpopular_item(data: pd.DataFrame, least_click):
item_popular = data.groupby('ItemId').size()
item_use = item_popular[item_popular >= least_click].index
data = data[data['ItemId'].isin(item_use)]
return data

Train / Vaild / Test split
test_path = data_path / 'yoochoose-test.dat'
test= load_data(test_path)
test['Time'].min(), test['Time'].max()
'''
(Timestamp('2014-04-01 03:00:08.250000+0000', tz='UTC'),
Timestamp('2014-09-30 02:59:23.866000+0000', tz='UTC'))
'''
- 1달 전에 성능이 좋은 모델을 지금 쓰면 사용자들의 소비 패턴이 달라지기 때문에 맞지 않는다. 따라서 추천 시스템은 지금 잘 예측하는 것이 중요하다.
데이터 분리

def split_by_date(data: pd.DataFrame, n_days: int):
final_time = data['Time'].max()
session_last_time = data.groupby('SessionId')['Time'].max()
session_in_train = session_last_time[session_last_time < final_time - dt.timedelta(n_days)].index
session_in_test = session_last_time[session_last_time >= final_time - dt.timedelta(n_days)].index
before_date = data[data['SessionId'].isin(session_in_train)]
after_date = data[data['SessionId'].isin(session_in_test)]
after_date = after_date[after_date['ItemId'].isin(before_date['ItemId'])]
return before_date, after_date
tr, test = split_by_date(data, n_days=1)
tr, val = split_by_date(tr, n_days=1)
data 정보 확인
# data에 대한 정보를 살펴봅니다.
def stats_info(data: pd.DataFrame, status: str):
print(f'* {status} Set Stats Info\n'
f'\t Events: {len(data)}\n'
f'\t Sessions: {data["SessionId"].nunique()}\n'
f'\t Items: {data["ItemId"].nunique()}\n'
f'\t First Time : {data["Time"].min()}\n'
f'\t Last Time : {data["Time"].max()}\n')
stats_info(tr, 'train')
stats_info(val, 'valid')
stats_info(test, 'test')
'''
* train Set Stats Info
Events: 5125100
Sessions: 1243431
Items: 20153
First Time : 2014-08-31 03:00:01.111000+00:00
Last Time : 2014-09-28 02:57:34.348000+00:00
* valid Set Stats Info
Events: 58074
Sessions: 12350
Items: 6232
First Time : 2014-09-28 03:00:25.298000+00:00
Last Time : 2014-09-29 02:58:27.660000+00:00
* test Set Stats Info
Events: 71009
Sessions: 15289
Items: 6580
First Time : 2014-09-29 02:37:20.695000+00:00
Last Time : 2014-09-30 02:59:59.430000+00:00
'''
# train set에 없는 아이템이 val, test기간에 생길 수 있으므로 train data를 기준으로 인덱싱합니다.
id2idx = {item_id : index for index, item_id in enumerate(tr['ItemId'].unique())}
def indexing(df, id2idx):
df['item_idx'] = df['ItemId'].map(lambda x: id2idx.get(x, -1)) # id2idx에 없는 아이템은 모르는 값(-1) 처리 해줍니다.
return df
tr = indexing(tr, id2idx)
val = indexing(val, id2idx)
test = indexing(test, id2idx)
데이터 저장
save_path = data_path / 'processed'
save_path.mkdir(parents=True, exist_ok=True)
tr.to_pickle(save_path / 'train.pkl')
val.to_pickle(save_path / 'valid.pkl')
test.to_pickle(save_path / 'test.pkl')
논문소개(GRU4REC)
- 사용할 모델:2016년 ICLR에 공개된 SESSION-BASED RECOMMENDATIONS WITH RECURRENT NEURAL NETWORKS
- Session Data에서 처음으로 RNN계열 모델을 적용

Session-Parallel Mini-Batches:

- Session의 길이는 매우 짧은 것들이 대부분이다. 따라서 session하나로 mini-batch를 구성하여 input으로 넣는다면 길이가 제일 긴 session의 연산이 끝날 때까지 짧은 session들이 기다려야 한다.
- 위에 방식은 session 1,2,3을 하나의 mini-batch로 만든다면, session 3이 끝나야 4가 시작된다.
- 하지만 Session-Parallel Mini-Batches는 기다리지않고 병렬적으로 수행해 session 2가 끝나면 session 4가 시작하는 방식이다.
- 이렇게 구성을 하게 되면 Mini-Batch의 shape은 (3, 1, 1)이 되고 RNN cell의 state가 1개로만 이루어진다.

- SAMPLING ON THE OUTPUT: Negative Sampling과 같은 개념이다. item의 수가 많기 때문에 Loss를 계산할 때 모든 이이템을 비교하지 않고 인기도를 고려하여 Sampling한다.
- Ranking Loss: Session-Based Recommendation Task를 어려 아이템 중 다음 아이템이 무엇인지 classification하는 task로 생각할 수 있지만 여러 아이템을 관련도 순으로 랭킹을 매겨서 높은 랭킹의 아이템을 추천하는 Task로도 생각할 수 있다.
Data Pipeline
SessionDataset
- 데이터가 주어지면 세션이 시작되는 인덱스를 담는 값과 세션을 새로 인덱싱한 값을 갖는 클래스를 만든다.
class SessionDataset:
"""Credit to yhs-968/pyGRU4REC."""
def __init__(self, data):
self.df = data
self.click_offsets = self.get_click_offsets()
self.session_idx = np.arange(self.df['SessionId'].nunique()) # indexing to SessionId
def get_click_offsets(self):
"""
Return the indexes of the first click of each session IDs,
"""
offsets = np.zeros(self.df['SessionId'].nunique() + 1, dtype=np.int32)
offsets[1:] = self.df.groupby('SessionId').size().cumsum()
return offsets
train에 적용
tr_dataset = SessionDataset(tr)
tr_dataset.df.head(10)

tr_dataset.click_offsets
'''
array([ 0, 4, 6, ..., 5125095, 5125097, 5125100],
dtype=int32)
'''
tr_dataset.session_idx
'''
array([ 0, 1, 2, ..., 1243428, 1243429, 1243430])
'''
SessionDataLoader
class SessionDataLoader:
"""Credit to yhs-968/pyGRU4REC."""
def __init__(self, dataset: SessionDataset, batch_size=50):
self.dataset = dataset
self.batch_size = batch_size
def __iter__(self):
""" Returns the iterator for producing session-parallel training mini-batches.
Yields:
input (B,): Item indices that will be encoded as one-hot vectors later.
target (B,): a Variable that stores the target item indices
masks: Numpy array indicating the positions of the sessions to be terminated
"""
start, end, mask, last_session, finished = self.initialize() # initialize 메소드에서 확인해주세요.
"""
start : Index Where Session Start
end : Index Where Session End
mask : indicator for the sessions to be terminated
"""
while not finished:
min_len = (end - start).min() - 1 # Shortest Length Among Sessions
for i in range(min_len):
# Build inputs & targets
inp = self.dataset.df['item_idx'].values[start + i]
target = self.dataset.df['item_idx'].values[start + i + 1]
yield inp, target, mask
start, end, mask, last_session, finished = self.update_status(start, end, min_len, last_session, finished)
def initialize(self):
first_iters = np.arange(self.batch_size) # 첫 배치에 사용할 세션 Index를 가져옵니다.
last_session = self.batch_size - 1 # 마지막으로 다루고 있는 세션 Index를 저장해둡니다.
start = self.dataset.click_offsets[self.dataset.session_idx[first_iters]] # data 상에서 session이 시작된 위치를 가져옵니다.
end = self.dataset.click_offsets[self.dataset.session_idx[first_iters] + 1] # session이 끝난 위치 바로 다음 위치를 가져옵니다.
mask = np.array([]) # session의 모든 아이템을 다 돌은 경우 mask에 추가해줄 것입니다.
finished = False # data를 전부 돌았는지 기록하기 위한 변수입니다.
return start, end, mask, last_session, finished
def update_status(self, start: np.ndarray, end: np.ndarray, min_len: int, last_session: int, finished: bool):
# 다음 배치 데이터를 생성하기 위해 상태를 update합니다.
start += min_len # __iter__에서 min_len 만큼 for문을 돌았으므로 start를 min_len 만큼 더해줍니다.
mask = np.arange(self.batch_size)[(end - start) == 1]
# end는 다음 세션이 시작되는 위치인데 start와 한 칸 차이난다는 것은 session이 끝났다는 뜻입니다. mask에 기록해줍니다.
for i, idx in enumerate(mask, start=1): # mask에 추가된 세션 개수만큼 새로운 세션을 돌것입니다.
new_session = last_session + i
if new_session > self.dataset.session_idx[-1]: # 만약 새로운 세션이 마지막 세션 index보다 크다면 모든 학습데이터를 돈 것입니다.
finished = True
break
# update the next starting/ending point
start[idx] = self.dataset.click_offsets[self.dataset.session_idx[new_session]] # 종료된 세션 대신 새로운 세션의 시작점을 기록합니다.
end[idx] = self.dataset.click_offsets[self.dataset.session_idx[new_session] + 1]
last_session += len(mask) # 마지막 세션의 위치를 기록해둡니다.
return start, end, mask, last_session, finished
tr_data_loader = SessionDataLoader(tr_dataset, batch_size=4)
tr_dataset.df.head(15)

iter_ex = iter(tr_data_loader)
inputs, labels, mask = next(iter_ex)
print(f'Model Input Item Idx are : {inputs}')
print(f'Label Item Idx are : {"":5} {labels}')
print(f'Previous Masked Input Idx are {mask}')
'''
Model Input Item Idx are : [0 3 5 7]
Label Item Idx are : [1 4 6 7]
Previous Masked Input Idx are []
'''
Modeling
Evaluation Metric
- Session-Based Recommendation Task에서는 모델이 k개의 아이템을 제시했을 때, 유저가 클릭/구매한 n개의 아이템이 많아야 좋다.
- 따라서 recall의 개념을 확장한 recall@k, precision의 개념을 확장한 Mean Average Precision@k를 사용한다.
- 추천 시스템에서는 몇 번째로 맞추느냐도 중요하다. 따라서 순서의 민감한 지표인 MRR, NDCG같은 지표도 사용한다.
MRR, recall@k 사용
def mrr_k(pred, truth: int, k: int):
indexing = np.where(pred[:k] == truth)[0]
if len(indexing) > 0:
return 1 / (indexing[0] + 1)
else:
return 0
def recall_k(pred, truth: int, k: int) -> int:
answer = truth in pred[:k]
return int(answer)
Model Architecture
import numpy as np
import tensorflow as tf
from tensorflow.keras.layers import Input, Dense, Dropout, GRU
from tensorflow.keras.losses import categorical_crossentropy
from tensorflow.keras.models import Model
from tensorflow.keras.optimizers import Adam
from tensorflow.keras.utils import to_categorical
from tqdm import tqdm
def create_model(args):
inputs = Input(batch_shape=(args.batch_size, 1, args.num_items))
gru, _ = GRU(args.hsz, stateful=True, return_state=True, name='GRU')(inputs)
dropout = Dropout(args.drop_rate)(gru)
predictions = Dense(args.num_items, activation='softmax')(dropout)
model = Model(inputs=inputs, outputs=[predictions])
model.compile(loss=categorical_crossentropy, optimizer=Adam(args.lr), metrics=['accuracy'])
model.summary()
return model
hyper-parameter class화
class Args:
def __init__(self, tr, val, test, batch_size, hsz, drop_rate, lr, epochs, k):
self.tr = tr
self.val = val
self.test = test
self.num_items = tr['ItemId'].nunique()
self.num_sessions = tr['SessionId'].nunique()
self.batch_size = batch_size
self.hsz = hsz
self.drop_rate = drop_rate
self.lr = lr
self.epochs = epochs
self.k = k
args = Args(tr, val, test, batch_size=2048, hsz=50, drop_rate=0.1, lr=0.001, epochs=3, k=20)
model = create_model(args)
'''
Model: "model"
_________________________________________________________________
Layer (type) Output Shape Param #
=================================================================
input_1 (InputLayer) [(2048, 1, 20153)] 0
_________________________________________________________________
GRU (GRU) [(2048, 50), (2048, 50)] 3030750
_________________________________________________________________
dropout (Dropout) (2048, 50) 0
_________________________________________________________________
dense (Dense) (2048, 20153) 1027803
=================================================================
Total params: 4,058,553
Trainable params: 4,058,553
Non-trainable params: 0
_________________________________________________________________
'''
Model Training
- 학습시간이 많이 걸리므로 학습 직전까지만 구현
# train 셋으로 학습하면서 valid 셋으로 검증합니다.
def train_model(model, args):
train_dataset = SessionDataset(args.tr)
train_loader = SessionDataLoader(train_dataset, batch_size=args.batch_size)
for epoch in range(1, args.epochs + 1):
total_step = len(args.tr) - args.tr['SessionId'].nunique()
tr_loader = tqdm(train_loader, total=total_step // args.batch_size, desc='Train', mininterval=1)
for feat, target, mask in tr_loader:
reset_hidden_states(model, mask) # 종료된 session은 hidden_state를 초기화합니다. 아래 메서드에서 확인해주세요.
input_ohe = to_categorical(feat, num_classes=args.num_items)
input_ohe = np.expand_dims(input_ohe, axis=1)
target_ohe = to_categorical(target, num_classes=args.num_items)
result = model.train_on_batch(input_ohe, target_ohe)
tr_loader.set_postfix(train_loss=result[0], accuracy = result[1])
val_recall, val_mrr = get_metrics(args.val, model, args, args.k) # valid set에 대해 검증합니다.
print(f"\t - Recall@{args.k} epoch {epoch}: {val_recall:3f}")
print(f"\t - MRR@{args.k} epoch {epoch}: {val_mrr:3f}\n")
def reset_hidden_states(model, mask):
gru_layer = model.get_layer(name='GRU') # model에서 gru layer를 가져옵니다.
hidden_states = gru_layer.states[0].numpy() # gru_layer의 parameter를 가져옵니다.
for elt in mask: # mask된 인덱스 즉, 종료된 세션의 인덱스를 돌면서
hidden_states[elt, :] = 0 # parameter를 초기화 합니다.
gru_layer.reset_states(states=hidden_states)
def get_metrics(data, model, args, k: int): # valid셋과 test셋을 평가하는 코드입니다.
# train과 거의 같지만 mrr, recall을 구하는 라인이 있습니다.
dataset = SessionDataset(data)
loader = SessionDataLoader(dataset, batch_size=args.batch_size)
recall_list, mrr_list = [], []
total_step = len(data) - data['SessionId'].nunique()
for inputs, label, mask in tqdm(loader, total=total_step // args.batch_size, desc='Evaluation', mininterval=1):
reset_hidden_states(model, mask)
input_ohe = to_categorical(inputs, num_classes=args.num_items)
input_ohe = np.expand_dims(input_ohe, axis=1)
pred = model.predict(input_ohe, batch_size=args.batch_size)
pred_arg = tf.argsort(pred, direction='DESCENDING') # softmax 값이 큰 순서대로 sorting 합니다.
length = len(inputs)
recall_list.extend([recall_k(pred_arg[i], label[i], k) for i in range(length)])
mrr_list.extend([mrr_k(pred_arg[i], label[i], k) for i in range(length)])
recall, mrr = np.mean(recall_list), np.mean(mrr_list)
return recall, mrr
# 학습 시간이 다소 오래 소요됩니다. 아래 주석을 풀지 마세요.
# train_model(model, args)
# 학습된 모델을 불러옵니다.
model = tf.keras.models.load_model(data_path / 'trained_model')
Inference
- 모델 성능 검증
def test_model(model, args, test):
test_recall, test_mrr = get_metrics(test, model, args, 20)
print(f"\t - Recall@{args.k}: {test_recall:3f}")
print(f"\t - MRR@{args.k}: {test_mrr:3f}\n")
test_model(model, args, test)
'''
Evaluation: 81%|████████▏ | 22/27 [02:27<00:33, 6.70s/it]
- Recall@20: 0.711270
- MRR@20: 0.309212
''''AIFFEL > exploration' 카테고리의 다른 글
| Exploration 17 (0) | 2022.03.14 |
|---|---|
| Exploration 15 (0) | 2022.02.24 |
| Exploration 14 (0) | 2022.02.22 |
| Exploration 13 (0) | 2022.02.17 |
| Exploration 12 (0) | 2022.02.15 |



