dersblog

Kümeleme (Clustering) ile Tavsiye Sistemi

Tavsiye sistemlerini kodlamanın klasik yolu kullanıcı-ürün matrisinde (ki matris ögeleri beğeni notunu taşır) arama yapmaktır. Kendi beğenilerim bu matris dışında ona benzer ama ayrı bir satır olarak düşünülebilir, bu satırın tüm kullanıcılara olan mesafesini [2] hesaplarım, bana en yakın kullanıcıları bulurum, ve onların en beğendiği ürünleri tavsiye alırım.

Bu teknik tüm matrisi tarar, onun tüm satırlarına bakar. Beğeni matrisi seyrektir tabii ki bu aramayı hızlandırır fakat yine de tavsiye sistemi tüm veriye sahip olmalıdır.

Bir diğer yöntem kullanıcı satırlarını kümelere ayırarak beğenileri birbirine benzeyen kişileri aynı grup altında toplamak. Böylece her küme için onu temsil eden bir küme merkezi vektörü elde edebiliriz, ve yeni kullanıcının bu merkezlere olan mesafesini hesaplarız sadece, 10-20 tane küme için, ve sonra kümedeki kullanıcıları listeleyip onların beğenilerini tavsiye olarak veririz.

Örnek veri [1] sitesinden, oradaki ml-latest-small.zip verisini alacağız. Daha büyük bir veri seti ml-25m.zip içinde. Veride bazı önişlemler hala lazım, veri setinde ratings.csv dosyası mesela şu formatta,

userId,movieId,rating,timestamp
1,296,5.0,1147880044
1,306,3.5,1147868817
1,307,5.0,1147868828
1,665,5.0,1147878820

Aynı kullanıcı kimliği birden fazla farklı satıra dağılmış halde. Satır satır işleme bazlı yapan yaklaşımlar için bir kullanıcının tüm verisini aynı satırda almak daha iyidir, böylece azar azar (incremental) işlem birimi kullanıcı olur, tek satır okuyunca tek bir kullanıcı işlemiş oluruz. Ayrıca parallel işlem gerektiğinde aynı kullanıcı veisinin farklı süreçlere dağıtılması gerekmez, bunun kordinasyonu zor olurdu. O zaman üstteki veriyi kullanıcı bazlı hale getirelim, ayrıca verinin seyrekliğini gözönünde bulundurursak, not verilen filmleri sözlük öğesi yapalım. Sözlük, metin formatta JSON olarak tutulabilir, dosyaya böyle yazılır. Yeni veride iki kolon olacak, birincisi userId, ikincisi ise bir JSON metni. Değişimi yapalım,

import json, csv, subprocess, os

indir = "/opt/Downloads/ml-latest-small"
outdir = "/tmp/movie"
infile = indir + "/ratings.csv"
outfile = outdir + "/ratings-json.csv"
curruser = 0
row_dict = {}

if os.path.exists(outdir) == False: os.mkdir(outdir)

fout = open(outfile, "w")
with open(infile) as csvfile:   
    rd = csv.reader(csvfile,delimiter=',')
    headers = {k: v for v, k in enumerate(next(rd))}
    for row in rd:
        if row[headers['userId']] != curruser:
            fout.write(str(curruser) + "|")
            fout.write(json.dumps(row_dict))
            fout.write("\n")
            fout.flush()
            curruser = row[headers['userId']]
            row_dict = {}       
        row_dict[int(row[headers['movieId']])] = float(row[headers['rating']])
fout.close()

def show_out(n,m):
    fin = open(outfile)
    for i,line in enumerate(fin.readlines()):
        if i>=n and i<= m: print (line[:30],"..")
        if i>m: break

def ls(dirname):
   cmd = "/bin/ls -s1 " + dirname
   process = subprocess.Popen(cmd.split(" "), stdout=subprocess.PIPE, stdin=subprocess.PIPE, stderr=subprocess.STDOUT, shell=False)
   output, err = process.communicate()
   res = str(output).split("\\n")
   for x in res: print (x)   
