티스토리 뷰

협업필터링, 영어로는 Collaborative Filtering을 구현해보겠다.

아직 자연어처리에서 글 분류가 완벽하지 않지만 최근에 collaborative filtering을 코드로 짜볼 기회가 있었기에, 잊기 전에 한 번 만들어보려고 한다.

 

0. 서론

Collaborative Filtering(이하 협업필터링. 영어로 쓰기 너무 길어서.)에 대해 간단히 알아보자.

협업 필터링이란 간단히 말하면 유사한 것들끼리는 유사한 결과를 보일 것이라는 가정으로, 아직 모르는 값을 예측하는 것이다.

(나는 그렇게 이해했다. 그렇게 보면 필터링이란 이름은 딱히 어울리지 않는 것 같다.  분석이나 예측이 더 가깝지 않을까...?)

협업필터링 과정은 유사한 것들을 찾는 유사도 계산과 결과 예측, 이렇게 2단계로 크게 나뉜다.

 

최근 '추천'시스템의 대부분은 이 협업필터링을 사용했다고 보면 될 정도로 많이 쓰인다.

 

협업필터링도 종류가 여러가지 있는데, 크게 보면 memory based CF(협업필터링 약어), model based CF로 나뉜다.

메모리 기반 협업필터링이란, 전통적인 방법이며 이미 있는 데이터(메모리)를 기반으로 필터링을 하는 것이다.

모델 기반은 기계학습을 적용한 필터링 방식으로 사용자(평가 주체)와 아이템(평가 대상)이 숨겨진 특성 값을 계산하여 필터링 하는 것이다.

기계학습이다보니 역시나 모델 기반이 더 유용하고 멋지다.

내 목표도 결국 모델 기반 협업 필터링을 만드는 것이다.

 

내가 최근에 코딩할 기회가 있었다는 것은 메모리 기반이었고, 그래서 전통적인 방식인 메모리기반 협업필터링을 먼저 해볼 것이다.

메모리 기반 협업필터링은 또 두 가지 방법으로 나뉜다.

user-based CF, item-based CF.

 

사용자 기반 CF는 사용자 중심으로 유사도를 계산하는 것이고

(사용자 A와 사용자 B가 유사하다 -> A가 좋아했다면 B도 좋아할 것이다 -> B에게 A가 좋아하는 것을 추천해주자)

아이템 기반 CF는 아이템 중심으로 유사로를 계산하는 것이다.

(아이템 a와 아이템 b가 유사하다 -> 사용자 A가 a를 좋아했으니 비슷한 b도 좋아할 것이다 -> A에게 A가 좋아하는 것과 비슷한 것을 추천해주자)

 

더 자세한 특성이나 장단점은 다음 주소들을 참조.

위키피디아

잘 정리된 사이트1

잘 정리된 사이트2

 

1. 구현 계획

나는 강의평가 데이터 200여개가 있다.

user 한 명당 평균 5개 꼴로 강의평가에 참여했다.

강의(item) 수는 user 수에 비해 아주 많다.

사용자 기반 CF는 사용자 사이에 중복해서 평가한 강의평가가 적어서 힘들 것이라고 생각했다.

사용자 기반 CF를 하려면 다른 사용자지만 동시에 강의평가한 강의들이 좀 겹쳐야 유사도가 제대로 나오기 때문이다.

 

그래서 나는 아이템 기반 CF를 구현하기로 결정했다.

아이템 기반 CF도 데이터가 많아야 하지만, 사용자 기반보다는 조금 적어도 잘 나온다.

이유는... 음... 그렇다고 한다.

뭔가 직관적으로는 알 것 같은데 논리적으로 설명하기 힘들다. (모르는 거겠지)

 

내 데이터의 경우, item이 될 수 있는 것은 2개이다.

강의가 하나의 item이고, 교수도 하나의 item이다.

한 강의를 여러 교수가 맡을 수도 있고, 여러 강의를 한 교수가 맡을 수도 있다.

그래서 나는 교수와 강의를 각각 아이템 기반 CF로 구현했다.

전체적인 방법은 같다.

 

앞서 말했듯, 협업 필터링은 2단계로 나뉜다.

①유사도 계산 -> ②상위 유사도에서 추천

두 번째 과정은 비슷하나 첫 번째 과정은 또 다시 여러가지 방법이 있다.

 

유사도는 '얼마나 비슷한가'인데, 비슷하다는 수치를 정의하는 것은 제각각이다.

