Filtrado colaborativo

Los Sistemas Recomendadores son una colección de algoritmos utilizados para sugerir temas a los usuarios, basados en información tomada desde el punto de vista del usuario. Estos sistemas ubicuos pueden ser comúnmente vistos en tiendas online, bases de datos de películas y buscadores de empleos. Vamos a ver los sistemas de recomendación basados en Contenido e implementaremos una versión utilizando Python y la librería Pandas.

Primero, importemos las librerías necesarias:

#librería de manipulación de dataframes
import pandas as pd
#Funciones matemáticas, necesitaremos sólo importar la función sqrt
from math import sqrt
import numpy as np
import matplotlib.pyplot as plt
%matplotlib inline

Adquisición de Datos

Para adquirir y extraer los datos, simplemente ejecuta los siguientes scripts Bash:

!wget -O moviedataset.zip https://s3-api.us-geo.objectstorage.softlayer.net/cf-courses-data/CognitiveClass/ML0101ENv3/labs/moviedataset.zip
print('unziping ...')
!unzip -o -j moviedataset.zip 
--2020-03-06 17:12:19--  https://s3-api.us-geo.objectstorage.softlayer.net/cf-courses-data/CognitiveClass/ML0101ENv3/labs/moviedataset.zip
Resolving s3-api.us-geo.objectstorage.softlayer.net (s3-api.us-geo.objectstorage.softlayer.net)... 67.228.254.196
Connecting to s3-api.us-geo.objectstorage.softlayer.net (s3-api.us-geo.objectstorage.softlayer.net)|67.228.254.196|:443... connected.
HTTP request sent, awaiting response... 200 OK
Length: 160301210 (153M) [application/zip]
Saving to: ‘moviedataset.zip’

moviedataset.zip    100%[===================>] 152.88M  19.2MB/s    in 8.4s    

2020-03-06 17:12:27 (18.1 MB/s) - ‘moviedataset.zip’ saved [160301210/160301210]

unziping ...
Archive:  moviedataset.zip
  inflating: links.csv               
  inflating: movies.csv              
  inflating: ratings.csv

Ahora carguemos cada archivo dentro de su dataframe:

#Guardando la información de la película dentro de un dataframe de panda
movies_df = pd.read_csv('movies.csv')
#Guardando información del usuario dentro de un dataframe de panda
ratings_df = pd.read_csv('ratings.csv')

Miremos cada uno de ellos a ver cómo están organizados:

#Head es una función que obtiene las primeras N filas de un dataframe. El valor por omisión de N es 5.
movies_df.head()
movieId title genres
0 1 Toy Story (1995) Adventure|Animation|Children|Comedy|Fantasy
1 2 Jumanji (1995) Adventure|Children|Fantasy
2 3 Grumpier Old Men (1995) Comedy|Romance
3 4 Waiting to Exhale (1995) Comedy|Drama|Romance
4 5 Father of the Bride Part II (1995) Comedy

Cada película tiene un único ID, un título con su año de estreno (El cual puede contener caracteres unicode) y muchos géneros diferentes en el mismo campo. Saquemos el año de la columna del título y ubiquemoslo en su propia columna con la función extract que viene con Pandas.

Saquemos el año de la columna title utilizando la función de pandas replace y guardémoslo en la nueva columna year.

#Utilizar expresiones regulares para encontrar un año guardado entre paréntesis
#Especificamos los paréntesis de forma tal de que no haya problemas con las películas que tiene el año en sus títulos
movies_df['year'] = movies_df.title.str.extract('(\d\d\d\d)',expand=False)
#Sacando los paréntesis
movies_df['year'] = movies_df.year.str.extract('(\d\d\d\d)',expand=False)
#Sacando los años de la columna 'title'
movies_df['title'] = movies_df.title.str.replace('(\d\d\d\d)', '')
#Aplicando la función strip para sacar los espacios finales que pudiera haber
movies_df['title'] = movies_df['title'].apply(lambda x: x.strip())

