Conjunto de datos
Por supuesto, lo primero que necesitaba era un conjunto de datos de letras de canciones. ¡Afortunadamente, encontré uno en Kaggle! Este conjunto de datos está bajo una licencia Creative Commons (CC0: dominio público).
Este conjunto de datos contiene aproximadamente 60K letras de las canciones junto con el título y el nombre del artista. Sé 60K Puede que no cubra todas las canciones que te gustan, pero creo que es un buen punto de partida para LyRec.
songs_df = pd.read_csv(f"{root_dir}/spotify_millsongdata.csv")
songs_df = songs_df.drop(columns=["link"])
songs_df["song_id"] = songs_df.index + 1
No necesité realizar ningún procesamiento previo de estos datos. acabo de quitar el enlace columna y agregó un IDENTIFICACIÓN para cada canción.
Modelos
Necesitaba seleccionar dos LLM: uno para calcular las incrustaciones y otro para generar los resúmenes de las canciones. ¡Elegir el LLM correcto para su tarea puede ser un poco complicado debido a la gran cantidad de ellos! Es una buena idea mirar la tabla de clasificación para encontrar los mejores actuales. Para el modelo de integración, verifiqué la tabla de clasificación MTEB alojada en HuggingFace.
Estaba buscando un modelo más pequeño (¡obviamente!) sin comprometer demasiada precisión; por lo tanto, decidí GTE-Qwen2-1.5B-Instrucción.
from sentence_transformers import SentenceTransformer
import torchmodel = SentenceTransformer(
"Alibaba-NLP/gte-Qwen2-1.5B-instruct",
model_kwargs={"torch_dtype": torch.float16}
)
Para el resumidor, solo necesitaba una instrucción lo suficientemente pequeña después de LLM, así que elegí Gemma-2–2b-Eso. En mi experiencia, es uno de los mejores modelos pequeños hasta el momento.
import torch
from transformers import pipelinepipe = pipeline(
"text-generation",
model="google/gemma-2-2b-it",
model_kwargs={"torch_dtype": torch.bfloat16},
device="cuda",
)
Precalcular las incrustaciones
Calcular las incrustaciones de letras fue bastante sencillo. Sólo usé el .codificar(…) método con un tamaño_lote de 32 para un procesamiento más rápido.
song_lyrics = songs_df["text"].valueslyrics_embeddings = model.encode(
song_lyrics,
batch_size=32,
show_progress_bar=True
)
np.save(f"{root_dir}/60k_song_lyrics_embeddings.npy", lyrics_embeddings)
En este punto, almacené estas incrustaciones en un .npy archivo. Podría haber usado un formato más estructurado, pero funcionó para mí.
En cuanto a las incrustaciones de resúmenes, primero necesitaba generar los resúmenes. Tenía que asegurarme de que el resumen capturara la emoción y el tema de la canción sin ser demasiado extenso. Entonces, se me ocurrió el siguiente mensaje para Gemma-2.
You are an expert song summarizer. \
You will be given the full lyrics to a song. \
Your task is to write a concise, cohesive summary that \
captures the central emotion, overarching theme, and \
narrative arc of the song in 150 words.{song lyrics}
Aquí está el fragmento de código para la generación de resúmenes. Para simplificar, a continuación se muestra un procesamiento secuencial. He incluido la versión de procesamiento por lotes en el repositorio de GitHub.
def get_summary(song_lyrics):
messages = [
{"role": "user",
"content": f'''You are an expert song summarizer. \
You will be given the full lyrics to a song. \
Your task is to write a concise, cohesive summary that \
captures the central emotion, overarching theme, and \
narrative arc of the song in 150 words.\n\n{song_lyrics}'''},
]outputs = pipe(messages, max_new_tokens=256)
assistant_response = outputs[0]["generated_text"][-1]["content"].strip()
return assistant_response
songs_df["summary"] = songs_df["text"].progress_apply(get_description)
Como era de esperar, este paso fue el que llevó más tiempo. Por suerte, esto sólo hay que hacerlo una vez, y por supuesto, cuando queramos actualizar la base de datos con nuevas canciones.
Luego, calculé y almacené la incrustación como la última vez.
song_summary = songs_df["summary"].valuessummary_embeddings = model.encode(
song_summary,
batch_size=32,
show_progress_bar=True
)
np.save(f"{root_dir}/60k_song_summary_embeddings.npy", summary_embeddings)
Búsqueda de vectores
Una vez implementadas las incrustaciones, llegó el momento de implementar la búsqueda semántica basada en la similitud de incrustaciones. Hay muchas bases de datos vectoriales de código abierto increíbles disponibles para este trabajo. Decidí usar uno simple llamado FAISS (Búsqueda de similitudes de IA en Facebook). Solo se necesitan dos líneas para agregar las incrustaciones a la base de datos. Primero, creamos un índice FAISS. Aquí, debemos mencionar la métrica de similitud que desea utilizar para la búsqueda y la dimensión de los vectores. utilicé el producto escalar (producto interior) como medida de similitud. Luego, agregamos las incrustaciones al índice.
Nota: Nuestra base de datos es lo suficientemente pequeña como para realizar una búsqueda exhaustiva utilizando el producto escalar. Para bases de datos más grandes, se recomienda realizar una búsqueda aproximada del vecino más cercano (ANN). FAISS tiene apoyo para eso.
import faisslyrics_embeddings = np.load(f"{root_dir}/60k_song_lyrics_embeddings.npy")
lyrics_index = faiss.IndexFlatIP(lyrics_embeddings.shape[1])
lyrics_index.add(lyrics_embeddings.astype(np.float32))
summary_embeddings = np.load(f"{root_dir}/60k_song_summary_embeddings.npy")
summary_index = faiss.IndexFlatIP(summary_embeddings.shape[1])
summary_index.add(summary_embeddings.astype(np.float32))
Para encontrar las canciones más similares dada una consulta, primero debemos generar la incrustación de la consulta y luego llamar al .buscar(…) método en el índice. En esencia, este método calcula la similitud entre la consulta y cada entrada en nuestra base de datos y devuelve la parte superior. k inscripciones y las puntuaciones correspondientes. Aquí está el código que realiza una búsqueda semántica de incrustaciones de letras.
query_lyrics = 'Imagine the last song you fell in love with'
query_embedding = model.encode(f'''Instruct: Given the lyrics, \
retrieve relevant songs\nQuery: {query_lyrics}''')
query_embedding = query_embedding.reshape(1, -1).astype(np.float32)
lyrics_scores, lyrics_ids = lyrics_index.search(query_embedding, 10)
Observe que agregué un mensaje simple en la consulta. Esto es recomendado para este modelo. Lo mismo se aplica a las incrustaciones de resumen.
query_description = 'Describe the type of song you wanna listen to'
query_embedding = model.encode(f'''Given a description, \
retrieve relevant songs\nQuery: {query_description}''')
query_embedding = query_embedding.reshape(1, -1).astype(np.float32)
summary_scores, summary_ids = summary_index.search(query_embedding, k)
Consejo profesional: ¿Cómo se hace una verificación de cordura?
¡Simplemente coloque cualquier entrada de la base de datos en la consulta y vea si la búsqueda arroja lo mismo que la entrada con mayor puntuación!
Implementación de las características
En esta etapa, tenía los componentes básicos de LyRec. Ahora era el momento de juntarlos. ¿Recuerdas los tres objetivos que me propuse al principio? Así es como los implementé.
Para mantener las cosas ordenadas, creé una clase llamada LyRec eso tendría un método para cada característica. Las dos primeras características son bastante sencillas de implementar.
El método.obtener_canciones_con_letras_similares(…) toma una canción (letra) y un número entero k como entrada y devuelve una lista de k Canciones más similares basadas en la similitud de las letras. Cada elemento de la lista es un diccionario que contiene el nombre del artista, el título de la canción y la letra.
Similarmente, .get_songs_with_similar_description(…) toma un texto de forma libre y un número entero k como entrada y devuelve una lista de k Canciones más similares según la descripción.
Aquí está el fragmento de código relevante.
class LyRec:
def __init__(self, songs_df, lyrics_index, summary_index, embedding_model):
self.songs_df = songs_df
self.lyrics_index = lyrics_index
self.summary_index = summary_index
self.embedding_model = embedding_modeldef get_records_from_id(self, song_ids):
songs = []
for _id in song_ids:
songs.extend(self.songs_df[self.songs_df["song_id"]==_id+1].to_dict(orient='records'))
return songs
def get_songs_with_similar_lyrics(self, query_lyrics, k=10):
query_embedding = self.embedding_model.encode(
f"Instruct: Given the lyrics, retrieve relevant songs\n Query: {query_lyrics}"
).reshape(1, -1).astype(np.float32)
scores, song_ids = self.lyrics_index.search(query_embedding, k)
return self.get_records_from_id(song_ids[0])
def get_songs_with_similar_description(self, query_description, k=10):
query_embedding = self.embedding_model.encode(
f"Instruct: Given a description, retrieve relevant songs\n Query: {query_description}"
).reshape(1, -1).astype(np.float32)
scores, song_ids = self.summary_index.search(query_embedding, k)
return self.get_records_from_id(song_ids[0])
La característica final fue un poco complicada de implementar. Recuerde que primero debemos recuperar las canciones principales según la letra y luego volver a clasificarlas según la descripción textual. La primera recuperación fue fácil. Para el segundo, sólo hay que considerar las canciones con mayor puntuación. Decidí crear un índice FAISS temporal con las mejores canciones y luego buscar las canciones con las puntuaciones de similitud resumidas más altas. Aquí está mi implementación.
def get_songs_with_similar_lyrics_and_description(self, query_lyrics, query_description, k=10):
query_lyrics_embedding = self.embedding_model.encode(
f"Instruct: Given the lyrics, retrieve relevant songs\n Query: {query_lyrics}"
).reshape(1, -1).astype(np.float32)scores, song_ids = self.lyrics_index.search(query_lyrics_embedding, 500)
top_k_indices = song_ids[0]
summary_candidates = []
for idx in top_k_indices:
emb = self.summary_index.reconstruct(int(idx))
summary_candidates.append(emb)
summary_candidates = np.array(summary_candidates, dtype=np.float32)
temp_index = faiss.IndexFlatIP(summary_candidates.shape[1])
temp_index.add(summary_candidates)
query_description_embedding = self.embedding_model.encode(
f"Instruct: Given a description, retrieve relevant songs\n Query: {query_description}"
).reshape(1, -1).astype(np.float32)
scores, temp_ids = temp_index.search(query_description_embedding, k)
final_song_ids = [top_k_indices[i] for i in temp_ids[0]]
return self.get_records_from_id(final_song_ids)
¡Voilá! Finalmente, LyRec está listo. Puede encontrar la implementación completa en este repositorio. ¡Deje una estrella si esto le resulta útil! 😃