Los sistemas de recomendación apuntan a ayudar a un usuario o un grupo de usuarios dentro de un sistema a seleccionar ítems cuando existen muchos de estos. (MacNee et. al 2006) Éstos se clasifican de la siguiente manera:
Para este proyecto se experimentó con dos tipos de sistemas recomendadores, el primero, el cual llamaremos primer acercamiento es Filtrado Colaborativo y el segundo, el cual llamaremos segundo acercamiento es Model-based: SVD.
En abril del 2012 el sitio Kaggle lanzo una compentencia llamada "Million Song Dataset Challenge" el cual consistió en: dado el historial completo de canciones de 1 millón de usuarios y la mitad del historial de 110 mil otros usuarios, lograr predecir correctamente la segunda mitad de canciones escuchadas para esos 110 mil usuarios.
Para este proyecto se utilizaron los siguiente datasets (Click para descargarlos):
Train_triplets.txt corresponde a alrededor de 43 millones de filas, donde cada fila es una tripleta UserID - SongID - PlayCount. Como veremos luego, este dataset se puede representar como una matriz esparsa de densidad ~0.00011.
MisMatches.txt es un archivo que contiene dos columnas por cada fila, donde cada fila corresponde a un SongID que no se tiene certeza si esta bien asociado a su nombre y artista. Esto ocurrió cuando se construyo el dataset y por comodidad este archivo se utilizó para borrar todos los SongID's presentes en este de el dataset Train_triplets.
Test_triplets.txt Tiene la misma estructura que Train_triplets.txt pero sólo contiene tripletas para 10 mil usuarios distintos. Estas tripletas son la primera mitad del historial musical de los usuarios para los cuales se quiere generar una recomendación. I.e, luego de entrenar el modelo se prueba este con los userID's en este dataset.
Validate_triplets.txt corresponde a la segunda mitad del historial de los 10 mil usuarios previamente mencionados. Se utiliza para evaluar las recomendaciones generadas.
Los datasets proporcionados no necesitaron de una limpieza muy extensa. Lo unico que debió hacerse sobre estos datos fue eliminar del dataset Train_triplets.txt todos los SongID que estuvieran presentes en MisMatches.txt. Si bien al final se utilizaron los SongID's solamente como identificadores del dataset principal esta limpieza sirvio para tener un dataset un poco más liviano. En Python, todo este proceso se puede hacer con las siguientes instrucciones:
import pandas as pd
#Cargamos los datasets
train_triplets =
pd.read_csv("train_triplets.txt", sep="\t", names=["UserID", "SongID", "PlayCount"])
misMatches =
pd.read_csv('mismatches.txt', sep=" ", header= None, names= ['SongID', 'TrackID'])
test_triplets =
pd.read_csv("year1_valid_triplets_visible.txt", sep="\t", names=["UserID", "SongID", "PlayCount"])
validate_triplets =
pd.read_csv("year1_valid_triplets_hidden.txt", sep="\t", names=["UserID", "SongID", "PlayCount"])
#Limpiamos
train_triplets = train_triplets[~train_triplets.SongID.isin(misMatches.SongID)]
test_triplets = test_triplets[~test_triplets.SongID.isin(misMatches.SongID)]
validate_triplets = validate_triplets[~validate_triplets.SongID.isin(misMatches.SongID)]
La exploración de datos fue hecha más que nada para ver qué medidas podían tomarse en el caso de que el tamaño del dataset se tornara inmanejable. Lo que se quiso averiguar en la exploración fue lo siguiente:
Para ambos sistemas de recomendaciones propuestos se utilizo una matriz de preferencias. Esta corresponde a una matriz donde cada fila es un usuario y cada columna una canción. De esta manera cada fila indica qué canciones escuchó (y cuántas veces) cada usuario. Para esta primera parte se utilizó la matriz exactamente como se describió previamente, mientras que para la segunda parte se utilizó la traspuesta.
Cabe destacar que con la cantidad de información que se manejó para el proyecto era imposible representar como un arreglo esta matriz en memoria, o como un DataFrame de Pandas. Si bien era posible cargarlos, cualquier operación levemente elaborada sobre ellos era inaguantable. En este punto Mauricio Quezada nos orientó hacia las representaciones de scipy de matrices sparsas, las cuales resultan ser bastante más manejables que las mencionadas anteriormente.
El siguiente extracto del código se encarga de generar estas representaciones:
from scipy import sparse
#Se genera un indice para UserID y SongID distinto.
triplets['UserID'] = triplets['UserID'].astype("category")
triplets['SongID'] = triplets['SongID'].astype("category")
#Representación en matriz esparsa. Donde el segundo argumento indica las filas y el tercero
#las columnas.
plays = sparse.coo_matrix((triplets['PlayCount'].astype(float),
(triplets['UserID'].cat.codes,
triplets['SongID'].cat.codes)))
En este caso el algoritmo es bastante simple y es el siguiente:
Filtrado-Colaborativo(userID := u):
1. Vecinos := Set de K vecinos mas similares a u
2. CancionesVecinos(V) := Set de canciones que los vecinos de u escuchan y u no.
3. Para cada cancion en CancionesVecinos(V):
score(cancion) := Promedio Ponderado de PlayCount de cancion con similitud de u y v.
4. Retorna M canciones con mejor score
Si bien el algoritmo es simple la implementación propuesta para esta tarea no fue la mejor. Esto pues el paso 3 del algoritmo resultó ser muy costoso de calcular. Para solucionar esto se decidió simplemente recomendar el set de canciones que escuchan los K vecinos más similares. Por otro lado, gran parte del éxito o fracaso de estos métodos radica en la definición o medida de similitud que se defina. En este caso se nos ocurrió pensar en el conjunto de canciones como un set de palabras y el conjunto de usuarios como un set de documentos. De esta manera pudimos aplicar TF-IDF sobre cada usuario. Esto nos permitió capturar dos nociones importantes para nuestro recomendador:
from sklearn.feature_extraction.text import TfidfTransformer
#Aplicamos TF-IDF sobre la matriz esparsa.
tfidf = TfidfTransformer()
wplays = tfidf.fit_transform(plays)
Despues de esto para computar la similitud entre dos usuarios simplemente se encontraba el angulo entre ambos vectores. Luego, nuestra similitud se tornaba en una distancia. A menor distancia mayor similitud.
Las siguientes funciones son las necesarias para hacer estos calculos:
@Verbibliografia
def unit_vector(vector):
return vector / np.linalg.norm(vector)
@Verbibliografia
def angle_between(v1, v2):
v1_u = unit_vector(v1)
v2_u = unit_vector(v2)
return np.arccos(np.clip(np.dot(v1_u, v2_u), -1.0, 1.0))
def row_no(userID):
return triplets['UserID'].cat.categories.get_loc(userID)
def similarityMatrix(userID, weightedMatrix):
rowNo = row_no(userID)
userRow = weightedMatrix.getrow(rowNo).toarray().flatten()
neighbours = set()
for songID in triplets.loc[triplets['UserID'] == userID].SongID.tolist():
neighbours = neighbours.union(set(triplets.loc[triplets['SongID'] == songID].UserID.tolist()))
neighbours = islice(neighbours,2000)
similarity = np.array([[otherUserID, angle_between(userRow, weightedMatrix.getrow(row_no(otherUserID)).toarray().flatten())]
for otherUserID in neighbours])
return similarity
Cabe notar que en la ultima función, encargada de construir la matriz de similitud, el primer 'for' se encarga de seleccionar todos los UserID's que han escuchado al menos una cancion dentro de las escuchadas por el sujeto a generar al recomendación. De no limitarse esto genera una lista muy extensa de vecinos tentativos, por lo que para efectos de computabilidad se corta esta lista en los primeros 2000 vecinos tentativos encontrados.
Ahora bien, resta decidir el tamaño del vecindario que se utilizará para generar los K vecinos más cercanos. Esto se hizo por prueba y error y se reportaron los siguientes resultados:
K | 100 | 200 | 300 | 400 |
---|---|---|---|---|
Mean Average Precision | 0.53 | 0.66 | 0.67 | 0.73 |
Se puede ver que como es de esperar, a medida que incrementamos el tamaño del vecindario la precisión del recomendador incrementa. Este sistema derrota un poco su propósito. Esto pues la idea de un sistema recomendador es reducir la cantidad de opciones que tiene un usuario, presentadole un subconjunto reducido el cual tiene altas posibilidades de contener ítems de su interés. En este caso, estamos reduciendo un item set de alrededor de 380 mil canciones a un set recomendado de alrededor de 1500 canciones. Nos gustaría quizás, que este numero sea menor.
Por último, cabe destacar que esta prueba se realizó tomando el Mean Average Precision para 10 usuarios distintos. Esta tabla tomo alrededor de 2 horas en ser computada.
Una de las complicaciones del primer acercamiento era la eficiencia de su implementación. Esta provocaba que partes del algoritmo se hicieran incalculables en un tiempo razonable para este curso. Es por esto que se decidió buscar otra solución mas escalable y que pudiera generar resultados en un tiempo razonable. En este articulo el autor propone, entre otras cosas, aplicar técnicas de álgebra lineal para reducir la dimensionalidad de los datos y luego trabajar sobre este nuevo espacio.
La idea es básicamente, generar una aproximación de dimensión inferior de la matriz de preferencias y luego en este espacio, computar similitudes. De esta manera, si en el primer acercamiento para calcular la similitud operabamos dos vectores de largo ~380000, ahora operaremos vectores de largo K, donde K es el parametro entregado al SVD.
Usar SVD de esta manera lleva el nombre de "Latent semantic analysis". Esto pues al hacer la reducción de dimensionalidad se revelan los llamados "factores de latencia" de los datos de entrada, los cuales pueden revelar semántico sobre estos. Por ejemplo, si antes teníamos que cada vector de SongID tenía un largo igual a la cantidad de usuarios en los datos, luego de hacer SVD, cada componente del nuevo vector representante de esta cancion puede significar algo, como por ejemplo "Grado de rock" de la canción.
La siguiente línea de código es la encargada de efectuar esta reducción de dimensionalidad, y para este acercamiento, es el punto que más tiempo toma en completarse:
artist_factors, _, user_factors = scipy.sparse.linalg.svds(wplays.transpose(), K)
Lo bueno, es que si bien esto toma un tiempo considerable, para generar las recomendaciones para cualquier usuario, sólo se necesita ejecutar esta instrucción una vez. Cabe notar que para este acercamiento se tomó una estrategia distinta. En el primer acercamiento, dado un usuario se buscaba usuarios similares a él y se recomendaba segun lo que sus vecinos más similares escuchaban. En este segundo acercamiento se propone una solucion distinta:
SVD(userID := u):
1. Canciones := Set de canciones que escucho u.
2. Recomendadas(u) := Set de canciones a recomendar a u.
3. Para cada cancion en Canciones:
Recomendadas += masSimilares(cancion)
4. Retorna Recomendadas
El hecho de contar con los factores de cada canción nos permite buscar canciones similares a las escuchadas por el usuario y recomendar ese conjunto. El artículo mencionado anteriormente provee con una clase para obtener las N canciones mas similares. Se tienen entonces dos parámetros para variar en este caso: el parámetro K de SVD y el N. Los mejores valores para estos se buscaron por prueba y error y se obtuvieron los siguientes resultados:
K/N | 5 | 10 | 20 | 40 |
---|---|---|---|---|
10 | 0.08 | 0.10 | 0.14 | 0.20 |
50 | 0.15 | 0.24 | 0.26 | 0.30 |
70 | 0.16 | 0.20 | 0.22 | 0.29 |
Se tiene entonces que el mejor resultado se obtuvo con K=50 y N=40, aún asi se puede ver que los resultados mejoran progresivamente a medida que aumenta el valor de N, lo que como se apuntó en el primer acercamiento, tiene sentido.
Cabe notar que por un tema de tiempo disponible, esta tabla fue generada obteniendo el Mean Average Precision para 10 usuarios distintos dentro del testset. Esta prueba tomo alrededor de 30 minutos en generarse.
Para evaluar los recomendadores se utilizó el Mean Average Precision, es decir, se midió en promedio qué cantidad de canciones fueron las realmente escuchadas por sobre las recomendadas. La siguiente función entrega este resultado para los distintos recomendadores:
def evaluateRecommendation(recList, userID):
songs = recList
userSongs = set(validate_triplets.loc[validate_triplets['UserID'] == userID].SongID.tolist())
return len(songs.intersection(userSongs))/len(userSongs)
El construir un recomendador tiene muchas aristas complicadas, entre las que se pudieron encontrar en este proyecto fueron:
Frederickson, B. (2016, 3 de Mayo). Matrix Factorization. Consultada el 10/12/2016, desde http://www.benfrederickson.com/matrix-factorization/
Frederickson, B. (2015, 27 de Abril). Distance Metrics for Fun and Profit. Consultada el 10/12/2016, desde http://www.benfrederickson.com/distance-metrics/