단순하게 유클리디안 유사도(두 점 사이의 거리를 계산)를 이용하는 방법,

코사인 유사도(두 (단위)벡터를 내적하여 얼마나 비슷한 방향인가)를 이용하는 방법,

피어슨 유사도(두 값이 경향성이 얼마나 비슷한가. 변화량이 클 때 다른 것도 변화량이 큰가)를 이용하는 방법,

자카드 유사도(값이 boolean 일 때. 값의 크기가 없을 때)를 이용하는 방법.

보통 이 4가지가 많이 쓰이는 것 같은데, 그 중에서도 피어슨을 꽤 많이 쓰는 것 같다.

이전에 구현해본 것도 피어슨 유사도이다.

 

하지만! 아이템 기반 CF에서는 코사인 유사도를 사용한다기에 코사인을 써보기로 했다.

다른 유사도로 바꾸는 것은 쉽다.

이미 다른 유사도를 함수로 구현해놓긴 했다.

 

정리 : item-based collaborative filtering 구현하기 순서

데이터 전처리 -> cosine 유사도 계산 -> 결과값에서 유사도가 큰 item 관계 출력 -> 시각화

 

결론부터 말하면, 데이터가 너무 적어서 (...) 딱히 뭔가 크게 와닿는 결과는 없다.

 

2. 데이터 전처리

이게 제일 오래 걸렸다.

어떤 자료구조를 사용할 것인지, 어떤 데이터를 빼낼 것인지 결정하는 게 까다롭다.

 

 

데이터 추출

먼저 django에서 데이터를 빼내야 했다.

manage.py shell로 접근한다.

 

import json
from django.core import serializers

# 데이터 추출 - django serializers 사용
from tas.models import Response, Answer, Question, Course, Instructor
json_str = serializers.serialize('json', Response.objects.all(), fields=('user', 'course', 'instructor'))
json_data = json.loads(json_str)
json_dict = {item['pk']:item['fields'] for item in json_data}

# 데이터 수정 - star data 추가
star_data = Question.objects.get(pk=1).answer_set.all()
for ans in star_data:
    r_id = ans.response.id
    json_dict[r_id]['star'] = ans.text

# 데이터 저장 - json 파일
with open('json_data.json', 'w') as outfile:
    json.dump(json_dict, outfile)

django의 serializers라는 라이브러리를 사용했는데, 쉽게 추출할 수 있게 도와줬다.

json 파일로 추출하려고 했기에 json이라는 옵션을 줬는데, xml이나 csv도 사용할 수 있다.

fields에는 추출할 필드만 적어준다.

return 값인 json_str은 json(python dict이 list에 저장된 형태)이 string으로 되어있다.

자세한 것은 장고 다큐멘터리 참고.

 

주의할 것은 json.load와 json.loads가 다르다.

s는 string이라는 뜻이다.

serializers가 string 값을 리턴해줘서 loads를 사용해야 string이 제대로 dict 형태로 json_data에 저장된다.

나는 이 데이터에서 추가해줄 데이터가 있기에 json_dict로 만들어줬다.

 

데이터 수정부분에서는 serializer가 추출할 수 없는 데이터를 직접 json_dict에 추가해주는 부분이다.

pk=1인 Question object에 달려있는 Answer object를 불러온다.

그리고 Answer object의 text 데이터를 그에 맞는 json_dict에다 추가해주는 부분이다.

내 데이터라 저런 부분이 있는 것이므로, 알아서 맞게 고쳐서 사용하면 된다.

 

데이터 저장은 json.dump를 사용했는데, 여기서도 json.dumps과 json.dump가 다르다.

s는 string을 뜻한다.

json을 string으로 바꿔서 사용하고 싶으면 dumps로 해도 되는데, 나는 바로 굳이 string으로 바꿀 필요가 없다고 느껴서 그냥 dump를 사용했다.

둘 다 해봤는데 뭐가 다른지 모르겠다.

그냥 저걸 썼다.

 

데이터 처리 : instructor vector 구하기

데이터가 있는 서버(가상환경)에서 json 파일을 저장하고, 이제 CF를 구현할 서버(가상환경)에서 json을 불러온다.

그리고 값을 원하는 자료구조 형태에 저장해준다.

나는 딕셔너리를 사용했다.