Veamos el resultado

movies_df.head()
movieId title genres year
0 1 Toy Story Adventure|Animation|Children|Comedy|Fantasy 1995
1 2 Jumanji Adventure|Children|Fantasy 1995
2 3 Grumpier Old Men Comedy|Romance 1995
3 4 Waiting to Exhale Comedy|Drama|Romance 1995
4 5 Father of the Bride Part II Comedy 1995

Luego, saquemos la columna de los géneros ya que no los necesitaremos para este sistema recomendador.

#Eliminando la columna géneros
movies_df = movies_df.drop('genres', 1)

Aquí está el dataframe final:

movies_df.head()
movieId title year
0 1 Toy Story 1995
1 2 Jumanji 1995
2 3 Grumpier Old Men 1995
3 4 Waiting to Exhale 1995
4 5 Father of the Bride Part II 1995

Ahora, veamos el dataframe de los ratings.

ratings_df.head()
userId movieId rating timestamp
0 1 169 2.5 1204927694
1 1 2471 3.0 1204927438
2 1 48516 5.0 1204927435
3 2 2571 3.5 1436165433
4 2 109487 4.0 1436165496

Cada fila en el dataframe ratings tiene un id de usuario asociado con al menos una película, un rating y una marca de tiempo que muestra cuando se revisó. No se necesitará la columna timestamp, por lo que se eliminará para ahorrar memoria.

#Drop elimina una fila en particular o columna dentro de un dataframe
ratings_df = ratings_df.drop('timestamp', 1)

Asi es cómo el Dataframe definitivo se ve:

ratings_df.head()
userId movieId rating
0 1 169 2.5
1 1 2471 3.0
2 1 48516 5.0
3 2 2571 3.5
4 2 109487 4.0

Filtrado Colaborativo

Ahora, comenzaremos el trabajo con los sistemas recomendadores.

La primer técnica que vas a ver se llama Filtrado Colaborativo, también conocido como Filtrado de Usuario a Usuario. Como lo indica su nombre alternativo, esta técnica utiliza otros usuarios para recomendar items al ingreso de datos. Se intenta encontrar usuarios que tengan preferencias y opiniones parecidas para entonces recomendar items que se hayan parecido al ingreso anterior. Existen varios métodos para encontrar usuarios parecidos (Incluso los que utilizan Machine Learning), y el método que vamos a utilizar estará basado en la Función de Correlación de Pearson.
El proceso para crear un sistema de recomendación Basado en el Usuario es el siguiente:

  1. Elegir un usuario con las películas que el usuario ha mirado
  2. Basado en su indice de selección de películas, encuentra a los primeros X vecinos
  3. Obtener el registro de la película que miró el usuario para cada vecino.
  4. Calcular un puntaje de similitud utilizando alguna fórmula
  5. Recomendar los ítems con los puntajes más altos

Comencemos creando un usuario a quien recomendar películas:
Nota: Para agregar más películas, aumenta la catidad de elementos en userInput. Agrega tantos como desees! Solo asegúrate de escribir en letras mayúsculas y si una película comienza con un «The», como «The Matrix» entonces escríbelo así: ‘Matrix, The’.

userInput = [
            {'title':'Breakfast Club, The', 'rating':5},
            {'title':'Toy Story', 'rating':3.5},
            {'title':'Jumanji', 'rating':2},
            {'title':"Pulp Fiction", 'rating':5},
            {'title':'Akira', 'rating':4.5}
         ] 
inputMovies = pd.DataFrame(userInput)
inputMovies
title rating
0 Breakfast Club, The 5.0
1 Toy Story 3.5
2 Jumanji 2.0
3 Pulp Fiction 5.0
4 Akira 4.5