show_out(2,6)
2|{"318": 3.0, "333": 4.0, "17 ..
3|{"31": 0.5, "527": 0.5, "647 ..
4|{"21": 3.0, "32": 2.0, "45": ..
5|{"1": 4.0, "21": 4.0, "34":  ..
6|{"2": 4.0, "3": 5.0, "4": 3. ..

Kullanıcı 4 film 21 için 3 notunu vermiş.. böyle gidiyor.

Kümeleme

K-Means algoritmasının ana mantığı, paralel versiyonu [3,4]'te işlendi.

Algoritmaya geçmeden önce bazı hazırlık işlemleri yapalım. Filmlerin tabandaki kimlik no'ları matris üzerinde kullanıma hazır değil, onlara 0'dan başlayan bir artarak giden kimliği biz atayacağız, ve aradaki eşlemeyi bir sözlük ile yapacağız, sözlükler movie_id_int.json ve movie_title_int.json içinde olacak.

Ayrıca K-Means'te bir ilk fitilin yakılması, önyükleme (boostrap) gerekir, çünkü algoritma verili etiketler üzerinden küme merkezleri, verili küme merkezleri üzerinden etiket eşlemesi hesaplar, özyineli bir algoritmadir, fakat ilk başlangıçta bu iki bilgide mevcut değildir, bu durumda bir yerden başlanılması gerekir, çoğu yaklaşım mesela küme merkezlerini rasgele atar. Bu örnekte küme merkezi yerine etiket ataması (hangi kullanıcı hangi kümeye ait) rasgele yapılırsa daha iyi, çünkü seyrek veri durumunda reel sayılar olan küme merkezlerini iyi seçememek mümkün. Belli bir aralıktaki tam sayı küme ataması daha basit, cluster_ass değişkeninde bu atama olacak.

import os, numpy as np, json, pandas as pd

K = 5 # kume sayisi
M = 9742 # film sayisi
U = 610 # kullanici sayisi

np.random.seed(0)

def prepare():

    df = pd.read_csv(indir + "/movies.csv")
    d = df.reset_index().set_index('movieId')['index'].to_dict()
    fout = open(outdir + "/movie_id_int.json","w")
    fout.write(json.dumps(d))
    fout.close()

    df = pd.read_csv(indir + "/movies.csv")
    d = df.reset_index().set_index('title')['index'].to_dict()
    fout = open(outdir + "/movie_title_int.json","w")
    fout.write(json.dumps(d))
    fout.close()

    cluster_ass = np.random.randint(K,size=U)
    np.savez(outdir + '/cluster-assignments-0',cluster_ass)

prepare()

Bu kodu işlettikten sonra gerekli başlangıç dosyaları outdir içinde olmalı. Şimdi işletimi sağlayacak yardımcı kodu paylaşalım, detaylar [4] içinde bulunabilir, ileride paralel işletimi de sağlayacak,

import os

def process(file_name,N,hookobj):
    file_size = os.path.getsize(file_name)
    beg = 0
    chunks = []
    for i in range(N):
        with open(file_name, 'r') as f:
            s = int((file_size / N)*(i+1))
            f.seek(s)
            f.readline()
            end_chunk = f.tell()-1
            chunks.append([beg,end_chunk])
            f.close()
        beg = end_chunk+1
    c = chunks[hookobj.ci]
    with open(file_name, 'r') as f:
        f.seek(c[0])
        i = 0
        while True:
            i += 1
            line = f.readline()
            hookobj.exec(line)
            if f.tell() > c[1]: break
        f.close()
        hookobj.post()        

Artık işleyici kodları yazabiliriz. Bize iki tane ayrı kod bloğu gerekiyor, bunlardan ilki daha önce söylediğimiz gibi verili küme atamalarını kullanarak küme merkezlerini hesaplar. İkincisi küme merkezlerini kullanarak kullanıcı küme atamalarını yapar. Atama verisi basit tek bir vektör içinde, U tane kullanıcı (seyirci), K tane küme var, o zaman (1,M) boyutunda, içeriği 0 ile K arasında bir değer, ilk atamaların rasgele yapıldığı vektöre bakalım,

ca = np.load(outdir + "/cluster-assignments-0.npz")['arr_0']
print ('atamalar',ca.shape)
print (ca[:10], '...')
atamalar (610,)
[4 0 3 3 3 1 3 2 4 0] ...

İkinci kod parçası atamaları kullanıp küme merkezlerini yaratır, bu merkez bir küme içindeki seyircilerin tüm filmlere verdiği notların ortalamasıdır, bu tüm kümeler için yapılır o zaman sonuç matrisinin boyutu K küme M film için K x M olmalı. Her iki kod parçası alttadır,

import numpy.linalg as lin
fin1  = outdir + "/ratings-json.csv"

np.random.seed(0)

class KMeans1Job:
    def __init__(self,ci,iter_no):
        self.ci = ci
        self.iter_no = iter_no
        self.movie_id_int = json.loads(open(outdir + "/movie_id_int.json").read())
        self.cluster_ass = np.load(outdir + "/cluster-assignments-%d.npz" % int(self.iter_no-1))['arr_0']
        self.s = np.zeros((K,M))
        self.N = np.zeros((K,M))

    def exec(self,line):
        id,jsval = line.split("|")
        ratings = json.loads(jsval)
        my_k = int(self.cluster_ass[int(id)])
        for mov,rating in ratings.items():
            self.s[my_k, self.movie_id_int[mov]] += rating
            self.N[my_k, self.movie_id_int[mov]] += 1.0

    def post(self):
        means = np.divide(self.s, self.N, out=np.zeros_like(self.s), where=self.N>0)
        np.savez(outdir + '/means-%d' % self.iter_no, means)
        print ('KMeans1Job tamam')

class KMeans2Job:
    def __init__(self,ci,iter_no):
        self.ci = ci
        self.iter_no = iter_no
        self.means = np.load(outdir + "/means-%d.npz" % int(self.iter_no))['arr_0']
        self.movie_id_int = json.loads(open(outdir + "/movie_id_int.json").read())
        self.cluster_ass = np.zeros((U,))


    def exec(self,line):
        id,jsval = line.split("|")
        ratings = json.loads(jsval)
        vec = np.zeros(M)
        for mov,rating in ratings.items():
            vec[self.movie_id_int[str(mov)]] = rating
        nearest = np.argmin(lin.norm(vec[vec>0] - self.means[:,vec>0],axis=1))
        self.cluster_ass[int(id)] = nearest

    def post(self):
        np.savez(outdir + '/cluster-assignments-%d' % self.iter_no,self.cluster_ass)
        print ('KMeans2Job tamam')

Ortalamalar

KMeans1Job kodunda ortalamalar için her küme için o küme altındaki kullanıcıların notları, yani vektörler toplanır, aynı sırada kaç not verilmiş olduğu takip edilir, ve toplam not sayısına bölünür. Sıfır ile bölüm tehlikesi var muhakkak, bu durumda özel bir bölme çağrısı kullanacağız, numpy.divide, bir örnek altta,

s = np.zeros((3,3,))
N = np.zeros((3,3,))

s[0,2] = 4; s[1,1] = 4; s[2,1] = 7

N[0,2] = 2; N[1,1] = 4; N[2,1] = 7

print (s)
print (N)
print ('\n')
print (s/N)
print (np.divide(s, N, out=np.zeros_like(N), where=N>0))
[[0. 0. 4.]
 [0. 4. 0.]
 [0. 7. 0.]]
[[0. 0. 2.]
 [0. 4. 0.]
 [0. 7. 0.]]


[[nan nan  2.]
 [nan  1. nan]
 [nan  1. nan]]
[[0. 0. 2.]
 [0. 1. 0.]
 [0. 1. 0.]]

Mesafe

Devam etmeden önce önemli bir konuya değinelim, bu da mesafe kavramı. K-Means kümelemesi Öklit (Euclidian) mesafe kullanır, bu mesafenin sıfır değerlerini nasıl kullandığına dikkat etmek gerekir. Sıfır değeri tabii ki bizim seçimlerimiz bağlamında "seyredilmemiş" demektir. Fakat Öklitsel mesafe hesaplıyorsam, mesela benim üç boyutlu vektörüm x1,y1,z1 ile başka bir x2,y2,z2 vektör arasında bu hesap (x1-x2) karesi artı (y1-y2) karesi vs diye devam ediyor. Sonra bu toplamın karekökü alınıyor. Fakat eğer bende x filmi seyredilmemişse, yoğun matris bağlamında orada sıfır vardır, ama küme merkezine uzaklık hesaplıyorsam bu merkezin x2 (ve diğer y2,z2).. öğesinde muhakkak bir değer vardır. Bu mevcut değere uzaklık hesaplarsam bendeki sıfır değeri mevcut değeri beni uzakmışım gibi gösterecektir. Fakat belki o filmi seyretsem belli bir kümenin değerine yakın not verirdim, o zaman ona yakın gözükürdüm. Demek ki bendeki boş değerler mesafesel olarak problem çıkartabilir. İşte bu sebeple KMeans2Job kodunda mesafe hesaplarken vec[vec>0] - self.means[:,vec>0] kodunu kullandık, yani vec üzerinde sıfır olmayan değerlerin self.means ile mesafesine bakılıyor. Diğerleri Öklit mesafesine dahil edilmiyor.

Kodu işletelim, şimdilik iki kod parçasını tek bir döngü içinde çağıracağız,

process(file_name = outdir + '/ratings-json.csv', N=1, hookobj = KMeans1Job(0,1))
process(file_name = outdir + '/ratings-json.csv', N=1, hookobj = KMeans2Job(0,1))
KMeans1Job tamam
KMeans2Job tamam
ls (outdir)
b'total 2156
   8 cluster-assignments-0.npz
   8 cluster-assignments-1.npz
 384 means-1.npz
 140 movie_id_int.json
 344 movie_title_int.json
1272 ratings-json.csv

Görüldüğü gibi -1.npz ile biten dosyalar yaratıldı, bu dosyaların 1'inci döngünün sonuçları. Boyutlara bakıyoruz,

ca = np.load(outdir + "/cluster-assignments-1.npz")['arr_0']
means = np.load(outdir + "/means-1.npz")['arr_0']
print ('atamalar',ca.shape)
print ('kume merkezleri',means.shape)
atamalar (610,)
kume merkezleri (5, 9742)

Kullanım

Döngüyü birkaç kez işletelim,

for iter_no in range(1,10):
    process(file_name=fin1, N=1, hookobj = KMeans1Job(0,iter_no))
    process(file_name=fin1, N=1, hookobj = KMeans2Job(0,iter_no))

Bunu yapmak sonuçları iyileştirir. Kendi seçtiğimiz bazı filmlere notlar verelim, ve bu seçimlere yakın olan kümeyi bulalım,

picks = """
movie,rating
Swordfish (2001),5
"Rock, The (1996)",5
Dunkirk (2017),2
"""

import io

dt = json.loads(open(outdir + "/movie_title_int.json").read())
means = np.load(outdir + "/means-9.npz")['arr_0']
picks = pd.read_csv(io.StringIO(picks),index_col=0).to_dict('index')
vec = np.zeros(M)
for mov,rating in picks.items():
    if str(mov) in dt:
        vec[dt[str(mov)]] = rating['rating']
nearest = np.argmin(lin.norm(vec[vec>0] - means[:,vec>0],axis=1))
print ('en yakin', nearest)
en yakin 3

En yakın kümeyi bulduk. Şimdi not verdiğimiz her filmin tüm kümelerde ortalama nasıl notlanmış olduğuna bakalım, acaba bizim notlarla uyumlu mu?

means[:,dt['Swordfish (2001)']]
Out[1]: array([2.25      , 3.5       , 3.33333333, 3.2       , 2.875     ])
means[:,dt['Rock, The (1996)']]
Out[1]: array([3.8125    , 3.3125    , 3.58064516, 3.85714286, 3.5       ])
means[:,dt['Dunkirk (2017)']]
Out[1]: array([3.375     , 3.75      , 5.        , 3.16666667, 3.        ])

Uyumlu gözüküyor.

Paralel işletme kısmını ödev bırakıyoruz, burada eklenecek kodların genel yaklaşımından bahsedelim. Ortalama için her paralel işletici kullanıcıların bir kısmını işler, ve o kısımla ilgili küme ortalamalarını hesaplar. İki tane süreç iki tane K x M ortalama yaratır, o zaman her döngüde paralel süreç ortalamaları bitince seri şekilde (paralel değil) bu ortalamaların ortalamaları alınır, o döngünün nihai ortalaması bu olur.

Küme ataması daha bariz, çünkü zaten kullanıcı bazlı hesaplanan ve atanan bir değer, eh bizim paralel yaklaşım da kullanıcı bazlı işbölümü yaptığına göre burada tek yapılması gereken paralel atama bitince her süreçten gelen sonuçları birleştirmektir, yani ucu uca getirip yapıştırmak (concatanate), bu kadar.

Not: K-Means algoritmasi nereden başlanırsa başlansın yakınsama yapabilen (convergent) bir algoritmadır, bir optimal noktaya muhakkak ulaşır. Fakat bu optimal nokta yerel (local) optima olabilir, genel, kullanışlı optima olmayabilir. Bu durumda farklı yerlerden (birkaç farklı rasgele noktadan) algoritmaya birkaç kere işletip nereye ulaştığına bakmak bir ek yöntem olabilir.

Kaynaklar

[1] https://grouplens.org/datasets/movielens/

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

[3] K-Means Kümeleme Metodu

[4] Paralel Veri Analizi, İstatistik


Yukarı