2d list를 사용할 수 있지만, 강의 수나 사용자 수에 비해 너무 강의평가가 적기에, dict으로 사용했다.

이것은 dfs나 bfs를 할 때 2d matrix로 표현할 수도 있고 dict으로 표현할 수 있는 것과 같다.

 

import json
# data 불러오기
def get_data(filename = 'json_data.json'):
    with open(filename) as json_file:
        json_data = json.load(json_file)
    return json_data
    
# dict에 각 강의의 별점 정보 저장
def get_matrix2item(data):
    course_ratings = dict()
    instructor_ratings = dict()
    # user가 key, course와 instructor가 value
    for _,i in data.items():
        if i['user'] == None:
            continue
        if course_ratings.get(i['user']):
            course_ratings[i['user']][0].append(i['course'])
            course_ratings[i['user']][1].append(int(i['star']))
            for instr in i['instructor']:
                instructor_ratings[i['user']][0].append(instr)
                instructor_ratings[i['user']][1].append(int(i['star']))
        else:
            course_ratings[i['user']] = [[i['course']], [int(i['star'])]]
            instructor_ratings[i['user']] = [i['instructor'], [int(i['star']) for _ in range(len(i['instructor']))]]
    return course_ratings, instructor_ratings
         
def get_item_vectors(cr,ir):
    # cr, ir = get_matrix2item(get_data())
    idx2course = dict()
    course2idx = dict()
    c_vectors = dict()
    # item과 item 사이의 ratings를 표시해둔 딕셔너리. key=course 2개 반점구분 str
    # 각 값은 [[r_11, r_21, r_31, ...],[r_12, r_22, r_32, ...]].
    # r_nm 뜻 : user_n이 item_m을 평가한 점수
    idx2instr = dict()
    instr2idx = dict()
    i_vectors = dict()

    # ...(생략)...

    idx = 0
    for _, [instrs, ratings] in ir.items():
        # print(courses,ratings)
        num_instrs = len(instrs)

        if num_instrs < 2:
            continue
        else:
            for i in range(num_instrs-1):
                for j in range(i+1,num_instrs):
                    if instrs[i] == instrs[j]:
                        # 재수강, 중복 응답 등
                        continue
                    if instr2idx.get(instrs[i]) == None:
                        instr2idx[instrs[i]] = idx
                        idx2instr[idx] = instrs[i]
                        idx += 1
                    if instr2idx.get(instrs[j]) == None:
                        instr2idx[instrs[j]] = idx
                        idx2instr[idx] = instrs[j]
                        idx += 1
                    dict_key1 = str(instrs[i])+','+str(instrs[j])
                    dict_key2 = str(instrs[j])+','+str(instrs[i])
                    # print(c_vectors)
                    if i_vectors.get(dict_key1) == None:
                        i_vectors[dict_key1]=[[ratings[i]], [ratings[j]]]
                        i_vectors[dict_key2]=[[ratings[j]], [ratings[i]]]
                    else:
                        i_vectors[dict_key1][0].append(ratings[i])
                        i_vectors[dict_key1][1].append(ratings[j])
                        i_vectors[dict_key2][0].append(ratings[j])
                        i_vectors[dict_key2][1].append(ratings[i])

    return (c_vectors, course2idx, idx2course), (i_vectors, instr2idx, idx2instr)

코드가 좀 길다...

get_data는 json 파일을 읽는 부분이다.

get_matrix2item은 dict을 만드는데, user의 강의평가를 저장해놓은 dict이다.

key는 user의 id이며, value는 강의평가(강의 평가를 그 강의를 진행하는 교수 평가로 생각) 내용인데, list형식이며 list의 첫 번째는 교수 list, 두 번째는 각 교수에 준 평점 list이다. 두 list의 같은 인덱스는 같은 강의를 의미한다.

 

get_item_vectors에서 교수의 item_vector를 만드는데, item vector는 어떤 교수를 평가한 user들의 점수이다.

예를 들어 교수 A에게 user1이 3점, user2가 5점, user3이 4점을 줬다면 A의 vector는 [3,5,4]가 된다.

 

우리는 similarity를 구해야 하므로, 서로 다른 두 교수(A,B)를 동시에 평점을 내린 user(1,2,3,...)의 강의평가 점수가 필요하다.

A와 B에 모두 평점을 준 user가 3명이라고 하자.

강의평가 점수(rating) 교수 A (id:100) 교수 B (id:33)
user1 3 5
user2 5 4
user3 4 4
vector [3,5,4] [5,4,4]