Agregar movieId al ingreso del usuario: Con las datos ingresados completos, extraigamos los ID de las películas del dataframe de películas y agreguémosla.
Esto se logra primero sacando las filas que tienen que tienen títulos de películas y luego une este subconjunto con el dataframe de entrada. También sacamos columnas que no se necesitan para ahorrar espacio de memoria.

#Filtrar las películas por título
inputId = movies_df[movies_df['title'].isin(inputMovies['title'].tolist())]
#Luego juntarlas para obtener el movieId. Implícitamente, lo está uniendo por título.
inputMovies = pd.merge(inputId, inputMovies)
#Eliminando información que no utilizaremos del dataframe de entrada
inputMovies = inputMovies.drop('year', 1)
#Dataframe de entrada final
#Si una película que se agregó no se encuentra, entonces podría no estar en el dataframe 
#original o podría estar escrito de otra forma, por favor revisar mayúscula o minúscula.
inputMovies
movieId title rating
0 1 Toy Story 3.5
1 2 Jumanji 2.0
2 296 Pulp Fiction 5.0
3 1274 Akira 4.5
4 1968 Breakfast Club, The 5.0

Usuarios que han visto las mismas películas: Ahora, que el ID de la película está como entrada, podemos obtener el subconjunto de usuarios que han visto o revisado las películas en nuestra entrada.

#Filtrando los usuarios que han visto las películas y guardándolas
userSubset = ratings_df[ratings_df['movieId'].isin(inputMovies['movieId'].tolist())]
userSubset.head()
userId movieId rating
19 4 296 4.0
441 12 1968 3.0
479 13 2 2.0
531 13 1274 5.0
681 14 296 2.0

Ahora agrupamos las filas por el ID del usuario.

#Groupby crea varios dataframes donde todos tienen el mismo valor para la columna especificada como parámetro
userSubsetGroup = userSubset.groupby(['userId'])

Miremos a uno de los usuarios, por ejemlo, el userID=1130

userSubsetGroup.get_group(1130)
userId movieId rating
104167 1130 1 0.5
104168 1130 2 4.0
104214 1130 296 4.0
104363 1130 1274 4.5
104443 1130 1968 4.5

Ahora hagamos ordenamiento dentro de cada grupo de forma tal que los usuarios que compartan la mayor cantidad de películas tengan prioridad. Esto brinda una recomendación mejorada ya que no será necesario pasar por todos los usuarios.

#Ordenamiento de forma tal de que los usuarios con más películas en común tengan prioridad
userSubsetGroup = sorted(userSubsetGroup,  key=lambda x: len(x[1]), reverse=True)

Ahora, miremos al primer usuario

userSubsetGroup[0:3]
[(75,       userId  movieId  rating
  7507      75        1     5.0
  7508      75        2     3.5
  7540      75      296     5.0
  7633      75     1274     4.5
  7673      75     1968     5.0), (106,       userId  movieId  rating
  9083     106        1     2.5
  9084     106        2     3.0
  9115     106      296     3.5
  9198     106     1274     3.0
  9238     106     1968     3.5), (686,        userId  movieId  rating
  61336     686        1     4.0
  61337     686        2     3.0
  61377     686      296     4.0
  61478     686     1274     4.0
  61569     686     1968     5.0)]

Similitud entre usuarios y usuarios ingresantes

Luego, compararemos a todos los usuarios (casi todos) con nuestro usuario indicado y encontraremos el que más se parece.
Encontraremos cómo cada usuario similar se relacionan entre si a través del Coeficiente de Correlación de Pearson. Se utiliza para medir la fuerza de una asociación lineal entre dos variables. La fórmula para encontrar este coeficiente entre los conjuntos X e Y con los valores de N se puede ver en la fórmula:
r=\frac{\sum_{i=1}^{n}(x_i-\bar{x})(y_i-\bar{y})}{\sqrt{\sum_{i=1}^{n}(x_i-\bar{x})^2}\sqrt{\sum_{i=1}^{n}(y_i-\bar{y})^2}}

