dersblog

Movielens Filmleri, Kosinüs Benzerliği, Tavsiyeler

Kosinüs benzerliği konusunu [1]'de işledik, ölçüt iki vektörün birbirine çok boyutlu açısal yakınlığını hesaplar. Bunun için tek gereken bu iki vektörün arasındaki noktasal çarpım, ve her iki vektörün büyülüklerinin (norm) çarpımı. Aynı belgede bu hesabın hızlı yapılabilmesi için seyrek matris kavramından bahsedildi. Fakat aslında biz python kütüphanelerinden gelen seyrek matris formatları yerine kendi seyrek matris / vektör yapımızı da oluşturabiliriz.

İlerlemeden önce noktasal çarpımın ne olduğunu hatırlayalım, aynı boyuttaki iki vektörün birbirine tekabül eden her değeri çarpılır ve bu çarpımlar birbirine toplanır, lineer cebirde $a \cdot b$ ile gösterilen islem, ve hesapsal olarak şöyle yapılabilir,

a = np.array([2,4,6,7,4,9,4,3,2,5,6,3,1,2])
b = np.array([1,3,6,8,1,1,4,3,3,4,1,5,4,9])

float (np.sum([x*y for x,y in zip(a,b)]))
Out[1]: 213.0

Üstteki kodda zip ile her iki vektörün öğeleri gezildi, ve tarif edilen işlemler yapıldı.

Şimdi Movielens film not veri tabanını düşünelim. Kullanıcılar filmlere not verirler, eğer bir matriste kullanıcılar satırda filmler kolonda ise bir kullanıcının tüm notlarını bir vektör, diğer bir kullanıcının notlarını diğer bir vektör olarak elde edebilirdik, ve yakınlık için noktasal çarpımı bu iki vektör üzerinde gerçekleştirirdik. Eğer 2000 kullanıcı 1000 tane film var ise bir kullanıcını vektörü 1 x 1000 boyutunda olurdu.

Tabii kayıtlarda binlerce film olacaktır, ve herhangi bir kullanıcının tüm filmleri seyredip not vermiş olması imkansız, hatta seyretmiş olsa bile not vermemiş olabilir. Çoğunlukla bir kullanıcı 10-15 filme not vermiştir, belki yüzlerce. Her durumda da not sayısı azdır. Yani üstteki örnek veri aslında şuna benzeyebilir,

a = np.array([2,0,6,0,0,0,0,0,0,0,0,0,0,0,3,1,2])
b = np.array([0,3,1,0,0,0,0,0,0,0,3,3,4,1,1,2,0])

float (np.sum([x*y for x,y in zip(a,b)]))
Out[1]: 11.0

Görüldüğü gibi her iki vektörde de bir sürü sıfır var. Noktasal çarpımda sıfır çarpı herhangi bir değer sıfır olduğu için, herhangi bir hücrede sıfır varsa o noktadaki hesap sıfır olur, toplama etkisi olmaz. Ve dikkat üstteki örnekte sıfır olmayan çoğu değer çakışmıyor (ancak o zaman toplama katkı olabilir), mesela a 1'inci öğe 2 ama b aynı öğe sıfır, b 2'inci öğe 3 ama onun eşi a uzerinde sıfır, demek ki ilk iki öğenin toplama hiçbir katkısı olmayacak. Keşke sıfır olan hücreleri direk atlayabilseydik, değil mi? Seyrek matris formatları aslında bunu yapmamızı sağlar. Kütüphane scipy.sparse içinde bu amaç için pek çok kodlar vardır. Fakat biz kendi pişirdiğimiz kodlar ile de aynı sonucu elde edebiliriz.

Bir vektör yerine bir sözlük (dictionary) yapısı kullanarak bunu yapabilirdik. Sözlük anahtarı üstteki vektörde sıfır olmayan değerin indisi olabilir. Yeni "vektörlerimiz" alttaki gibi olur,

da = dict({0: 2, 2:6, 14:3, 15:1, 16:2})
db = dict({1: 3, 2:1, 10:3, 11:3, 12:4, 13:1, 14:1, 15:2})

Görüldüğü gibi, mesela da içinde 1, 3, .. gibi anahtar değerleri yok, bu anahtarlar atlandı çünkü değerleri sıfır. Şimdi noktasal çarpım mantığı şöyle kodlanabilir: sözlüklerden birini al, tüm öğelerini gez, gezerken bu anahtar değeri diğer sözlükte var mı diye kontrol et, varsa çarpımı yap, toplama ekle, yoksa sıfır değerini ekle. Dikkat edilirse gezilen sözlük zaten sıfır olan değerleri bastan içermediği için onları dolaylı olarak zaten atlamış oluyoruz.

res = sum(da[key]*db.get(key, 0) for key in da)
print (res)
11

Aynı sonucu aldık. Bir kodlama püf noktası, diğer sözlükte anahtar erişimi .get ile yaptık, ve bu get bir şey bulamadıysa sıfır döndürsün diye bir "yokluk olağan değerini" bu çağrıya bildirdik, çünkü

print (da.get(5))
None

olurdu, ama alttaki kullanımla,

print (da.get(5, 0))
0

elde edilir, böylece olmama durumunu sıfır değerine çevirip nihai çarpımı sıfırlamış oluyoruz.

Kodun optimal olduğunu görebiliriz. Gezilen ana sözlükte olmayan değerler (verilmemiş notlar) hiç gezilmiyor, çünkü onlar sözlük içinde bile değiller. Eğer çakışma yoksa get sıfır döndürüyor, toplama etkisi olmuyor. Hesapsal yük açısından get çağrısı çok hızlıdır, sabit zamanda işler. Gezilen değerlerin okunması aynı şekilde.