i_vectors라는 dict에는 instructor id 2개를 key로 한다.

위의 경우, i_simils는 다음과 같은 형식으로 저장된다.

i_simils['100,33'] = [ [3,5,4], [5,4,4] ]

i_simils['33,100'] = [ [5,4,4], [3,5,4] ]

 

사실 위의 두 유사도 값은 같다.

중복이라서 피해줘야 하는데, 일단은 그냥 했다.

 

3. similarity 구하기

   
# cosine 유사도 계산하기 위해. cosine similarity의 분모
def sqrt_sum(v):
    length = len(v)
    sums = 0
    for i in v:
        sums += i**2
    return sqrt(sums)

# 유사도 계산. cosine similarity
def cosine_simil(v1,v2):
    length = len(v1)
    sums = 0
    for i in range(length):
        sums += v1[i]*v2[i]
    denominator = sqrt_sum(v1) * sqrt_sum(v2)
    if denominator:
        return sums / denominator
    else:
        return 0
        
def real_round(n, decimals=0):
    # python의 round는 오차가 생기므로 제대로된 round 함수 구현
    multiplier = 10 ** decimals
    return floor(n*multiplier + 0.5) / multiplier

def item_based_cosine_instructor_cf():
    _,(iv,instr2idx,_) =  get_item_vectors(*get_matrix2item(get_data()))
    num_instrs = len(iv)
    i_simils = [[ [] for _ in range(num_instrs)] for _ in range(num_instrs)]
    print(len(i_simils))
    for k,v in iv.items():
        ##############중복방지!!!!!!!!!!!
        num_v = len(v[0])
        if num_v < 2:
            continue
            # 1차원 단위 벡터는 내적하면 무조건 1. 의미 없음.
        i_from = int(k.split(',')[0])
        idx_from = instr2idx[i_from]
        i_to = int(k.split(',')[1])
        idx_to = instr2idx[i_to]
        # item과 item 사이의 similarity를 표시해둔 2차원 배열. 각 값은 [similarity, num_user].
        # similarity는 cosine / num_user는 similarity를 계산한 표본 수
        simil = cosine_simil(v[0],v[1])
        # print(real_round(simil,3), simil ,num_v, v, idx_from, idx_to)
        i_simils[idx_from][idx_to] = [real_round(simil,3),num_v]
    return i_simils

일단 먼저 중복이 있다는 걸 알아야 한다.

A와 B의 유사도는 B와 A의 유사도와 같다.

아직 처리하지 않았는데, 염두에 두고 보길.

 

item_based_cosine_instructor_cf함수를 보면 i_simils라는 list가 보인다.

이것은 item들 사이에 유사도가 어떤지를 저장한 2d list이다.

i_simils(cosine similarity) 교수 A 교수 B 교수 C ...
교수 A - [0.955, 3] -  
교수 B [0.955, 3] - -  
교수 C - - -  
...        

-은 값이 없는 곳이다.

0.955는 [3,5,4]와 [5,4,4]의 cosine similarity이고 3은 동시에 평가 내린 user의 수이다.

나중에 쓸 것 같아서 같이 저장했다.

소수점 4째자리에서 반올림했다.

 

보면 알겠지만, cosine 유사도의 경우, 값이 대부분 0.8 이상에서 형성된다.

(내 데이터의 경우, 5점 만점에 1점 단위로 평가 가능하다)

0.9의 경우, 값이 꽤 커보이지만 사실 그렇게 유사도가 높은 것은 아니다.

 

유의해야할 점 또 하나는, 한 사람(user1)만이 교수 A와 교수 B한테 평점을 줬다면 cosine similarity는 항상 1의 값을 가진다.

교수 A한테 5점을 주고 교수 B한테 1점을 줬다고 해도 말이다.

왜냐면 cosine similarity가 방향 벡터의 내적 개념이기에, 1차원 벡터 2개([5]와 [1])는 항상 같은 방향을 향하기 때문이다.

그러므로 무조건 2차원 벡터 이상(동시에 평가한 user 수가 2명 이상)이어야 한다.

안 그래도 데이터가 적은데, 구할 수 있는 값이 더 적어진다.

 

4. 유사도가 높은 값 출력 / 시각화

앞서 말했듯, 내 데이터는 부족하다....

그래서 유사도가 높은 수업(교수)을 출력한다는 것 자체가 별 의미가 없다.