¿Por qué la Correlación Pearson?
La correlación Pearson no varía con la escala, ejemplo: si se multiplican todos los elementos por una constante distinta a cero o si se agrega cualquier constante a todos los elementos. Por ejemplo, si tienes dos vectores X e Y, entonces, pearson(X, Y)=pearson(X, 2 \cdot Y + 3). Esta es una propiedad muy importante en los sistemas recomendadores por que dos usuarios podrían putnuar dos series de elementos de manera completamente diferente, pero serían usuarios parecidos (ejemplo: con ideas similares) con puntuaciones parecidas en escalas variadas .

Los valores brindados por la fórumula puede variar de r=-1 a r=1, donde 1 se correlaciona directamente entre las dos entidades (esto sería una correlación positiva perfecta) y -1 forma una correlación negativa perfecta. En nuestro caso, un 1 se refiere a que dos usuarios tiene gustos parecidos, mientras que -1 es lo opuesto.
Elegiremos un subconjunto de usuarios para hacer las iteraciones. Este limite existe porque no queremos desperdiciar mucho tiempo pasando por cada usuario.

userSubsetGroup = userSubsetGroup[0:100]

Ahora, calculemos la Correlación Pearson entre la entrada del usuario el grupo, para almacenarlo en el diccionario, donde la clave es el Id del usuario y el valor es el coeficiente

#Guardar la Correlación Pearson en un diccionario, donde la clave es el Id del usuario y el valor es el coeficiente
pearsonCorrelationDict = {}

#Para cada grupo de usuarios en nuestro subconjunto 
for name, group in userSubsetGroup:
    #Comencemos ordenando el usuario actual y el ingresado de forma tal que los valores no se mezclen luego
    group = group.sort_values(by='movieId')
    inputMovies = inputMovies.sort_values(by='movieId')
    #Obtener el N para la fórmula
    nRatings = len(group)
    #Obtener los puntajes de revisión para las películas en común
    temp_df = inputMovies[inputMovies['movieId'].isin(group['movieId'].tolist())]
    #Guardarlas en una variable temporal con formato de lista para facilitar cálculos futuros
    tempRatingList = temp_df['rating'].tolist()
    #Pongamos también las revisiones de grupos de usuarios en una lista
    tempGroupList = group['rating'].tolist()
    #Calculemos la Correlación Pearson entre dos usuarios, x e y
    Sxx = sum([i**2 for i in tempRatingList]) - pow(sum(tempRatingList),2)/float(nRatings)
    Syy = sum([i**2 for i in tempGroupList]) - pow(sum(tempGroupList),2)/float(nRatings)
    Sxy = sum( i*j for i, j in zip(tempRatingList, tempGroupList)) - sum(tempRatingList)*sum(tempGroupList)/float(nRatings)

    #Si el denominador es diferente a cero, entonces dividir, sino, la correlación es 0.
    if Sxx != 0 and Syy != 0:
        pearsonCorrelationDict[name] = Sxy/sqrt(Sxx*Syy)
    else:
        pearsonCorrelationDict[name] = 0
