티스토리 뷰

목표&질문 : 450개 data로 matrix factorization을 하면 제대로 될까?

내가 가진 평가 데이터

내가 운영하는 사이트에서 리뷰 data가 450개 정도 있다.

5개 이상하면 권한이 달라지게 해놔서, 대부분 사람들이 5개 이상 리뷰를 해놨다.

문제는 데이터가 450개 밖에 없다는 것인데, 이걸로도 matrix factorization(MF)이 잘 돌아갈지 궁금하다.

잘 돌아가는지 어떻게 평가할지도 잘 모르겠다.

 

리뷰는 총점이 있고, 세부 항목 점수가 10개 정도 있다.

과목의 특성을 선택하는 란도 있고, 주관식도 있지만, 일단 간단한 MF가 잘 돌아갈 지 확인해야 하므로, 총점 하나로만 하겠다.

 

data 옮기기 - pickle (serialization)

먼저 내가 가진 data를 웹서버 db에서 빼올 필요가 있다.

나는 django를 쓰기에 django api를 이용해서 데이터를 뺐다.

# linux
$ python manage.py shell

# django shell
>>> from <app_name>.models import <model_name> as md
>>> all_data  = md.objects.all()
>>> import pickle
>>> f = open('output.pickle', 'wb')
>>> pickle.dump(all_data, f)
>>> f.close()

f 를 'wb'로 하지 않고 'w'로만 하면 TypeError: must be str, not bytes가 난다.

 

output.pickle에 저장됐으면 사용할 directory 또는 서버로 옮긴다.

 

참고로 나는 data가 시간에 따라서 변할 수 있으므로, 파일 이름에 날짜를 추가해줬다.

'output_20190906.pickle' 같은 모양이다.

날짜를 추가할 땐 datetime 모듈을 이용했는데, 여기 링크를 참고했다.

 

data 가져오기 & MM 변환

데이터를 사용할 곳에서 data를 load할 함수를 만든다.

json.load()를 이용하면 dump한 그대로 가져올 수 있다.

# queryset2mm.py
import sys
sys.path.append('/usr/local/lib/python3.6/dist-packages')
import json

from buffalo.misc import aux, log

class Q2M:
    def __init__(self, conf_fname = 'Config/queryset2mm.json'):
        self.logger = log.get_logger()
        self.opt = aux.Option(conf_fname)

    def load_data(self):
        with open(self.opt.data_fname) as f:
            self.data = json.load(f)

    def make_mm(self):
        self.mms = []
        for str_rsp_id, vals in self.data.items():
            self.mms.append([vals['user'], vals['course'], int(vals['star'])])
        self.save_mm() #아래 설명

    def run(self):
        self.logger.info('start running')
        self.load_data()
        self.core_set()  #아래 설명
        self.make_mm()


if __name__ == '__main__':
    q2m = Q2M()
    q2m.run()
# Config/queryset2mm.json
{
  "data_fname" : "output.json"
}

queryset data를 mm(matrix market. data 형식의 종류)로 바꾸는 함수의 일부이다.

 

참고로 make_mm 부분에서 for loop을 돌 때, python2에서는 iteritems()를 썼지만 python3에서는 items()를 사용한다.

 

Matrix Market (MM)

buffalo에서는 data type이 크게 stream과 mm으로 나뉘는데, 이번엔 mm을 사용할 것이므로 mm이 무엇인지 알아보자.

 

매트릭스는 보통 n*m의 사각형으로 표현할 수 있는데, 성분에 0이 너무 많은 경우라든지 사각형으로 표현하기 힘든 경우에는 다른 형식으로 표현하는 것이 편하다.

원래 매트릭스

원래 매트릭스가 위와 같은 형태라고 하자.

MM 형식의 매트릭스

이렇게 바꾸는 것이 MM 형식이다. (여기서 표기하지 않은 곳을 0이라고 한다.)

세 번째 값은 nnz(non-zero value)라고 한다.

 

이건 약간 관광지도에서 많이 보던 형식이다.

제주도 광광지도의 일부. 전체 매트릭스라고 하자.

위와 왼쪽에 형광펜으로 칠해진 부분에 행과 열이 표시되어 있다.

그리고 관광지도 옆에는 index가 있는데, 이것이 mm 형식이라고 보면 된다.

관광지도의 index. 이것이 MM

그러므로 위에 나왔던 코드 중에 make_mm 함수가 mm을 만들어 주는 부분이다.

    def make_mm(self):
        self.mms = []
        for str_rsp_id, vals in self.data.items():
            self.mms.append([vals['user'], vals['course'], int(vals['star'])])

self.mms에 list 형태로 mm 형식 matrix가 저장되어 있다.

user-item matrix에 0이 너무 많으므로, mm 형식으로 표기하는 것이 좋다.

 

N core setting

맞는 표현일지는 모르겠지만, 논문에 10 core setting이라는 말을 봤다.

데이터를 전처리 하는 과정에서 user가 item과 interaction한 수가 (user-item matrix에서 row) 10개가 안 되면 데이터를 버리고, 마찬가지로 item이 10명 미만의 user에게 선택받았을 때 (user-item matrix에서 column) item을 지우는 방식이다.

충분한 data가 쌓인 것만 가지고 알고리즘을 돌리겠다는 것이다.

 

내 data에서도 하나의 item만 평가한 user는 유의미한 prediction을 하기 힘들다.

