Uno de los grandes usos actuales de los algoritmos de Machine Studying son los Sistemas de Recomendación. Son los encargados de elaborar listas de productos en los que potencialmente podemos estar interesados, ya sea para consumir o adquirir. Se basan en productos que ya hemos valorado (por haberlos comprado), que hemos comprado (aunque no los hayamos valorado) o que incluso hemos buscado o mirado sin comprarlos, y también en esos mismos tipos de productos que valoran, compran o buscan clientes o usuarios con gustos similares a los nuestros, es decir, que han comprado o consumido productos en común con nosotros y que los han valorado de una forma parecida a la nuestra. Están presentes y atentos en muchos momentos de nuestro día a día: en Amazon lo podemos ver en varias listas en la página principal como “Relacionado con artículos que has visto”, en Netflix con “Creemos que te va a encantar” o “Recomendaciones de hoy para ti” y en Spotify con “Especialmente para ti” o “Populares entre oyentes de <x artista o podcast que escuches>”.
Durante el módulo de Sistemas de Recomendación, se han tratado las definiciones y conceptos más relevantes de los SR y sus formas más básicas. Se ha aprendido la relevancia que tienen estos sistemas en nuestro día a día y cómo afrontar su construcción desde diversos puntos de vista.
En el presente artículo se presentan 3 casos distintos con sus propuestas de solución y código Python:
- Chilly-start: recomendando películas a un usuario del que no tenemos información.
- Enfoque basado en memoria: recomendando películas a un usuario basándonos en usuarios similares.
- Enfoque basado en modelos: cómo predecir la nota de un usuario con modelos de machine studying en la librería Shock.
Se afronta este problema cuando no tenemos información de nuestro usuario como para poder determinar usuarios con gustos similares. Sucede, por ejemplo, cuando se tiene un nuevo usuario que aún no ha consumido ni valorado ningún elemento. Como punto de partida, esta situación se puede atacar simplemente recomendando aquellos productos más consumidos y mejor valorados.
A continuación, se presenta un ejemplo en Python para un dataset de películas:
#Importacion de datos y librerias
import pandas as pd#################################################################
# Creamos el dataset de rankings
notas = pd.read_csv("rankings.csv")
# Renombrar las columnas del df de notas
notas.columns = ['usuarioId', 'peliculaId', 'nota', 'timestamp']
#################################################################
# Creamos el dataset de películas
peliculas = pd.read_csv("films.csv")
# Ajustamos el nombre de las columnas
peliculas.columns = ['peliculaId', 'titulo', 'generos']
# Seleccionamos la columna 'peliculaId' como índice
peliculas = peliculas.set_index('peliculaId')
#################################################################
# Anexamos el recuento de votos a cada película
peliculas['total_de_votos'] = notas.value_counts("peliculaId")
# Anexamos la media de raatings a cada película
peliculas['nota_media'] = notas.groupby('peliculaId').imply()['nota']
A partir de dicho dataset, se puede hacer lo siguiente:
- Opción A: mostrar recomendaciones según el High N de las películas más vistas; el problema es que no se está considerando su puntuación.
- Opción B: mostrar recomendaciones según el High N de las películas con mejor nota media; el problema es que se podría estar recomendando películas con muy buena nota pero con muy pocos votos en detrimento de otras que tengan algo de peor nota pero que hayan sido vistas por muchos usuarios.
- Opción C: comprobar cuáles son las películas más vistas, sacar un High N1 y, después, entre las más vistas, ordenarlas según su nota media y sacar un High N2.
Como ejemplo, se muestra cómo obtener un Top20 de películas más vistas y, sobre el Top20 de más vistas, se recomendará el Top10 con más nota:
# Hacemos un nuevo DF con el Top20 de películas más vistas:
top20rated_df = peliculas.sort_values('total_de_votos', ascending = False).head(20)# Ordenamos el Top20 de películas más vistas según su nota media y sacamos el High 10:
top10_rated_items = top20rated_df.sort_values('nota_media', ascending = False).head(10)
top10_rated_items
Aquí se presenta el resultado:
Este resultado también se puede representar de forma más gráfica para tener un vistazo más visible, como un gráfico de columnas:
# Importamos paquetes matplotlib y seaborn. Solo es necesario cargarlos una vez, pero se añaden por si no se ejecutan celdas anteriores.
import matplotlib.pyplot as plp
import seaborn as sns# Generaremos una figura del tamaño deseado en la que incrustar nuestro gráfico. Este paso no es obligatorio, pero si lo forzamos, podremos decidir el tamaño de la representación.
f = plp.determine(figsize=(10,5))
# Generamos el subgráfico correspondiente a la figura, con los argumentos (fila, columna, id) todos en 1 ya que solo queremos un solo gráfico en nuestra figura.
ax = f.add_subplot(1,1,1)
# Representamos con `barplot` de `seaborn`. En colour, representaremos el whole de votos
plot = sns.barplot(top10_rated_items, x="titulo",y="nota_media", hue='total_de_votos', palette = sns.color_palette(palette="rocket_r", n_colors=10), ax = ax, legend = True)
# Añadimos etiquetas de valores:
for i in plot.containers:
plot.bar_label(i,)
# Reubicamos la leyenda en la parte inferior derecha:
plot.legend(title='Votos totales', loc='decrease proper')
# Añadimos título y etiquetas a los ejes del gráfico
plot.set(title = "High 10 de ítems con más votos ordenados según valoración media", xlabel="Película", ylabel="Valoración media");
plot.tick_params(axis='x', rotation=90)
En base a lo anterior, es posible definir una función para obtener recomendaciones para un determinado usuario que ya haya empezado a ver películas y en las que se considere lo siguiente:
- Que recomiende películas que no haya visto aún el usuario definido en la variable
user_id
. - Que devuelva el High N de películas que queramos definido en la variable
high
. - Que sean del género definido en la variable
genero
. - Que tengan al menos un determinado número de votos definido en la variable
min_votos
.
def get_recommendations(movies_df, ratings_df, user_id: str = None, genero: str = None, min_votos: int = 0, high: int = None):# Vamos a encontrar primero las películas que nuestro usuario ya ha visto:
if user_id is None:
# Si no se outline el user_id, se mostrarán todas las películas
no_rated_movies = movies_df
else:
# Si se outline el user_id, extraemos las películas que ha visto el usuario
rated_movies = ratings_df.question(f"usuarioId =={user_id}")[['peliculaId']]
# Generamos un nuevo dataframe de películas quitando las que el usuario ya ha visto
no_rated_movies = movies_df[~movies_df.index.isin(rated_movies['peliculaId'])]
# Definimos una question dentro de nuestro dataframe para extraer solo aquellas películas
# con al menos el número de votos definidos en la variable 'min_votos'
# Si la variable no se outline, el mínimo de votos será 0 y mostrará todas
top_movies = no_rated_movies[(no_rated_movies['total_de_votos'] > min_votos)]
# Filtramos según género:
if genero isn't None:
# Si el género se outline, se filtra. Si no se outline, se muestran todas
top_movies = top_movies[(top_movies['generos'] == genero)]
# Reordenamos por nota media
top_movies = top_movies.sort_values('nota_media', ascending = False)
if high isn't None:
# Mostramos el high N definido en la variable 'high'. Si no, se muestra entero
top_movies = top_movies.sort_values('nota_media', ascending = False).head(high)
return top_movies
# Vamos a obtener las recomendaciones:
get_recommendations(
movies_df = peliculas,
ratings_df = notas,
user_id = 1, # Para el usuario user_id 1
genero = "Comedy|Drama", # Para el género Comedu|Drama
min_votos = 10, # Con al menos 10 votos
high = 10, # Top10 de películas
)
Aquí se puede ver el resultado:
Se trata de realizar filtros o SR basándonos en usuarios similares al nuestro. Para encontrar los usuarios similares, se utiliza la matriz de utilidad, que consiste en una relación de valoraciones o rankings entre usuarios y productos.
Consiste en definir a cada usuario como un vector de sus rankings y compararlo en aquellos elementos que ambos han valorado, para conocer cómo de cerca o de lejos están sus valoraciones. Esto se hace con la distancia euclídea.
A continuación, se presenta un ejemplo de cómo calcular la distancia euclídea entre 2 usuarios:
import numpy as np
def distancia_usuarios(ratings_df, user_idA, user_idB):
# Extraemos las películas valoradas y sus notas por el usuario A
userA_df= ratings_df.question(f"usuarioId=={user_idA}")[['peliculaId','nota']].set_index('peliculaId')# Extraemos las películas valoradas y sus notas por el usuario B
userB_df = ratings_df.question(f"usuarioId=={user_idB}")[['peliculaId','nota']].set_index('peliculaId')
# Unimos las notas de ambos usuarios en un dataframe solamente en las películas que ambos han visto:
notas_comun = userA_df.be part of(
userB_df, # Uniremos el dataframe userA con userB
on = "peliculaId", # Uniremos con la clave común `peliculaId`
how = "inside", # Especificamos que queremos una unión tipo `inside`, lo que nos garantiza que solo apareceran aquellos `peliculaId` que se hayan visto por ambos usuarios
lsuffix='_userA', # Como unimos el A al B, la primera columna corresponde a `_userA`.
rsuffix='_userB', # Como unimos el norte al sur, la segunda columna corresponde a `_userB`.
)
# Calculamos la distancia:
distancia = np.linalg.norm(notas_comun['nota_userA']-notas_comun['nota_userB'])
return distancia
# Probamos la función entre el usuario 1 y el usuario 3:
user_id1 = 1
user_id2 = 3
distancia = distancia_usuarios(
ratings_df = notas,
user_idA = user_id1,
user_idB = user_id2
)
print(f"La distancia entre el usuario {user_id1} y el usuario {user_id2} es: {distancia}")
Con el siguiente resultado para la prueba:
Si esta función la ejecutamos entre un usuario y todos los demás, es posible extraer lo que se conocen como los Ok-vecinos, es decir, aquellos primeros Ok usuarios con menor distancia euclídea con respecto a un usuario de referencia. En el caso de las notas de las películas, se haría de la siguiente manera:
# Definimos una función k_vecinos para calcular los usuarios más similares a un usuario de referencia:def k_vecinos(ratings_df, user_id, okay: int = None):
# Extraemos los valores únicos de user_id quitando a nuestro usuario de referencia:
usuarios = ratings_df.question(f"usuarioId!={user_id}")['usuarioId'].distinctive()
# Definimos una lista vacía que usaremos para definir un dataframe después
distancias = []
# Recorremos todos los usuarios únicos:
for usuario in usuarios:
# Calculamos la distancia entre 'user_id' y el usuario que estamos comprobando
distancia = distancia_usuarios(
ratings_df=ratings_df,
user_idA = user_id,
user_idB = usuario
)
# Adjuntamos un diccionario con nuestro usuario de referencia, el usuario con el que comparamos y su distancia
distancias.append(
{"usuario_ref": user_id,
"usuario": usuario,
"distancia": distancia,
}
)
kvecinos_df = pd.DataFrame(distancias, columns = ['usuario_ref','usuario','distancia']).sort_values('distancia', ascending = True, ignore_index = True)
if okay isn't None:
kvecinos_df = kvecinos_df.head(okay)
return kvecinos_df
# Probamos la función para el usuario 1 con los Ok=15 vecinos:
kvecinos_df = k_vecinos(
ratings_df = notas,
user_id = 1,
okay = 15,
)
kvecinos_df
Mostrando un dataset resultante de la siguiente forma para la prueba, donde se puede ver el usuario de referencia, el id de usuario con el que se compara y la distancia respectiva:
Si se aglutinan todos estos conceptos, se consigue construir una función last de sugerencias que:
- Calcule los usuarios más similares (es decir, los
okay
vecinos más próximos) a unuser_id
de referencia. - Extraiga las películas vistas por dichos usuarios.
- Dentro de dichas películas vistas por usuarios similares, filtre (si así se desea) por un determinado
genero
. - Finalmente, que haga una recomendación del
high
N de películas mejor valoradas dentro de las vistas por usuarios similares para un determinado género.
Adicionalmente, el script planteado se asegura de, si todos los k-vecinos tienen la misma distancia, podría implicar que hay más usuarios que son igual de similares que el resto, por lo que se incluirían adicionalmente para no dejar fuera ningún usuario con el mismo grado de similitud que los del high okay
.
Así quedaría el código y un ejemplo:
def sugerencias_kvecinos(movies_df, ratings_df, user_id, genero: str = None, okay: int = None, high: int = None):# Calculamos todos los vecinos. No utilizamos la Ok por si, por ejemplo, obtenemos un mayor número de usuarios similares con la distancia mínima que la propia Ok
kvecinos_df = k_vecinos(
ratings_df = notas,
user_id = user_id,
okay = None
)
# Calculamos la menor distancia:
min_dist = kvecinos_df['distancia'].min()
# Calculamos la mayor distancia entre los Ok-vecinos:
k_dist = max(kvecinos_df['distancia'][0:k])
# Comparamos la distancia del vecino más próximo y la del Ok-vecino
if min_dist == k_dist:
# Si en los primeros Ok-vecinos la distancia sigue siendo la mínima
# definiremos como Ok-vecinos aquellos cuya distancia sea igual a la mínima
# para no descartar ningún usuario igual de comparable
kvecinos_df = kvecinos_df[(kvecinos_df['distancia'] == min_dist)]
else:
# Si no es el caso, cogeremos los primeros Ok vecinos:
kvecinos_df = kvecinos_df.head(okay)
# Con los kvecinos definidos, vamos a extraer la lista de ids únicos de películas valoradas por nuestros k-vecinos:
notas_vecinos = ratings_df[ratings_df['usuarioId'].isin(kvecinos_df['usuario'])]
# Con las películas valoradas por nuestros k-vecinos, vamos a sacar la información de dichas películas:
peliculas_vecinos = movies_df[movies_df.index.isin(notas_vecinos['peliculaId'])]
# A partir del dataframe de todas las películas que han visto nuestros Ok-vecinos,
# sacaremos el High de recomendaciones para un determinado género
# y quitando aquellas que nuestro usuario ya haya visto
sugerencias_kvecinos_df = get_recommendations(
movies_df = peliculas_vecinos,
ratings_df = notas_vecinos,
user_id = user_id,
genero = genero,
min_votos = 0,
high = high
)
return sugerencias_kvecinos_df
# Probamos nuestra función:
sugerencias_df = sugerencias_kvecinos(
movies_df = peliculas,
ratings_df = notas,
user_id = 1,
genero = "Documentary",
okay = 10,
high = 10
)
sugerencias_df
Este tipo de filtros consiste en usar técnicas de machine studying y aprendizaje supervisado para poder realizar predicciones sobre el posible score que un usuario determinado daría a un elemento basándose en sus gustos, es decir, basándose en la nota que le dieron a dicho elemento usuarios similares a nuestro usuario de referencia.
En otro artículo se presentó la librería Shock, que es una librería Python que contiene métodos para facilitar la creación de modelos de machine studying particularizados para sistemas de recomendación (SR) a partir de datos de valoración/evaluación/puntuación de elementos.
En el artículo de referencia se puede encontrar la información más detallada de la construcción de los modelos; aquí se planteará el código de manera más acelerada:
- Se instala la librería y se leen los datos con la clase
Reader
para convertirlos en unDataset
:
# Instalamos la librería shock
!pip set up -q scikit-surprise#############################################################################
# Definimos nuestro reader:
reader = Reader(
identify = None, # Nombre predefinido de esquema
line_format='consumer merchandise score timestamp', # Esquema de columnas
sep= ',', # Separador de columnas
rating_scale = (0,5), # Escala de los rankings
skip_lines = 1, # 1 si nuestro fichero tiene una primera línea con los títulos de columnas
)
#############################################################################
# Definimos nuestro dataset:
from shock import Dataset
information = Dataset.load_from_file(
file_path = 'rankings.csv', # Ruta del fichero que queremos leer.
reader=reader, # Lector que queremos utilizar
)
- Se definen un par de algoritmos para probar utilizando la métrica de la similitud del coseno: KNN y Ok-Means
# Definimos el algoritmo KNN:
from shock import KNNBasickNN = KNNBasic(
okay=50, # Máximo número de vecinos para considerar en la agregación.
min_k = 1, # Mínimo número de vecinos para considerar en la agregación.
sim_options={
'identify': 'cosine', # Modelo de similitud entre cosine/pearson/msd/pearson_baseline
'user_based': True, # True si la similitud es entre usuarios; False si la similitud es entre gadgets.
'min_support': 1, # Umbral mínimo de similitudes; si no se supera, devuelve 0 como valor de similitud.
},
verbose = False, # Para que el modelo muestre en pantalla el racional del proceso.
)
#############################################################################
# Definimos el algoritmo Ok-Means:
from shock import KNNWithMeans
kMeans = KNNWithMeans(
okay=50, # Máximo número de vecinos para considerar en la agregación.
min_k = 1, # Mínimo número de vecinos para considerar en la agregación.
sim_options={
'identify': 'cosine', # Modelo de similitud entre cosine/pearson/msd/pearson_baseline
'user_based': True, # True si la similitud es entre usuarios; False si la similitud es entre gadgets.
'min_support': 1, # Umbral mínimo de similitudes; si no se supera, devuelve 0 como valor de similitud.
},
verbose = False, # Para que el modelo muestre en pantalla el racional del proceso.
)
- Se preparan losdatos de entrenamiento, dividiendo los datos en los subconjuntos de entrenamiento y check:
# Dividimos nuestro dataset en datos de entrenamiento y de check:
from shock.model_selection import train_test_splitdataset_train, dataset_test = train_test_split(
information = information, # Los datos que queremos dividir.
test_size=0.3, # Tamaño (porcentual del whole) del conjunto de datos de check.
# train_size = 0.7, # Tamaño del conjunto de entrenamiento; no es necesario si ya se outline el de check.
random_state = 47, # Punto de partida (seed) para el algoritmo de división.
shuffle = True, # Mezclar los datos antes de la división.
)
- Se ajustan los modelos a los datos de entrenamiento:
# Realizamos el entrenamiento de nuestro modelo predictivo de Ok-Means con el dataset de entrenamiento:
kMeans.match(dataset_train)# Realizamos el entrenamiento de nuestro modelo predictivo de KNN con el dataset de entrenamiento:
kNN.match(dataset_train)
- Se evalúan los modelos con el dataset de validación:
# Realizamos las predicciones de nuestro modelo Ok-Means con el dataset de check:
kMeans_test_predictions = kMeans.check(dataset_test)# Realizamos las predicciones de nuestro modelo KNN con el dataset de check:
kNN_test_predictions = kNN.check(dataset_test)
- Finalmente, se sacan las métricas de error:
MSE
,RMSE
yMAE
:
from shock import accuracy
from tabulate import tabulate# Medimos la precisión de nuestros modelos con MSE:
kMeans_mse = accuracy.mse(kMeans_test_predictions, verbose = False)
kNN_mse = accuracy.mse(kNN_test_predictions, verbose = False)
# Medimos la precisión de nuestros modelos con RMSE:
kMeans_rmse = accuracy.rmse(kMeans_test_predictions, verbose = False)
kNN_rmse = accuracy.rmse(kNN_test_predictions, verbose = False)
# Medimos la precisión de nuestros modelos con MAE:
kMeans_mae = accuracy.mae(kMeans_test_predictions, verbose = False)
kNN_mae = accuracy.mae(kNN_test_predictions, verbose = False)
# Componemos una tabla con nuestros valores para representarlo con Tabulate:
desk=[
['Modelo', 'MSE', 'RMSE', 'MAE'],
['K-Means:', kMeans_mse, kMeans_rmse, kMeans_mae],
['KNN:', kNN_mse, kNN_rmse, kNN_mae],
]
print(tabulate(desk))
- Y se obtiene el siguiente resultado:
Donde se puede comprobar cómo el modelo de Ok-Means tiene un mejor comportamiento que el modelo de KNN al tener un valor de error inferior tanto para MSE/RMSE como para MAE, por lo que sería el mejor entre los dos.
Como se ha podido ver, los sistemas de recomendación están muy presentes en la vida precise, siendo partícipes de muchas plataformas o lugares de consumo cotidiano de las personas.
Además, se disponen de diferentes estrategias de aproximación según el caso que se plantee de forma que se pueda sacar todo el partido posible a los datos disponibles.