Creando un Sistema de Recomendación con Python y Machine Learning

Ivan Lee
10 min readMar 12, 2023

En 2006 Netflix lanza una competencia pública para mejorar en 10% su sistema de recomendación. El equipo ganador sería premiado con una bolsa de $1M USD. Después de 3 años solo dos equipos logran el objetivo y presentan sus modelos con tan solo 20 minutos de diferencia. Hoy en día interactuamos con estos sistemas todo el tiempo, plataformas como Youtube, Amazon, Spotify y muchas otras los utilizan para captar al usuario y capitalizar su oferta de productos.

El objetivo de este proyecto será construir desde cero, un sistema básico de recomendación de restaurantes de la ciudad de Santa Barbara, California. Utilizaremos el dataset público de la plataforma Yelp y aplicaremos el método de reducción de dimensionalidad SVD.

Que es un Sistema de Recomendación?

Se puede definir como un algoritmo de machine learning que utiliza datos para sugerir productos o servicios, buscando personalizar la experiencia de cada consumidor.

Podemos clasificar en 3 los sistemas de recomendación:

Sistemas de filtrado colaborativo (collaborative filtering)

  • Las recomendaciones se dan a partir de la agrupación de usuarios con gustos similares.
  • Pueden ser basados en el usuario (user-based) o basados en el artículo (item-based).
  • Requieren gran cantidad de datos de los usuarios y los artículos que han consumido.

Sistemas basados en contenido (content-based)

  • Utilizan el perfil del usuario y los artículos por los que ha mostrado preferencia.
  • Basado en estas preferencias se trata de inferir que artículo nuevo le interesará

Sistemas basados en popularidad (popularity-based)

  • Son sencillos de implementar, pero no son personalizados.
  • Útiles para el problema del “cold start”, es decir cuando no se tiene historial del usuario.
Dos modelos de sistemas de recomendación.

En general, una buena estrategia es usar sistemas híbridos de acuerdo con la cantidad y calidad de los datos disponibles.

Los datos

Trabajaremos con el dataset de Yelp, una plataforma en la que sus usuarios califican y reseñan todo tipo de negocios. Este es un dataset público para fines educativos y de investigación, lo puedes consultar aquí.

(Consulta el código completo y notebooks de este proyecto en mi repo de GitHub, y un dashboard interactivo en Tableau public.)

Identificamos dos tablas de interés para nuestro sistema de recomendación. En una tenemos la información de los negocios y en otra las calificaciones de todos los usuarios a estos negocios:

import pandas as pd
import numpy as np
from sklearn.decomposition import TruncatedSVD

business = pd.read_json('./../data/yelp_business.json', lines=True)
reviews = pd.read_csv('./../data/yelp_reviews_subset.csv')
‘business’ dataframe
‘reviews’ dataframe

Pre-procesamiento y Limpieza

La primer tarea será:
- Eliminar todos los registros que no sean de Restaurantes.
- Limpiar datos faltantes o nulos en caso de ser necesario.
- Filtrar solo los restaurantes de California, específicamente Santa Barbara.
- Crear una matriz con los datos de los restaurantes y los usuarios.

# Create dataframe with Restaurants only
restaurants = business[business.categories.fillna('-').str.lower().str.contains('restaurant')]

# Select restaurants in California only
ca_restaurants = restaurants[restaurants.state.str.contains('CA')]

Hacemos la unión de tablas. Si estás familiarizado con el lenguaje SQL, esto es análogo a hacer un INNER JOIN de dos tablas usando como relación la llave ‘business_id’.

# Join tables using 'bussines_id' as the key
df = reviews.merge(ca_restaurants, on='business_id')

Eliminamos las columnas que consideramos sin relevancia para nuestro análisis:

# Drop non-relevant columns
df_clean = df.drop(columns=['text','useful','funny','cool'])

Continuamos la exploración de los datos para una mejor perspectiva previo al modelado.

Datos nulos o faltantes y tipo de datos:

