

En este pequeño tutorial, vamos a hablar sobre Sistemas de Recomendación.
Es posible que no sepas que son, sin embargo interactúas constantemente con ellos en Internet.
Cada vez que Amazon te sugiere productos relacionados…
O cuando Netflix o Youtube te recomiendan contenido que te puede interesar…
La finalidad de un sistema de recommendación es predecir la valoración que un usuario va a hacer de un ítem que todavía no ha evaluado.
Esta valoración se genera al analizar una de dos cosas, o las características de cada item, o las valoraciones de cada usuario a cada item, y se usa para recomendar contenido personalizado a los usuarios.
Hay dos tipos principales de sistemas de recomendación:
- Filtrado de Contenido. Las recomendaciones están basadas en las características de cada item.
- Filtrado Colaborativo. Las recomendaciones están basadas en las valoraciones existentes de los usuarios.
En este tutorial vamos a trabajar con el dataset de MovieLens. Este dataset contiene valoraciones de películas sacadas de la página web MovieLens (https://movielens.org/).
El dataset consiste en múltiples archivos, pero los que vamos a usar en este artículo son movies.dat y ratings.dat.
Primero nos descargamos el dataset:
1 2 3 |
wget http://files.grouplens.org/datasets/movielens/ml-1m.zip unzip ml-1m.zip cd ml-1m/ |
Filtrado de Contenido
Aquí están las primeras líneas del archivo movies.dat
. El archivo tiene el formato:
movie_id::movie_title::movie genre(s)
1 2 3 4 5 6 7 8 9 10 11 |
head movies.dat 1::Toy Story (1995)::Animation|Children's|Comedy 2::Jumanji (1995)::Adventure|Children's|Fantasy 3::Grumpier Old Men (1995)::Comedy|Romance 4::Waiting to Exhale (1995)::Comedy|Drama 5::Father of the Bride Part II (1995)::Comedy 6::Heat (1995)::Action|Crime|Thriller 7::Sabrina (1995)::Comedy|Romance 8::Tom and Huck (1995)::Adventure|Children's 9::Sudden Death (1995)::Action 10::GoldenEye (1995)::Action|Adventure|Thriller |
Los géneros de cada película están separados por un pipe |.
Cargamos el archivo movies.dat
:
1 2 3 4 |
import pandas as pd import numpy as np movies_df = pd.read_table('movies.dat', header=None, sep='::', names=['movie_id', 'movie_title', 'movie_genre']) movies_df.head() |
Out[]:
movie_id | movie_title | movie_genre | |
---|---|---|---|
0 | 1 | Toy Story (1995) | Animation|Children’s|Comedy |
1 | 2 | Jumanji (1995) | Adventure|Children’s|Fantasy |
2 | 3 | Grumpier Old Men (1995) | Comedy|Romance |
3 | 4 | Waiting to Exhale (1995) | Comedy|Drama |
Para poder usar la columna movie_genre
, tenemos que convertirla en un grupo de campos llamados dummy_variables
.
Esta función convierte una variable categórica (por ejemplo, el genéro de la película puede ser Animation, Comedy, Romance…), en múltiples columnas (una columna para Animation, una columna para Comedy, etc).
Para cada película, éstas columnas dummy
tendrán un valor de 0 excepto para aquellos géneros que tenga la película.
1 2 3 |
# we convert the movie genres to a set of dummy variables movies_df = pd.concat([movies_df, movies_df.movie_genre.str.get_dummies(sep='|')], axis=1) movies_df.head() |
Out[]:
movie_id | movie_title | movie_genre | Action | Adventure | Animation | Children’s | Comedy | Crime | Documentary | … | |
---|---|---|---|---|---|---|---|---|---|---|---|
0 | 1 | Toy Story (1995) | Animation|Children’s|Comedy | 0 | 0 | 1 | 1 | 1 | 0 | 0 | … |
1 | 2 | Jumanji (1995) | Adventure|Children’s|Fantasy | 0 | 1 | 0 | 1 | 0 | 0 | 0 | … |
2 | 3 | Grumpier Old Men (1995) | Comedy|Romance | 0 | 0 | 0 | 0 | 1 | 0 | 0 | … |
3 | 4 | Waiting to Exhale (1995) | Comedy|Drama | 0 | 0 | 0 | 0 | 1 | 0 | 0 | … |
4 | 5 | Father of the Bride Part II (1995) | Comedy | 0 | 0 | 0 | 0 | 1 | 0 | 0 | … |
Por ejemplo, la película con una id
de 1, Toy Story, pertenece a los géneros Animation, Children’s y Comedy, y por lo tanto las columnas Animation, Children’s y Comedy tienen un valor de 1 para Toy Story.
1 2 |
movie_categories = movies_df.columns[3:] movies_df.loc[0] |
Out[]:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
movie_id 1 movie_title Toy Story (1995) movie_genre Animation|Children's|Comedy Action 0 Adventure 0 Animation 1 Children's 1 Comedy 1 Crime 0 Documentary 0 Drama 0 Fantasy 0 Film-Noir 0 Horror 0 Musical 0 Mystery 0 Romance 0 Sci-Fi 0 Thriller 0 War 0 Western 0 Name: 0, dtype: object |
El filtrado de contenidos es una manera bastante simple de construir un sistema de recomendación. En este método, los items (en éste ejemplo las películas), se asocian a un grupo de características (en este caso los géneros cinematográficos).
Para recomendar items a un usuario, primero dicho usuario tiene que especificar sus preferencias en cuanto a las características.
En el ejemplo de Movielens, el usuario tiene que especificar qué generos le gustan y cuánto le gustan.
Por el momento tenemos todas las columnas categorizadas por géneros.
Vamos a crear un usuario de ejemplo, con unos gustos cinematográficos enfocados a películas de acción, aventura y ficción:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
from collections import OrderedDict user_preferences = OrderedDict(zip(movie_categories, [])) user_preferences['Action'] = 5 user_preferences['Adventure'] = 5 user_preferences['Animation'] = 1 user_preferences["Children's"] = 1 user_preferences["Comedy"] = 3 user_preferences['Crime'] = 2 user_preferences['Documentary'] = 1 user_preferences['Drama'] = 1 user_preferences['Fantasy'] = 5 user_preferences['Film-Noir'] = 1 user_preferences['Horror'] = 2 user_preferences['Musical'] = 1 user_preferences['Mystery'] = 3 user_preferences['Romance'] = 1 user_preferences['Sci-Fi'] = 5 user_preferences['War'] = 3 user_preferences['Thriller'] = 2 user_preferences['Western'] =1 |
Ahora que tenemos las preferencias del usuario, para calcular la puntuación que dicho usuario daría a cada película sólo tenemos que hacer el producto vectorial de las preferencias del usuario con las características de cada película.
1 2 3 4 5 |
#En producción usaríamos np.dot, en vez de escribir esta función, la pongo como ejemplo. def dot_product(vector_1, vector_2): return sum([ i*j for i,j in zip(vector_1, vector_2)]) def get_movie_score(movie_features, user_preferences): return dot_product(movie_features, user_preferences) |
Ahora podemos computar la puntuación de la película Toy Story, una película de animación infantil, para el usuario del ejemplo.
1 2 |
toy_story_features = movies_df.loc[0][movie_categories] toy_story_features |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
Action 0 Adventure 0 Animation 1 Children's 1 Comedy 1 Crime 0 Documentary 0 Drama 0 Fantasy 0 Film-Noir 0 Horror 0 Musical 0 Mystery 0 Romance 0 Sci-Fi 0 Thriller 0 War 0 Western 0 Name: 0, dtype: object |
1 2 |
toy_story_user_predicted_score = dot_product(toy_story_features, user_preferences.values()) toy_story_user_predicted_score |
Out[]:
1 |
5 |
Para este usuario, Toy Story tiene una puntuación de 5. Lo cual no significa mucho por sí mismo, sólo si comparamos dicha puntuación con la puntuación de las otras películas.
Por ejemplo,, calculamos la puntuación de Die Hard (La Jungla de Cristal), una película de acción.
1 |
movies_df[movies_df.movie_title.str.contains('Die Hard')] |
movie_id | movie_title | movie_genre | Action | Adventure | Animation | Children’s | Comedy | Crime | Documentary | … | |
---|---|---|---|---|---|---|---|---|---|---|---|
163 | 165 | Die Hard: With a Vengeance (1995) | Action|Thriller | 1 | 0 | 0 | 0 | 0 | 0 | 0 | … |
1023 | 1036 | Die Hard (1988) | Action|Thriller | 1 | 0 | 0 | 0 | 0 | 0 | 0 | … |
1349 | 1370 | Die Hard 2 (1990) | Action|Thriller | 1 | 0 | 0 | 0 | 0 | 0 | 0 | … |
1 2 3 |
die_hard_id = 1036 die_hard_features = movies_df[movies_df.movie_id==die_hard_id][movie_categories] die_hard_features.T |
Out[]:
1023 | |
---|---|
Action | 1 |
Adventure | 0 |
Animation | 0 |
Children’s | 0 |
Comedy | 0 |
Crime | 0 |
Documentary | 0 |
Drama | 0 |
Fantasy | 0 |
Film-Noir | 0 |
Horror | 0 |
Musical | 0 |
Mystery | 0 |
Romance | 0 |
Sci-Fi | 0 |
Thriller | 1 |
War | 0 |
Western | 0 |
- Nota, 1023 es el índice interno del dataframe, no el índice de la película Die Hard en Movielens*
1 2 |
die_hard_user_predicted_score = dot_product(die_hard_features.values[0], user_preferences.values()) die_hard_user_predicted_score |
1 |
8 |
Vemos que Die Hard tiene una puntuación de 8 y Toy Story de 5, asi que Die Hard sería recomendada al usuario antes que Toy Story. Lo cual tiene sentido teniendo en cuenta que a nuestro usuario de ejemplo le encantan las películas de acción.
Una vez sabemos como calcular la puntuación para una película, recomendar nuevas películas es tan fácil como calcular las puntuaciones de cada película, y luego escoger aquellas con una puntuación más alta.
1 2 3 4 5 6 |
def get_movie_recommendations(user_preferences, n_recommendations): #we add a column to the movies_df dataset with the calculated score for each movie for the given user movies_df['score'] = movies_df[movie_categories].apply(get_movie_score, args=([user_preferences.values()]), axis=1) return movies_df.sort_values(by=['score'], ascending=False)['movie_title'][:n_recommendations] get_movie_recommendations(user_preferences, 10) |
1 2 3 4 5 6 7 8 9 10 11 |
2253 Soldier (1998) 257 Star Wars: Episode IV - A New Hope (1977) 2036 Tron (1982) 1197 Army of Darkness (1993) 2559 Star Wars: Episode I - The Phantom Menace (1999) 1985 Honey, I Shrunk the Kids (1989) 1192 Star Wars: Episode VI - Return of the Jedi (1983) 1111 Abyss, The (1989) 1848 Armageddon (1998) 2847 Total Recall (1990) Name: movie_title, dtype: object |
Asi que vemos que el sistema de recomendación recomienda películas de acción y de ciencia ficción.
El filtrado de contenidos hace que recomendar nuevas películas a un usuario sea muy fácil, ya que los usuarios simplemente tienen que indicar sus preferencias una vez. Sin embargo, este sistema tiene algunos problemas:
- Hay que categorizar cada item nuevo manualmente en funcion a las características existentes.
- Las recomendaciones son limitadas, ya que por ejemplo, los items existentes no se pueden clasificar en función de una nueva categoría.
Hemos visto que el filtrado de contenidos es quizás una manera demasiado simple de hacer recomendaciones, lo que nos lleva a…
Filtrado Colaborativo
El filtrado colaborativo es otro método distinto de predecir puntuaciones de usuarios a items. Sin embrago, en este método usamos las puntuaciones existentes de usuarios a items para predecir los items que no han sido valorados por el usuario al que queremos recomendar.
Para ello asumimos que las recomendaciones que le hagamos a un usuario serán mejores si las basamos en usuarios con gustos similares.
Para este ejemplo usaremos el archivo ratings.dat, que tiene el siguiente formato:
user_id::movie_id::rating::timestamp
1 2 3 4 5 6 7 8 9 10 11 |
head ratings.dat 1::1193::5::978300760 1::661::3::978302109 1::914::3::978301968 1::3408::4::978300275 1::2355::5::978824291 1::1197::3::978302268 1::1287::5::978302039 1::2804::5::978300719 1::594::4::978302268 1::919::4::978301368 |
El dataset de Movielens contiene un archivo con más de un millón de valoraciones de películas hechas por usuarios.
1 2 3 4 5 6 |
ratings_df = pd.read_table('ratings.dat', header=None, sep='::', names=['user_id', 'movie_id', 'rating', 'timestamp']) #Borramos al fecha en la que el rating fue creado. del ratings_df['timestamp'] #reemplazamos la id de la película por su titulo para tener una mayor claridad ratings_df = pd.merge(ratings_df, movies_df, on='movie_id')[['user_id', 'movie_title', 'movie_id','rating']] ratings_df.head() |
Out[]:
user_id | movie_title | movie_id | rating | |
---|---|---|---|---|
0 | 1 | One Flew Over the Cuckoo’s Nest (1975) | 1193 | 5 |
1 | 2 | One Flew Over the Cuckoo’s Nest (1975) | 1193 | 5 |
2 | 12 | One Flew Over the Cuckoo’s Nest (1975) | 1193 | 4 |
3 | 15 | One Flew Over the Cuckoo’s Nest (1975) | 1193 | 4 |
4 | 17 | One Flew Over the Cuckoo’s Nest (1975) | 1193 | 5 |
De momento tenemos una matriz de usuarios y películas, vamos a convertir ratings_df
a una matriz con un usuario por fila y una película por columna.
1 2 3 4 |
ratings_mtx_df = ratings_df.pivot_table(values='rating', index='user_id', columns='movie_title') ratings_mtx_df.fillna(0, inplace=True) movie_index = ratings_mtx_df.columns ratings_mtx_df.head() |
Out[]:
movie_title | $1,000,000 Duck (1971) | ‘Night Mother (1986) | ‘Til There Was You (1997) | … |
---|---|---|---|---|
user_id | ||||
1 | 0 | 0 | 0 | … |
2 | 0 | 0 | 0 | … |
3 | 0 | 5 | 0 | … |
4 | 0 | 0 | 1 | … |
5 | 0 | 0 | 0 | … |
Nos queda una matriz de 6064 usuarios y 3706 películas.
Para computar la similaridad entre películas, una manera de hacerlo es calcular la correlación entre ellas en función de la puntuación que dan los usuarios.
Una manera fácil de calcular la similaridad en python es usando la función numpy.corrcoef
, que calcula el coeficiente de correlación de Pearson(PMCC)](https://es.wikipedia.org/wiki/Coeficiente_de_correlaci%C3%B3n_de_Pearson) entre cada pareja de items.
El PMCC tiene un valor entre -1 y 1 que mide cuán relacionadas están un par de variables cuantitativas.
La matriz de correlación es una matriz de tamaño m x m, donde el elemento Mij representa la correlación entre el item i y el item j.
1 2 |
corr_matrix = np.corrcoef(ratings_mtx_df.T) corr_matrix.shape |
Out[]:
1 |
(3706, 3706) |
*Nota: Usamos la matriz traspuesta de ratings_mtx_df
para que la función np.corrcoef
nos devuelva la correlación entre películas. En caso de no hacerlo nos devolvería la correlación entre usuarios.
Una vez tenemos la matriz, si queremos encontrar películas similares a una concreta, solo tenemos que encontrar las películas con una correlación alta con ésta.
1 2 3 4 5 |
favoured_movie_title = 'Toy Story (1995)' favoured_movie_index = list(movie_index).index(favoured_movie_title) P = corr_matrix[favoured_movie_index] #solo devolvemos las películas con la mayor correlación con Toy Story list(movie_index[(P>0.4) & (P<1.0)]) |
Out[]:
1 2 3 4 5 |
['Aladdin (1992)', "Bug's Life, A (1998)", 'Groundhog Day (1993)', 'Lion King, The (1994)', 'Toy Story 2 (1999)'] |
Vemos que los resultados son bastante buenos.
Ahora, si queremos recomendar películas a un usuario, solo tenemos que conseguir la lista de películas que dicho usuario ha visto. Ahora, con dicha lista, podemos sumar las correlaciones de dichas películas con todas las demás y devolver las películas con una mayor correlación total.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
def get_movie_similarity(movie_title): '''Devuelve el vector de correlación para una película''' movie_idx = list(movie_index).index(movie_title) return corr_matrix[movie_idx] def get_movie_recommendations(user_movies): '''Dado un grupo de películas, devolver las mas similares''' movie_similarities = np.zeros(corr_matrix.shape[0]) for movie_id in user_movies: movie_similarities = movie_similarities + get_movie_similarity(movie_id) similarities_df = pd.DataFrame({ 'movie_title': movie_index, 'sum_similarity': movie_similarities }) similarities_df = similarities_df[~(similarities_df.movie_title.isin(user_movies))] similarities_df = similarities_df.sort_values(by=['sum_similarity'], ascending=False) return similarities_df |
Por ejemplo, vamos a seleccionar un usuario con preferencia por las películas infantiles y algunas películas de acción.
1 2 |
sample_user = 21 ratings_df[ratings_df.user_id==sample_user].sort_values(by=['rating'], ascending=False) |
Out[]:
user_id | movie_title | movie_id | rating | |
---|---|---|---|---|
583304 | 21 | Titan A.E. (2000) | 3745 | 5 |
707307 | 21 | Princess Mononoke, The (Mononoke Hime) (1997) | 3000 | 5 |
70742 | 21 | Star Wars: Episode VI – Return of the Jedi (1983) | 1210 | 5 |
239644 | 21 | South Park: Bigger, Longer and Uncut (1999) | 2700 | 5 |
487530 | 21 | Mad Max Beyond Thunderdome (1985) | 3704 | 4 |
707652 | 21 | Little Nemo: Adventures in Slumberland (1992) | 2800 | 4 |
708015 | 21 | Stop! Or My Mom Will Shoot (1992) | 3268 | 3 |
706889 | 21 | Brady Bunch Movie, The (1995) | 585 | 3 |
623947 | 21 | Iron Giant, The (1999) | 2761 | 3 |
619784 | 21 | Wild Wild West (1999) | 2701 | 3 |
4211 | 21 | Bug’s Life, A (1998) | 2355 | 3 |
368056 | 21 | Akira (1988) | 1274 | 3 |
226126 | 21 | Who Framed Roger Rabbit? (1988) | 2987 | 3 |
41633 | 21 | Toy Story (1995) | 1 | 3 |
34978 | 21 | Aladdin (1992) | 588 | 3 |
33432 | 21 | Antz (1998) | 2294 | 3 |
18917 | 21 | Bambi (1942) | 2018 | 1 |
612215 | 21 | Devil’s Advocate, The (1997) | 1645 | 1 |
617656 | 21 | Prince of Egypt, The (1998) | 2394 | 1 |
440983 | 21 | Pinocchio (1940) | 596 | 1 |
707674 | 21 | Messenger: The Story of Joan of Arc, The (1999) | 3053 | 1 |
708194 | 21 | House Party 2 (1991) | 3774 | 1 |
Ahora podemos proporcionar nuevas recomendaciones para dicho usuario teniendo en cuenta las películas que ha visto como input.
1 2 3 4 |
sample_user_movies = ratings_df[ratings_df.user_id==sample_user].movie_title.tolist() recommendations = get_movie_recommendations(sample_user_movies) #Obtenemos las 20 películas con mejor puntuación recommendations.movie_title.head(20) |
Out[]:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
1939 Lion King, The (1994) 324 Beauty and the Beast (1991) 1948 Little Mermaid, The (1989) 3055 Snow White and the Seven Dwarfs (1937) 647 Charlotte's Web (1973) 679 Cinderella (1950) 1002 Dumbo (1941) 301 Batman (1989) 3250 Sword in the Stone, The (1963) 303 Batman Returns (1992) 2252 Mulan (1998) 2924 Secret of NIMH, The (1982) 2808 Robin Hood (1973) 3026 Sleeping Beauty (1959) 1781 Jungle Book, The (1967) 260 Back to the Future Part III (1990) 259 Back to the Future Part II (1989) 2558 Peter Pan (1953) 2347 NeverEnding Story, The (1984) 97 Alice in Wonderland (1951) Name: movie_title, dtype: object |
Vemos que el sistema recomienda mayoritariamente películas infantiles y algunas películas de acción.
El ejemplo que he puesto sobre filtrado colaborativo es un ejemplo muy simple, y no tiene en cuenta las valoraciones que cada usuario ha hecho a cada película (solo si las ha visto).
Una manera más eficaz de hacer filtrado colaborativo es vía Descomposición en valores singulares (SVD). Es un tópico que da para otro artículo pero este artículo lo explica con bastante claridad.
El filtrado colaborativo se usa con frecuencia en la actualidad. Es capaz de recomendar nuevos items sin tener que clasificarlos manualmente en función de sus características. Además, es capaz de proporcionar recomendaciones basadas en características ocultas que no serían obvias a primera vista (por ejemplo, combinaciones de géneros o de actores).
Sin embargo, el filtrado colaborativo tiene un problema importante, y es que no puede recomendar items a un usuario a menos que dicho usuario haya valorado items, este problema se llama problema de Arranque en frío.
Una manera de solucionar ésto es usar un sistema híbrido de filtrado de contenido + filtrado colaborativo, usando el filtrado de contenidos para nuevos usuarios y filtrado colaborativo para usuarios de los que se tiene suficiente información.
Lista de lecturas
Aquí hay una lista de lecturas sobre sisetmas de recomendación (en inglés)
Pingback: Una breve introducción a los Sistemas de Recomendación
Muy chulo, se ve bien como usar pandas.
nota: donde dices producto vectorial, realmente no es producto vectorial, es producto escalar, producto punto (dot_product) o producto interno. El producto vectorial te devolvería un vector.