예를 들어, 내가 영화 <기생충> 딱 하나만 평가했다.

그걸로 내가 다른 어떤 영화를 좋아할 지 예상할 수 있는가?

어떤 결과가 나오더라도 데이터가 부족하기에 신뢰하기 힘들 수 있다.

그래서 나는 5개 이상 평가한 user에 대해서만 prediction이나 train을 하려고 한다.

 

하지만, 아직 전반적인 data가 부족하여 item은 user에 비해 훨씬 다양하기 때문에, 1번 이상 평가받은 item이 부족하다.

그래서 item은 그냥 1번만 평가돼도 사용하기로 했다...

 

코드는 다음과 같다.

    def core_set(self):
        # remove anonymous & less than min cnt # of user and response
        user_cnt = defaultdict(int)
        course_cnt = defaultdict(int)
        anon_num = 0
        self.logger.info('core setting -> %s' % \
                         (', '.join(['%s : %s' % (k,v) for k,v in self.opt.core_set.items()])))

        # counting users & items
        for (str_rsp_id, vals) in list(self.data.items()):
            if not vals['user']:
                del self.data[str_rsp_id]
                anon_num += 1
                continue
            user_cnt[vals['user']] += 1
            course_cnt[vals['course']] += 1
        self.logger.info('removed anonymous data : %s' % (anon_num))

        # filtering users & items which will be removed
        remove_user_list = []
        remove_item_list = []
        for u_id, u_cnt in user_cnt.items():
            if u_cnt < self.opt.core_set.get('user_min_item', 5):
                remove_user_list.append(u_id)
        if self.opt.core_set.get('item_min_user', 1) > 1:
            for i_id, i_cnt in course_cnt.items():
                if i_cnt < self.opt.core_set.get('item_min_user', 2):
                    remove_item_list.append(i_id)

        # removing users & items
        self.user_set = set()
        self.course_set = set()
        if remove_item_list or remove_user_list:
            for (str_rsp_id, vals) in list(self.data.items()):
                if vals['user'] in remove_user_list:
                    del self.data[str_rsp_id]
                elif vals['course'] in remove_item_list:
                    del self.data[str_rsp_id]
                else:
                    self.user_set.add(vals['user'])
                    self.course_set.add(vals['course'])

        self.logger.info('# user : %s, # course : %s' % (len(self.user_set), len(self.course_set)))
        self.logger.info('number of response data to analyse : %s' % (len(self.data)))

for loop를 보면, 굳이 self.data.items()에 list로 감싼 것을 볼 수 있는데, 이것을 하지 않으면, self.data가 for를 돌면서 delete된 것들 때문에 에러가 난다.

약간 편법이긴 하지만 이 방법이 간단해서 이렇게 썼다.

 

save mm

저장하는 부분은 그렇게 어렵지 않다.

다만 buffalo에서는 uid(user id)와 iid(item id) 파일이 필요하기 때문에, 그 부분을 신경써주기만 하면 된다.

uid는 user의 id를 모아둔 text file이고, iid는 item이다.

 

main 파일의 첫 번째 줄에는 %%MatrixMarket 어쩌구가 들어가는데, 그냥 파일 설명이다.

buffalo에서 첫 줄을 아마(?) 지우고 사용할 것이다. (확실한 건 나중에 써보면 알겠지...)

두 번째 줄에는 user수, item수, nnz수(interaction수)가 표기된다.

진짜 제대로 된 data는 세 번째 줄부터다.

 

    def save_mm(self):
        if not self.opt.get('mm_fname', False):
            mm_fname = self.opt.data_fname.split('.')[0] + '.mm'
            uid_fname = 'uid_' + self.opt.data_fname.split('.')[0]
            iid_fname = 'iid_' + self.opt.data_fname.split('.')[0]
        else:
            mm_fname = self.opt.mm_fname
            uid_fname = 'uid_' + self.opt.mm_fname
            iid_fname = 'iid_' + self.opt.mm_fname

        u_set = set()
        i_set = set()
        uid2idx = dict()
        iid2idx = dict()
        with open(mm_fname, 'w') as f, open(uid_fname, 'w') as fu, open(iid_fname, 'w') as fi:
            f.write('%%MatrixMarket matrix coordinate real general\n')
            f.write('%s %s %s\n' % (len(self.user_set), len(self.course_set), len(self.mms)))
            for line in self.mms:
                u_id, i_id, score = line
                if u_id not in u_set:
                    idx_uid = len(u_set) + 1 # index from 1
                    uid2idx[u_id] = idx_uid
                    u_set.add(u_id)
                    fu.write('%s\n' % u_id)
                else:
                    idx_uid = uid2idx[u_id]
                if i_id not in i_set:
                    idx_iid = len(i_set) + 1 # index from 1
                    iid2idx[i_id] = idx_iid
                    i_set.add(i_id)
                    fi.write('%s\n' % i_id)
                else:
                    idx_iid = iid2idx[i_id]

                f.write('%s %s %s\n' % (str(idx_uid), str(idx_iid), str(score)))

        self.logger.info('saved succesfully at %s' % mm_fname)
# queryset2mm.json
{
  "data_fname" : "output.json",
  "mm_fname" : "output.mm",
  "core_set" : {
    "user_min_item" : 5,
    "item_min_user" : 1
  }
}

 

데이터 처리는 여기까지!

다음에는 ALS를 돌려보겠다.

댓글