Üstteki algoritmaya farklı şekilerde de yardım edebiliriz. Mesela biz örnek veride da sözlüğünü gezdik (onun tüm öğelerini okuduk), bunu yapmamızın sebebi da sözlüğünün db'den daha küçük olmasıdır. Eğer verimizde bir tarafın her zaman diğer taraftan daha ufak vektörler vereceğini biliyorsak, bu bilgiyi koda gömebiliriz. Film tavsiyesi bağlamında benim kayıtlarımda not verdiğim 1000 üzerinde film var. Bu demektir ki benim beğeni vektörüm çoğu zaman karşılaştırma yaptığım Movielens tabanındaki diğer kullanıcıların beğeni vektörlerinden büyük olacaktır. Bu durumda kendim için bir noktasal çarpım kodluyorsam, algoritmayi her zaman diğer kullanıcının vektörünü gezecek şekilde kodlamalıyım.

Altta tarif edilen algoritmanın kodlaması var, bu algoritma büyük Movielens tabanı [2] üzerinden film tavsiyeleri üretiyor. Benim şahsi seçimlerim movpicks.csv içinde. Kod benim seçimlerimi kullanarak bana en yakın kullanıcıları bulur ve o en yakın kullanıcıların 4 ve daha üzeri not verdiği filmleri toparlayarak benim için bir tavsiye listesi oluşturur.

content = open("movrecom.py").read()
print(content.replace('\n', '\r\n'))
#
# Recommend movies based on Grouplens ratings file
#
# https://grouplens.org/datasets/movielens/latest/
#
# Download the full file, and unzip in a known location set in d
#
from scipy.sparse import csr_matrix
import scipy.sparse.linalg, json
import pandas as pd, numpy as np
import os, sys, re, csv

csv.field_size_limit(sys.maxsize)

d = "/opt/Downloads/ml-32m"

def sim_recommend():
    fin = d + "/user_movie.txt"
    picks = pd.read_csv(os.environ['HOME'] + '/Documents/kod/movpicks.csv',index_col=0).to_dict('index')
    skips = pd.read_csv(os.environ['HOME'] + '/Documents/kod/movskips.csv',index_col=0).to_dict('index')
    mov = pd.read_csv(d + "/movies.csv",index_col="title")['movieId'].to_dict()
    genre = pd.read_csv(d + "/movies.csv",index_col="movieId")['genres'].to_dict()
    mov_id_title = pd.read_csv(d + "/movies.csv",index_col="movieId")['title'].to_dict()
    picks_json = dict((mov[p],float(picks[p]['rating'])) for p in picks if p in mov)
    picks_norm = np.sqrt(sum(v**2 for v in picks_json.values()))
    res = []
    with open(fin) as csvfile:   
        rd = csv.reader(csvfile,delimiter='|')
        for i,row in enumerate(rd):
            jrow = json.loads(row[1])
            jrow_norm = np.sqrt(sum(v**2 for v in jrow.values()))
            dp = sum(jrow[key]*picks_json.get(int(key), 0) for key in jrow)
            dp = dp / ((picks_norm*jrow_norm)+1e-10)
            res.append([row[0],dp])
            if i % 1e4 == 0: print (i,dp)


    df = pd.DataFrame(res).set_index(0)
    df = df.sort_values(by=1,ascending=False).head(400)
    df = df.to_dict()[1] # the final list of close users

    recoms = []
    with open(fin) as csvfile:   
        rd = csv.reader(csvfile,delimiter='|')
        for i,row in enumerate(rd):
            jrow = json.loads(row[1])
            # if the user exists in the closest users list
            if str(row[0]) in df:
                # get this person's movie ratings
                for movid,rating in jrow.items():
                    if int(movid) not in mov_id_title: continue 
                    fres = re.findall('\((\d\d\d\d)\)', mov_id_title[int(movid)])
                    if rating >= 4 and \
                       mov_id_title[int(movid)] not in picks and \
                       mov_id_title[int(movid)] not in skips and \
                       'Animation' not in genre[int(movid)] and \
                       'Documentary' not in genre[int(movid)] and \
                       len(fres)>0 and int(fres[0]) > 2010: \
                       # add the picks of this user multiplied by his closeness
                       recoms.append([mov_id_title[int(movid)],rating*df[row[0]]])

    df = pd.DataFrame(recoms)
    df = df.sort_values(1,ascending=False)
    df = df.drop_duplicates(0)
    df.to_csv("/opt/Downloads/movierecom3.csv",index=None,header=False)

if __name__ == "__main__":      
    sim_recommend()

Üstteki kod Movielens 32M verisinin rating.csv dosyasından bir user_movie.txt dosyası üretilmiş olduğunu farz ediyor. Bu yeni dosya içeriği daha önce tarif ettiğimiz sözlük yapısının dosyalaşmış halidir, her satırda bir kullanıcı vardır ve her kullanıcının verdiği notlar o satırda bir JSON formatında paylaşılır, {'film1': not, "film10': not} şeklinde. Bildiğimiz gibi JSON formatı bir sözlüğün diske yazılması için kullanılabilmektedir. Değişim kodu alttaki bağlantıda paylaşılıyor.

Kodlar

movprep.py

Kaynaklar

[1] Bayramli, Toplu Tavsiye (Collaborative Filtering), Filmler, SVD ile Boyut İndirgeme

[2] Netflix, MovieLens 32M, (ml-32m)


Yukarı