# lets check for null values and data types
df_clean.info()
<class 'pandas.core.frame.DataFrame'>
Int64Index: 37830 entries, 0 to 37829
Data columns (total 15 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 review_id 37830 non-null object
1 user_id 37830 non-null object
2 business_id 37830 non-null object
3 stars 37830 non-null float64
4 date 37830 non-null object
5 name 37830 non-null object
6 address 37830 non-null object
7 city 37830 non-null object
8 state 37830 non-null object
9 postal_code 37830 non-null object
10 latitude 37830 non-null float64
11 longitude 37830 non-null float64
12 avg_rating 37830 non-null float64
13 review_count 37830 non-null int64
14 categories 37830 non-null object
dtypes: float64(4), int64(1), object(10)

Total de restaurantes, usuarios y calificaciones:

# Total number of restaurants, users and reviews in the dataset
rest_total = df_clean['name'].nunique()
user_total = df_clean['user_id'].nunique()
review_total = df_clean['review_id'].nunique()

print('Numero total de restaurantes: ', rest_total)
print('Numero total de usuarios: ', user_total)
print('Numero total de calificaciones: ', review_total)
Numero total de restaurantes:  659
Numero total de usuarios: 23468
Numero total de calificaciones: 37830

(Consulta el EDA completo y lista de restaurantes en este jupyter notebook)

Procedemos a crear el dataframe con los datos esenciales para generar el sistema de recomendación. Particularmente nos interesan los “id” de usuario, nombre del restaurante y la calificación de cada usuario:

df_recsys = df_clean[['user_id', 'business_id', 'stars', 'name']]

print(df_recsys.shape)
df_recsys.head()

Ahora tenemos el dataframe listo para continuar con el modelo. A manera de ejemplo, extraemos las calificaciones de un usuario arbitrario:

# Choose a random user
idx = (df_recsys['user_id']=='4MSEWnnxKhNdh7GgNHM-YQ')

# print user's reviews
print('Total de restaurantes calificados por el usuario: ',idx.sum())
df_recsys[idx]

Reducción de dimensionalidad con SVD

En machine learning el aprendizaje no-supervisado se refiere a los métodos para extraer información relevante sin entrenar al modelo con datos etiquetados. Por ejemplo, los métodos de Clustering o conglomerados se consideran no-supervisados y pueden resultar particularmente útiles para el problema del “cold-start”.
Otra aplicación de modelos no-supervisados es la reducción de dimensionalidad con el fin de tener un set de variables mas manejable.

SVD (Singular Value Decomposition) o Descomposición de Valores Singulares, es una técnica de álgebra lineal para factorización de matrices muy similar a PCA. Esta técnica nos permite reducir la dimensionalidad de nuestro dataset, minimizando la perdida de información. Dicho en palabras más simples, después de la descomposición SVD tendremos un dataset más pequeño para simplificar los cálculos que realizaremos mas adelante.

Una explicación muy clara y paso a paso de la descomposición SVD.

Iniciamos por crear una matriz de usuario-artículo. Los valores de cada registro corresponden a la calificación que los usuarios le dieron al restaurante correspondiente. Además, para los casos en los que no exista una calificación de usuario, le asignaremos un valor de 0:

# Create a utility matrix
UtlMtrx = df_recsys.pivot_table(values='stars', index='user_id',
columns='name', fill_value=0
)
UtlMtrx.head()

Notamos que en esta nueva matriz tenemos una gran cantidad de 0’s. Esto se debe a que cada usuario solo ha evaluado una fracción de los 659 restaurantes.
En álgebra lineal, esta matriz se dice que es dispersa (sparse matrix) y la técnica SVD es conveniente para trabajar con este tipo de matrices.

Deseamos obtener la información latente en relación a los restaurantes, por lo que necesitamos a los restaurantes en las filas y los usuarios en las columnas. Para ello simplemente tomamos la transpuesta de la matriz:

# Transpose the user-item matrix
X = UtlMtrx.T

print(X.shape)
X.head()

Ahora ya tenemos los datos de forma conveniente para aplicar la descomposición SVD (cada usuario es ahora un “vector-columna”). La librería de machine learning Scikit-learn nos permite hacer la descomposición en un par de líneas de código:

SVD = TruncatedSVD(n_components=658, random_state=42)  
SVD.fit(X)

Notarás que hemos usado la versión truncada de la descomposición SVD. Esto se debe a que en la práctica solo una proporción pequeña de valores singulares son significativos, y por lo tanto una buena aproximación de la matriz original.
Los nuevos vectores obtenidos con SVD son una combinación lineal de los vectores originales. Los vectores singulares se calculan de tal manera que cada vector sucesivo va capturando el mayor grado de variabilidad posible. Gracias a esto, podemos seleccionar un numero reducido de vectores que alcancen a “explicar” suficientemente la información total.

Seleccionamos los primeros 7 vectores singulares y calculamos el porcentaje de información simplificada:

num_sv = 7

print('Información simplificada con los primeros %d vectores singulares:' % num_sv)
print('%.1f%%' % (100 * (1- (SVD.singular_values_[0:num_sv]).sum() / (SVD.singular_values_).sum())))
Información simplificada con los primeros 7 vectores singulares:
95.8%

Utilizando solo los primeros 7 vectores singulares estamos reduciendo la cantidad de información en casi 96%.

Si observamos que las predicciones del modelo no son satisfactorias, podemos mejorarlo ajustando el numero de valores singulares a utilizar.

Recuerda que el propósito de reducir la dimensionalidad del dataset es el de hacer mas económico el cómputo de la correlación. Esto resulta ventajoso en aplicaciones donde se requieren predicciones en tiempo real.

Ya que hemos determinado un numero de valores singulares para diseñar nuestro modelo, generamos la nueva matriz:

num_sv = 7

SVD = TruncatedSVD(n_components=num_sv, random_state=42)

resultant_matrix = SVD.fit_transform(X)
resultant_matrix.shape
(659, 7)

Observa que la matriz original de [659 x 23468] la hemos reducido a [659 x 7].

Con la nueva matriz calculamos el coeficiente de correlación de Pearson. Esta será la métrica que nos dirá la similitud entre restaurantes y por lo tanto, si es o no, opción para recomendar:

# Pearson correlation matrix
corrMtx = np.corrcoef(resultant_matrix)

Ya tenemos listo nuestro modelo de recomendación. Es momento de ponerlo a prueba…

Resultados

Elegimos un restaurante al azar, por ejemplo Taco Bell. Lo primero será identificar en qué fila de la matriz se encuentra el restaurante:

# Look for Taco Bell index
liked = 'Taco Bell'

names = UtlMtrx.columns
names_list = list(names)
id_liked = names_list.index(liked)

id_liked
543

Encontramos que Taco Bell se ubica en la fila 543 de la matriz. Ahora veamos qué restaurantes le recomendaría el modelo a alguien que le ha gustado Taco Bell.

Observa que limitamos la salida de la lista especificando un alto coeficiente de correlación, en este caso, entre 0.97 y 0.99:

corr_recom = corrMtx[id_liked]

print('Recomendaciones: ')
# select names with a correletion between .97 and .99
list(names[(corr_recom > 0.97) & (corr_recom < 0.99)])
Recomendaciones: 
['El Rincon Bohemio',
'Kanaloa Seafood',
'La Salsa Fresh Mexican Grill',
'Le Cafe Stella',
"Louie's California Bistro",
"Norton's Pastrami & Deli",
"Rudy's Restaurant No 1",
"Rusty's Pizza Parlor",
'Santa Barbara Roasting Company',
'Subway',
'Sushi GoGo',
'The Creekside Restaurant & Bar',
'The Natural Cafe',
'The Spot']

Vemos que el modelo recomienda algunos restaurantes de estilo mexicano y algunos de comida rápida.

Supongamos que ahora buscamos comida italiana, elegimos por ejemplo Presto Pasta.
Imprimiremos la lista ordenando las recomendaciones de acuerdo al valor del coeficiente de correlación en forma descendente:

# Restaurant 'Presto Pasta'
liked = "Presto Pasta"
id_liked = names_list.index(liked)
corr_recom = corrMtx[id_liked]

print('Recomendaciones: ')
ids = (corr_recom > 0.98) & (corr_recom < 0.99)
tmp = list()

for i in range(len(names[ids])):
tmp.append((corr_recom[ids][i].round(2), names[ids][i]))

sorted(tmp, key=lambda x:x[0], reverse=True)
Recomendaciones: 
[(0.99, "Ca' Dario Goleta"),
(0.99, 'Dave’s Dogs'),
(0.99, 'Eureka!'),
(0.99, "Lito's Take Out"),
(0.99, 'On The Alley - Goleta'),
(0.99, "Petrini's Italian Restaurant - Santa Barbara"),
(0.99, 'Pizza Online Company'),
(0.98, "Big Joe's Tacos"),
(0.98, 'China Pavilion'),
(0.98, "Guicho's Eatery"),
(0.98, 'IHOP'),
(0.98, 'Ichiban'),
(0.98, 'JJ’s Diner'),
(0.98, 'Java Station'),
(0.98, 'La Tapatia'),
(0.98, 'Nutbelly Pizzeria and Deli'),
(0.98, 'South Coast Deli- Carrillo'),
(0.98, "Super Cuca's Restaurant"),
(0.98, 'Zen Yai Thai Cuisine')]

El modelo incluye ahora algunas opciones similares de estilo italiano.

Aprovechamos que ya tenemos el modelo creado y hagamos ahora recomendaciones basadas en los usuarios. Es decir, utilizaremos la matriz original en donde los restaurantes son los vectores-columna:

Xu = UtlMtrx
print (Xu.shape)

Xu.head()

Hacemos la descomposicion SVD, probando con los primeros 10 valores singulares:

num_sv = 10

SVDu = TruncatedSVD(n_components=num_sv, random_state=42)

resultant_umatx = SVDu.fit_transform(Xu)
resultant_umatx.shape
(23468, 10)

Continuamos con el cálculo de la matriz de correlación:

# Pearson correlation matrix
corrUmtx = np.corrcoef(resultant_umatx)

Elegimos algún usuario al azar y lo introducimos al modelo:

# Choose a random user
user = '4MSEWnnxKhNdh7GgNHM-YQ'

userids = UtlMtrx.index
users_list = list(userids)
user_id = users_list.index(user)
corr_urecom = corrUmtx[user_id]

uids = (corr_urecom > .98) & (corr_urecom < .99)
tmp = list()

for i in range(len(userids[uids])):
tmp.append((corr_urecom[uids][i].round(2), userids[uids][i]))

print('Usuarios relacionados: ')
sorted(tmp, key=lambda x:x[0], reverse=True)
Usuarios relacionados: 
[(0.99, 'KcZ52cNO_o9SR5WFkpFCBw'),
(0.98, 'BWjVF5476cdpwqXKh5r8Kw'),
(0.98, 'HhW_eGhtUJryU2ZaI0uSpw'),
(0.98, 'QmF2rD3E4erx9471QxIUDQ'),
(0.98, 'jxFgDOyjI1u0vznjm3q2QQ'),
(0.98, 'knReN1F-2WbpQzyhElvnYQ'),
(0.98, 'wK0Q4UtcvMsZNLitf5hKTA'),
(0.98, 'z1HlPVSEde7iuaPtwTx6Fw')]

El “id” de usuario no nos da mucho contexto. Para tener una mejor idea de porqué nuestro modelo correlaciona a estos usuarios, observemos las calificaciones registradas de los primeros dos:

# User 1
idx1 = (df_recsys['user_id']== 'KcZ52cNO_o9SR5WFkpFCBw')
# print user's reviews
df_recsys[idx1]
Calificaciones del usuario 1
# User 2
idx2 = (df_recsys['user_id']== 'jxFgDOyjI1u0vznjm3q2QQ')
# print user's reviews
df_recsys[idx2]
Calificaciones del usuario 2

Si bien el usuario 1 solo tiene 4 registros, observamos que 2 de sus calificaciones (Mesa Burger y Rascal’s Vegan Food) son similares a las del usuario 2.

Conclusión

En general este sistema es algo básico con lo que se puede iniciar e ir mejorando a partir de aquí.
Los métodos de factorización de matrices, como el que hemos usado en este proyecto, son ampliamente utilizados en sistemas de filtrado colaborativo. Existen sistemas más avanzados que utilizan modelos de redes neuronales en sus distintas variantes para lograr mejores resultados.

Espero que te haya sido de utilidad este artículo. Si deseas, compártelo y déjame tus comentarios.

--

--

Ivan Lee

MSc. in Applied Artificial Intelligence student // I write in English and Spanish 👨🏻‍💻