pearsonCorrelationDict.items()
dict_items([(75, 0.8272781516947562), (106, 0.5860090386731182), (686, 0.8320502943378437), (815, 0.5765566601970551), (1040, 0.9434563530497265), (1130, 0.2891574659831201), (1502, 0.8770580193070299), (1599, 0.4385290096535153), (1625, 0.716114874039432), (1950, 0.179028718509858), (2065, 0.4385290096535153), (2128, 0.5860090386731196), (2432, 0.1386750490563073), (2791, 0.8770580193070299), (2839, 0.8204126541423674), (2948, -0.11720180773462392), (3025, 0.45124262819713973), (3040, 0.89514359254929), (3186, 0.6784622064861935), (3271, 0.26989594817970664), (3429, 0.0), (3734, -0.15041420939904673), (4099, 0.05860090386731196), (4208, 0.29417420270727607), (4282, -0.4385290096535115), (4292, 0.6564386345361464), (4415, -0.11183835382312353), (4586, -0.9024852563942795), (4725, -0.08006407690254357), (4818, 0.4885967564883424), (5104, 0.7674257668936507), (5165, -0.4385290096535153), (5547, 0.17200522903844556), (6082, -0.04728779924109591), (6207, 0.9615384615384616), (6366, 0.6577935144802716), (6482, 0.0), (6530, -0.3516054232038709), (7235, 0.6981407669689391), (7403, 0.11720180773462363), (7641, 0.7161148740394331), (7996, 0.626600514784504), (8008, -0.22562131409856986), (8086, 0.6933752452815365), (8245, 0.0), (8572, 0.8600261451922278), (8675, 0.5370861555295773), (9101, -0.08600261451922278), (9358, 0.692178738358485), (9663, 0.193972725041952), (9994, 0.5030272728659587), (10248, -0.24806946917841693), (10315, 0.537086155529574), (10368, 0.4688072309384945), (10607, 0.41602514716892186), (10707, 0.9615384615384616), (10863, 0.6020183016345595), (11314, 0.8204126541423654), (11399, 0.517260600111872), (11769, 0.9376144618769914), (11827, 0.4902903378454601), (12069, 0.0), (12120, 0.9292940047327363), (12211, 0.8600261451922278), (12325, 0.9616783115081544), (12916, 0.5860090386731196), (12921, 0.6611073566849309), (13053, 0.9607689228305227), (13142, 0.6016568375961863), (13260, 0.7844645405527362), (13366, 0.8951435925492911), (13768, 0.8770580193070289), (13888, 0.2508726030021272), (13923, 0.3516054232038718), (13934, 0.17200522903844556), (14529, 0.7417901772340937), (14551, 0.537086155529574), (14588, 0.21926450482675766), (14984, 0.716114874039432), (15137, 0.5860090386731196), (15157, 0.9035841064985974), (15466, 0.7205766921228921), (15670, 0.516015687115336), (15834, 0.22562131409856986), (16292, 0.6577935144802716), (16456, 0.7161148740394331), (16506, 0.5481612620668942), (17246, 0.48038446141526137), (17438, 0.7093169886164387), (17501, 0.8168748513121271), (17502, 0.8272781516947562), (17666, 0.7689238340176859), (17735, 0.7042381820123422), (17742, 0.3922322702763681), (17757, 0.64657575013984), (17854, 0.537086155529574), (17897, 0.8770580193070289), (17944, 0.2713848825944774), (18301, 0.29838119751643016), (18509, 0.1322214713369862)])
pearsonDF = pd.DataFrame.from_dict(pearsonCorrelationDict, orient='index')
pearsonDF.columns = ['similarityIndex']
pearsonDF['userId'] = pearsonDF.index
pearsonDF.index = range(len(pearsonDF))
pearsonDF.head()
similarityIndex userId
0 0.827278 75
1 0.586009 106
2 0.832050 686
3 0.576557 815
4 0.943456 1040

Ahora obtengamos los 50 primeros usuarios más parecidos a los que se ingresaron.

topUsers=pearsonDF.sort_values(by='similarityIndex', ascending=False)[0:50]
topUsers.head()
similarityIndex userId
64 0.961678 12325
34 0.961538 6207
55 0.961538 10707
67 0.960769 13053
4 0.943456 1040

Recomendemos películas al usuario de entrada puntuando a los usuarios elegidos para todas las películas

Haremos esto tomando el peso promedio de los ratings de las películas utilizando la Correlación Pearson. Pero para hacer esto, primero necesitamos que los usuarios vean las películas en nuestro pearsonDF a partir del dataframe de puntajes y luego guardar su correlación en una nueva columna llamada _similarityIndex». Estos se logra juntando estas dos tablas de debajo.