왜냐면 유사도를 계산할 수 있는 수업(교수)가 거의 없기 때문이다.

데이터가 부족하다는 것은 참 심각한 일이다.

 

그래서 유사도가 높은 교수/수업을 보여준다는 것은 의미가 없다.

 

이왕 이렇게 된 거 데이터도 별로 없는데 시각화나 해보려고 했다.

시각화를 하면 제대로 유사도가 계산이 됐는지 가늠은 할 수 있을 것 같았다.

수학과 교수, 인문/교양 수업 교수, 등 비슷한 과목을 가르치는 교수 사이엔 유사도의 연관성이 있지 않을까, 해서 한 번 봤다.

 

시각화 : UCINET

시각화를 위해서 네트워크를 그려주는 것에 특화된 프로그램을 사용했다.

UCINET이라는 프로그램인데, 예전에 저엉말 상관 없어보이는 교육경제학을 공부하고 연구할 때 다뤄봤다.

정말 어떤 경험이라도 쓸 수 있는 곳이 꼭 있다.

모든 경험은 그래서 소중하다.

(김*삼 교수님 감사합니다.)

 

UCINET을 사용하기 위해서 여러 과정이 필요한데, 생략하겠다.

라고 말했지만 내가 다음에 봐야 하니 간단히 정리는 하는 게 낫겠다.

label이 index 0인 row와 column에 있고, 값이 없는 곳은 0으로 채워진 i_simils가 필요하다.

csv로 저장. encoding이 잘 되어야 한다.

with open('network.csv', 'w', encoding='utf-8', newline='') as f:
    r = csv.writer(f)
    r.writerows(i_simils)

excel 실행 -> 데이터 -> 텍스트 -> csv 불러오기 -> 반점으로 구분 됐다고 설정 ... -> xls로 저장

ucinet 실행 -> data -> data editors -> matrix editor -> 저장파일 불러오기(file -> open) -> 저장(file -> save -> UCINET 파일 선택 후 저장)

uninet 실행 -> visualize -> Netdraw -> File -> Open -> Ucinet data -> Network -> 파일 선택하고 default 설정으로 그냥 열기

상단 아이콘 툴바에서 '선분 위에 1.4' 적힌 곳 클릭 -> all / visible

알아서 예쁘게 고치고 저장도 알아서 하면 됨.

 

UCINET으로 시각화한 유사도 네트워크. 편의상 cosine similarity에 100을 곱했고 이름은 가렸다.

자, 여기서 문제점이 하나 보인다.

왼쪽 상단에 몰려있는 집단이 보이는데, 유사도도 모두 1(사진에서는 100)이다.

데이터를 살펴보니 한 과목을 공동으로 수업한 교수님들이었다.

한 수업에 평점을 내리면 그 수업을 가르친 모든 교수님들한테 같은 점수가 부여된다.

그래서 교수님들이 1의 유사도를 보이는 것이다.

데이터가 크면 조정이 되겠지만, 적어서 저런 식으로 몰려있는 것이다.

 

왼쪽에 0으로 label 되어있는 값들은 평가 데이터가 없는 교수님들이라 label을 붙이지 않은 것이다.

undirected graph인데도 불구하고 weight 값이 한 edge에 2개씩 표시되어 있다.

나중에 중복을 피하면 없어질 문제이다.

 

시각화까지 끝냈다.

재밌는 그림이다.

아마 나중에 데이터가 좀 많아지고 시각화가 가능하다면 재밌는 결과를 도출할 수 있을 것 같다.

 

5. 다른 방법

item-based CF를 하다가 instructor가 아닌 course에 대해서도 진행했고, cosine이 아닌 euclidean similarity도 구해봤다.

cosine과 euclidean은 차이가 많이 났었다.

pearson도 데이터가 적어서 별 의미가 없었다.

user-based는 더욱 데이터가 없었다.

 

참고할 다른 사이트를 추가해보면 다음과 같다.

파이썬으로 실습한 곳 중심이다.

https://kutar37.tistory.com/38

https://yumere.tistory.com/70?category=430507

 

6. 앞으로 할 방법

앞으로는 model-based CF를 시도해 볼 것이다.

아니, 그보다 먼저 data 확보가 시급할 것 같다.

돈을 좀 쓰더라도 데이터를 보아야 한다.

흑... 돈... 돈이 필요해...

댓글