Breve introducción a los Sistemas de Recomendación

Por Manuel Garrido

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.

amazon

Cada vez que Amazon te sugiere productos relacionados...

netflix

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:

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)

head movies.dat

Output: ::: 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:

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()

Output:

movie_id movie_title movie_genre
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.

# Convertimos los generos de peliculas a variables dummy 
movies_df = pd.concat([movies_df, movies_df.movie_genre.str.get_dummies(sep='|')], axis=1)
movies_df.head()

Output:

movie_id movie_title movie_genre Action Adventure Animation Children's Comedy Crime Documentary ...
1 Toy Story (1995) Animation|Children's|Comedy 1 1 1 ...
1 2 Jumanji (1995) Adventure|Children's|Fantasy 1 1 ...
2 3 Grumpier Old Men (1995) Comedy|Romance 1 ...
3 4 Waiting to Exhale (1995) Comedy|Drama 1 ...
4 5 Father of the Bride Part II (1995) Comedy 1 ...

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.

movie_categories = movies_df.columns[3:]
movies_df.loc[0]

Output:

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:

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.

#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.

toy_story_features = movies_df.loc[0][movie_categories]
toy_story_features

:::python
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

:::python
toy_story_user_predicted_score = dot_product(toy_story_features, user_preferences.values())
toy_story_user_predicted_score

Output:

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.

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 ...
1023 1036 Die Hard (1988) Action|Thriller 1 ...
1349 1370 Die Hard 2 (1990) Action|Thriller 1 ...
die_hard_id = 1036
die_hard_features = movies_df[movies_df.movie_id==die_hard_id][movie_categories]
die_hard_features.T

Output:

1023
Action 1
Adventure
Animation
Children's
Comedy
Crime
Documentary
Drama
Fantasy
Film-Noir
Horror
Musical
Mystery
Romance
Sci-Fi
Thriller 1
War
Western

Nota, 1023 es el índice interno del dataframe, no el índice de la película Die Hard en Movielens

die_hard_user_predicted_score = dot_product(die_hard_features.values[0], user_preferences.values())
die_hard_user_predicted_score

Output:

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.

def get_movie_recommendations(user_preferences, n_recommendations):
    #metemos una columna al dataset movies_df con la puntuacion calculada para el usuario
    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)

Output:

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</pre>

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 formato user_id::movie_id::rating::timestamp

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.

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()

Output:

user_id movie_title movie_id rating
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.

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()

Output:

movie_title $1,000,000 Duck (1971) 'Night Mother (1986) 'Til There Was You (1997) ...
user_id
1 ...
2 ...
3 5 ...
4 1 ...
5 ...

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) 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.

corr_matrix = np.corrcoef(ratings_mtx_df.T)
corr_matrix.shape

Output:

(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.

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)])

Output:

['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.

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.

sample_user = 21
ratings_df[ratings_df.user_id==sample_user].sort_values(by=['rating'], ascending=False)

Output:

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.

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)

Output:

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)


photo

Manuel ha estado 4 años en Nueva York, primero trabajando como Senior Analyst para la NBC y luego como Data Scientist. Tras trabajar en varias empresas, ahora trabaja como Data Scientist Consultant, tanto para clientes españoles como extranjeros. Si necesitas ayuda, no dudes en contactarle!

Comentarios