topUsersRating=topUsers.merge(ratings_df, left_on='userId', right_on='userId', how='inner')
topUsersRating.head()
similarityIndex userId movieId rating
0 0.961678 12325 1 3.5
1 0.961678 12325 2 1.5
2 0.961678 12325 3 3.0
3 0.961678 12325 5 0.5
4 0.961678 12325 6 2.5

Ahora todo lo que se necesita hacer es multiplicar el puntaje de la película por su peso (El índice de similitud), luego se suman los nuevos puntajes y dividen por la suma de los pesos.
Esto se logra sencillamente multiplicando dos columnas, luego agrupando el dataframe por la columna movieId y luego dividiendo dos columnas:
Aqui se muestra la idea de todos los usuarios similares respecto de las películas candidatas para el usuario ingresado:

#Se multiplica la similitud de los puntajes de los usuarios
topUsersRating['weightedRating'] = topUsersRating['similarityIndex']*topUsersRating['rating']
topUsersRating.head()
similarityIndex userId movieId rating weightedRating
0 0.961678 12325 1 3.5 3.365874
1 0.961678 12325 2 1.5 1.442517
2 0.961678 12325 3 3.0 2.885035
3 0.961678 12325 5 0.5 0.480839
4 0.961678 12325 6 2.5 2.404196
#Se aplica una suma a los topUsers luego de agruparlos por userId
tempTopUsersRating = topUsersRating.groupby('movieId').sum()[['similarityIndex','weightedRating']]
tempTopUsersRating.columns = ['sum_similarityIndex','sum_weightedRating']
tempTopUsersRating.head()
sum_similarityIndex sum_weightedRating
movieId
1 38.376281 140.800834
2 38.376281 96.656745
3 10.253981 27.254477
4 0.929294 2.787882
5 11.723262 27.151751
#Se crea un dataframe vacío
recommendation_df = pd.DataFrame()
#Ahora se toma el promedio ponderado
recommendation_df['weighted average recommendation score'] = tempTopUsersRating['sum_weightedRating']/tempTopUsersRating['sum_similarityIndex']
recommendation_df['movieId'] = tempTopUsersRating.index
recommendation_df.head()
weighted average recommendation score movieId
movieId
1 3.668955 1
2 2.518658 2
3 2.657941 3
4 3.000000 4
5 2.316058 5

Luego, ordenémoslo y veamos las primeras 20 películas que el algoritmo recomendó!

recommendation_df = recommendation_df.sort_values(by='weighted average recommendation score', ascending=False)
recommendation_df.head(10)
weighted average recommendation score movieId
movieId
5073 5.0 5073
3329 5.0 3329
2284 5.0 2284
26801 5.0 26801
6776 5.0 6776
6672 5.0 6672
3759 5.0 3759
3769 5.0 3769
3775 5.0 3775
90531 5.0 90531
movies_df.loc[movies_df['movieId'].isin(recommendation_df.head(10)['movieId'].tolist())]
movieId title year
2200 2284 Bandit Queen 1994
3243 3329 Year My Voice Broke, The 1987
3669 3759 Fun and Fancy Free 1947
3679 3769 Thunderbolt and Lightfoot 1974
3685 3775 Make Mine Music 1946
4978 5073 Son’s Room, The (Stanza del figlio, La) 2001
6563 6672 War Photographer 2001
6667 6776 Lagaan: Once Upon a Time in India 2001
9064 26801 Dragon Inn (Sun lung moon hak chan) 1992
18106 90531 Shame 2011

Ventajas y Desventajas del Filtro Colaborativo

  • Ventajas
    • Tiene en cuenta el puntaje de otros usuarios
    • No necesita estudiar o extraer la información del elemento recomendado
    • Se adapta al interés del usuario mientras cambia
  • Desventajas
    • La función de aproximación puede ser lenta
    • Podriá surgir una cantidad baja de usuarios para aproximar
    • Temas de privacidad cuando se intenta aprender de las preferencias del usuario