Más sobre ciencia de datos: cienciadedatos.net
En este artículo me he propuesto aunar dos de mis pasiones, el vino y la ciencia de datos, para poder encontrar recomendaciones de nuevos vinos basadas en opiniones de críticos. Para ello, hago uso de métodos de transfer learning y búsqueda semántica.
Los datos se encuentran disponibles en https://www.kaggle.com/zynicide/wine-reviews de manera abierta y contienen reseñas de más de 120.000 vinos diferentes del viejo y nuevo mundo, procedentes de la web Wine Enthusiast.
import pandas as pd
wine_data = pd.read_csv('./winemag-data-130k-v2.zip')
wine_data = wine_data.drop(columns='Unnamed: 0')
wine_data.head()
En muchas ocasiones, encontramos vinos sorprendentes y fuera de lo que estamos acostumbrados y nos apetecería probar cosas similares. Ya sea por el terroir, la variedad de uva o la puntuación en listas reputadas, podemos identificar nuevos candidatos gracias a ello, pero también sería fantástico poder tener en cuenta el conocimiento de expertos críticos a la hora de tomar nuestras elecciones.
Para ello, voy a utilizar las notas de cata como punto de partida y a transformarlas mediante NLP en vectores representativos de toda la descripción, o sentence embeddings para comparar unos vinos con otros.
Para verificar que las recomendaciones van a ser diversas, he escogido el top 10 tanto de países como de variedades de uva. Observamos que, si bien Estados Unidos es el país predominante de origen de los vinos del set de datos, lo compensamos con los mayores productores del viejo mundo (Italia, Francia y España) por lo que obtendremos recomendaciones de ambos lados del Atlántico para explorar.
En lo referente a los varietales, contamos tanto con blanca como tinta, por lo que del mismo modo que con el origen, no estaremos sesgados ante un tipo u otro de vino.
wine_data.groupby('country').count().sort_values('description').tail(10).plot.bar(y ='description');
wine_data.groupby('variety').count().sort_values('description').tail(10).plot.bar(y ='description');
En primer lugar, vamos a utilizar un modelo preentrenado (transfer learning), para convertir las notas de cata, que esrtán en formato texto, a vectores numéricos que nos permitan encontrar recomendaciones.
Para ello, usaremos el Google Universal Sentence Encoder, que está disponible en el TensorFlow hub y que convierte cualquier frase en inglés en un vector de 512 dimensiones o sentence embedding.
En caso de tener las descripciones en varios idiomas, podríamos recurrir a alternativas multilenguaje, aunque no es el caso que nos ocupa, ya que todas están en inglés. Como es un proceso costoso, almacenaremos los resultados de transformar cada nota en un tensor con tantas observaciones como vinos y 512 columnas correspondientes a las dimensiones del embedding.
import cloudpickle
from tqdm import tqdm
import tensorflow_hub as hub
import numpy as np
from sklearn.manifold import TSNE
import tensorflow as tf
import math
create_embeddings = False
if create_embeddings:
use = hub.load("https://tfhub.dev/google/universal-sentence-encoder/4")
# Raw embeddings
EMBEDDINGS = []
for i in tqdm(wine_data['description'].tolist()):
EMBEDDINGS.append(use([i]))
EMBEDDINGS = np.array(EMBEDDINGS)
EMBEDDINGS = EMBEDDINGS.reshape([EMBEDDINGS.shape[0], EMBEDDINGS.shape[2]])
cloudpickle.dump(EMBEDDINGS, open('./wine_embeddings.p','wb'))
else:
EMBEDDINGS = cloudpickle.load(open('./wine_embeddings.p','rb'))
EMBEDDINGS = np.array(EMBEDDINGS)
EMBEDDINGS = EMBEDDINGS.reshape([EMBEDDINGS.shape[0], EMBEDDINGS.shape[2]])
Podemos comprobar que los embeddings están normalizados. Esto es importante de cara a calcular su similitud para obtener recomendaciones.
sum(EMBEDDINGS[0,:]**2)
Una vez transformadas todas las notas de cata en vectores, podemos utilizar la similitud del coseno para obtener aquellos vectores más cercanos a uno dado usando la siguiente fórmula:
$$ Similitud(A, B) = \frac{A \cdot B}{||A||\cdot||B||} $$En este caso, nosotros lo transformaremos en una puntuación siguiendo las indicaciones para similitud textual del benchmark STS.
def compute_scores(a, b):
a = tf.nn.l2_normalize(a, axis=1)
b = tf.nn.l2_normalize(b, axis=1)
cosine_similarities = tf.matmul(a, b, transpose_b = True)
clip_cosine_similarities = tf.clip_by_value(cosine_similarities, -1.0, 1.0)
scores = 1.0 - tf.acos(clip_cosine_similarities) / math.pi
return scores
def get_recommendations(query, embeddings, top_k = 10):
if len(query.shape)==1:
query = tf.expand_dims(query, 0)
sim_matrix = compute_scores(query, embeddings)
rank = tf.math.top_k(sim_matrix, top_k)
return rank
Vamos a tomar un ejemplo aleatorio, la muestra número 15, que corresponde a un Riesling de Alemania. Comprobamos que las primeras recomendaciones son coherentes ya que, o bien son de la misma variedad y origen, o de la región de Alsacia, en Francia.
También encontramos recomendaciones interesantes, como un Riesling estadounidense, un rosado alsaciano con Pinot Noir y un Chardonnay austríaco.
scores, rank = get_recommendations(EMBEDDINGS[15,:], EMBEDDINGS, top_k = 10)
wine_data.iloc[rank.numpy().reshape(-1).tolist(), :]
En el caso de querer buscar recomendaciones similares a un vino específico, es sencillo obtener candidatos por similitud, pero, ¿qué sucede en el caso de que sean más genéricas, no respecto a un vino concreto, sino a una región o variedad?
En este caso, lo que podemos hacer es considerar los vectores de todos aquellos vinos que cumplen las características y promediarlos para obtener recomendaciones próximas a todos ellos. Ese vector promedio representa a un "candidato arquetípico" del cuál buscaremos recomendaciones cercanas.
Vamos ahora a inspeccionar esta casuística, para evaluar el comportamiento del recomendador ante:
Un vino tinto australiano con uva Pinot Noir, buscando recomendaciones del viejo mundo.
Un vino tinto de la D.O.Ca. Rioja restringiendo las recomendaciones a no ser españolas.
Vinos similiares al Prosecco que no procedan ni de Francia ni de Italia.
Tinto australiano Pinot Noir
Generamos el embedding promedio de todos los vinos candidatos. Comprobamos que obtenemos resultados interesantes, la mayoría del nuevo mundo y de la misma variedad de uva, aunque también sorprendentes como un Meinklang 2015 austríaco, de uva Blauburgunder (otro apelativo para la Pinot Noir).
indices = wine_data[(wine_data['country'] == 'Australia')&(wine_data['variety'] == 'Pinot Noir')].index
australian_pinotnoir = tf.math.reduce_mean(EMBEDDINGS[indices,:], axis = 0)
scores, rank = get_recommendations(australian_pinotnoir, EMBEDDINGS, top_k = 10)
wine_data.iloc[rank.numpy().reshape(-1).tolist(), :]
D.O.Ca. Rioja
indices = wine_data[(wine_data['region_1'] == 'Rioja')&([str(i).find('Tempranillo')>-1 for i in wine_data['variety'].values.tolist()])].index
spanish_rioja_tempranillo = tf.math.reduce_mean(EMBEDDINGS[indices,:], axis = 0)
scores, rank = get_recommendations(spanish_rioja_tempranillo, EMBEDDINGS, top_k = 1000)
recommendations = wine_data.iloc[rank.numpy().reshape(-1).tolist(), :]
recommendations[recommendations.country != 'Spain'].head(10)
Prosecco
indices = wine_data[([str(i).find('Prosecco')>-1 for i in wine_data['variety'].values.tolist()])].index
prosecco = tf.math.reduce_mean(EMBEDDINGS[indices,:], axis = 0)
scores, rank = get_recommendations(prosecco, EMBEDDINGS, top_k = 1000)
recommendations = wine_data.iloc[rank.numpy().reshape(-1).tolist(), :]
recommendations[(recommendations.country!='Italy')&(recommendations.country!='France')].head(10)
Este último caso es interesante porque en el top 10 obtenemos vinos blancos no espumosos. Podríamos argüir que mayoritariamente han sido excluidos con el filtro del país, al no considerar los champagnes, pero fundamentalmente se debe a que, en las notas de cata, el término sparkling no suele aparecer. Lo verificamos y solo aparece en un 0.6% de las muestras.
spark_indices = [i.find('sparkling')>-1 for i in wine_data.description]
sum(spark_indices) / wine_data.shape[0]
wine_data[spark_indices & (wine_data.country != 'France') & (wine_data.country != 'Italy')].shape
recommendations[(recommendations.country!='Italy')&(recommendations.country!='France')&[str(i).lower().find('sparkling')>-1 for i in recommendations.variety]]
Verificamos que aunque hay algunos candidatos de entre los vinos espumosos, esa característica se pierde entre la descripción de la nota de cata y el recomendador se centra en otras características.
Es interesante poder obtener un listado de recomendaciones dado un determinado terroir, variedad, etc. Pero igualmente interesante el poder explorar de manera interactiva los resultados.
Para ello, usaremos T-SNE para reducir la dimensionalidad de 512 a 2 dimensiones. Escogeremos aleatoriamente 5000 muestras de entre todos los vinos reseñados para que sea visual y computacionalmente más eficaz.
sample_num = 5000
idxs = tf.range(tf.shape(EMBEDDINGS)[0])
ridxs = tf.random.shuffle(idxs)[:sample_num]
rinput = tf.gather(EMBEDDINGS, ridxs)
descriptions = wine_data.iloc[ridxs,:]
descriptions = descriptions.loc[:,['country', 'designation', 'province', 'title',
'variety', 'winery', 'price', 'points']]
# T-SNE
X_embedded = TSNE(n_components=2).fit_transform(rinput)
import plotly.express as px
fig = px.scatter(pd.DataFrame({'x': X_embedded[:,0],
'y': X_embedded[:,1],
'country':descriptions['country'],
'name':descriptions['title'],
'variety':descriptions['variety'],
'designation':descriptions['designation']
}).dropna(),
x='x', y='y', color='country', hover_name="name",
hover_data=["variety", "name"],
width=700, height=400
)
fig.show()
Observamos en la proyección en 2 dimensiones usando TSNE cómo los vinos tienden a agruparse por país, con algunos descubrimientos interesantes como las agrupaciones de tintos españoles de Rioja y Toro con tintos de Argentina y Chile, igualemente intensos o la cercanía entre los vinos portugueses de Dão y franceses.
Para poder sacar partido a toda la información disponible, sería interesante tanto disponibilizar un motor de búsqueda más refinado y que permita a los usuarios introducir filtros complejos, como visualizar el grafo de similaridad y poder colorearlo por regiones, variedades de uva, etc.
Adicionalmente, podríamos pensar en un enfoque alternativo a T-SNE, construyendo un grafo a partir de la matriz de pesos de las similitudes. Esto nos permitiría no solo visualizar los distintos vinos sino también llevar a cabo un análisis de comunidades para detectar grupos de vinos cercanos, lo cual sería un ejercicio interesante para por ejemplo catas a ciegas.
from sinfo import sinfo
sinfo()
Daniel Cer, Yinfei Yang, Sheng-yi Kong, Nan Hua, Nicole Limtiaco, Rhomni St. John, Noah Constant, Mario Guajardo-Céspedes, Steve Yuan, Chris Tar, Yun-Hsuan Sung, Brian Strope, Ray Kurzweil.Universal Sentence Encoder
Wine reviews dataset
¿Cómo citar este documento?
Sistema de recomendación de vinos con transfer learning y búsqueda semántica by Francisco Espiga, available under a CC BY-NC-SA 4.0 at https://www.cienciadedatos.net/documentos/py33-recomendaciones-busqueda-semantica.html
¿Te ha gustado el artículo? Tu ayuda es importante
Mantener un sitio web tiene unos costes elevados, tu contribución me ayudará a seguir generando contenido divulgativo gratuito. ¡Muchísimas gracias! 😊
This work by Francisco Espiga is licensed under a Creative Commons Attribution 4.0 International License.