last files for commit
Browse files- app.py +82 -0
- ranker.py +122 -0
- requirements.txt +4 -0
app.py
ADDED
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import streamlit as st
|
2 |
+
from ranker import SparseTfIdfRanker, BertRanker, SparseDenseMoviesRanker
|
3 |
+
import pandas as pd
|
4 |
+
import torch
|
5 |
+
from utils import get_image_from_url
|
6 |
+
|
7 |
+
st.set_page_config(layout="wide")
|
8 |
+
|
9 |
+
@st.cache(allow_output_mutation = True)
|
10 |
+
def get_ranker(modele):
|
11 |
+
df = pd.read_pickle('films.pkl')
|
12 |
+
|
13 |
+
if modele=="sparse-dense":
|
14 |
+
modelpath = "sentence-transformers/multi-qa-MiniLM-L6-cos-v1"
|
15 |
+
bert_index = torch.load('multi-qa-MiniLM-L6-cos-v1_embeddings.pth')
|
16 |
+
sparse_index = torch.load('sparsefake.pth')
|
17 |
+
ranker = SparseDenseMoviesRanker(df, modelpath=modelpath, bert_index = bert_index, sparse_index = sparse_index, vectorizer_path='my_v.pkl')
|
18 |
+
elif modele=="sparse":
|
19 |
+
sparse_index = torch.load('sparsefake.pth')
|
20 |
+
ranker = SparseTfIdfRanker(df, vectorizer_path = 'my_v.pkl', index_matrix = sparse_index)
|
21 |
+
elif modele=="dense":
|
22 |
+
modelpath = "sentence-transformers/multi-qa-MiniLM-L6-cos-v1"
|
23 |
+
bert_index = torch.load('multi-qa-MiniLM-L6-cos-v1_embeddings.pth')
|
24 |
+
ranker = BertRanker(df, index_matrix = bert_index, modelpath=modelpath)
|
25 |
+
else:
|
26 |
+
return NotImplementedError
|
27 |
+
return ranker
|
28 |
+
|
29 |
+
|
30 |
+
def main():
|
31 |
+
|
32 |
+
st.title("Canap' _is all you need_ 🎥 ")
|
33 |
+
st.header("Prototype simple de recommandation de contenus basée sur des description de films")
|
34 |
+
st.write("Jeunes parents, je m'adresse en particulier à vous. Pendant que vos jeunes chérubins 👶 tentent de trouver le sommeil, la plupart de vos soirées se terminent dans les tréfonds d'un canapé, emportés par algorithme de recommandation de contenu qui se base sur moult critères. D'un autre côté, les choix proposés par les sites francophones vous font rentrer dans un vortex sans fin qui allégera vos espoirs d'une soirée cinéma réussie.")
|
35 |
+
st.write("Je vous propose, avec cette maquette, de comprendre un tout petit peu ce qui se passe derrière le capot.")
|
36 |
+
st.write("⚠️ Il s'agit bien évidemment d'un prototype; je rejette toute responsabilité quant au potentiel ratage d'un dîner pour cause de promesse cinématographique non tenue ⚠️ . Après tout, je ne suis qu'un ingénieur. Si j'étais critique ciné, je ne connaîtrais probablement pas [Hugging Face](https://huggingface.co/) 🤗, ce qui serait bien dommage - et je connais déjà Martin Scorsese, ce qui suffit amplement à me pavaner en soirée (même si, en tant que jeune parent, je n'ai plus de soirée, souvenez-vous).")
|
37 |
+
st.subheader("Principe et objectifs")
|
38 |
+
st.write("On propose ici de comparer trois modèles de recommandation sur une base de films.")
|
39 |
+
st.write("Plus sérieusement, l'objectif est ici triple")
|
40 |
+
st.write("* Montrer que l'on peut rapidement prototyper un modèle de recommandation sur base de contenus")
|
41 |
+
st.write("* Prouver qu'il est également possible de le faire en langue française. Et, plus généralement, encourager les avancées remarquables du NLP francophone")
|
42 |
+
st.write("* Avec la mise à disposition des modèles pré-entraînés, la période actuelle de l'intelligence artificielle est similaire à l'avènement de l'open source. Il s'agit juste de dépasser rapidement la phase de prototypage !")
|
43 |
+
st.write("Pour les curieux, je suis en train d'élaborer [un post explicatif, plus technique](https://mnemlaghi.github.io/simsearch)")
|
44 |
+
|
45 |
+
st.subheader("La donnée")
|
46 |
+
st.write("La donnée indexée est issue de la collecte de descriptifs d'environ 8000 films. Les métadonnées telles que l'image du film sont récupérées en temps réel lors de la requête")
|
47 |
+
|
48 |
+
st.subheader("Expérimentons !")
|
49 |
+
modele = st.selectbox("Choisissez votre modèle de recommandation", ['sparse-dense', 'sparse', 'dense'])
|
50 |
+
ranker = get_ranker(modele)
|
51 |
+
query = st.text_input("Quelle histoire voulez-vous regarder ce soir ?", "Une histoire de pirates à la recherche d'un trésor")
|
52 |
+
topn = st.number_input("Combien de films souhaitez-vous afficher ?", min_value = 1, max_value = 20, value = 5)
|
53 |
+
|
54 |
+
|
55 |
+
with st.spinner("Canap' vous recherche des films appropriés sur une base de plus de 8000 films..."):
|
56 |
+
if modele=='sparse-dense':
|
57 |
+
user_firstranking = st.number_input("Filtre isssu du premier ranking (vous pouvez le laisser par défaut à 100) ?", min_value = 10, max_value = 1000, value=1000)
|
58 |
+
df = ranker.run_query(query, topn, first_ranking=user_firstranking)
|
59 |
+
else:
|
60 |
+
df = ranker.run_query(query, topn)
|
61 |
+
|
62 |
+
score_key = 'tfidf-score' if modele=='sparse' else 'bert-score'
|
63 |
+
if st.button("Qu'est-ce qu'on regarde, ce soir ?"):
|
64 |
+
for i,v in df.iterrows():
|
65 |
+
url, desc, score, title = v['url'], v['desc'], v[score_key], v['title']
|
66 |
+
st.header(title)
|
67 |
+
col1, col2, col3 = st.columns([3,5,2])
|
68 |
+
with col1:
|
69 |
+
st.image(get_image_from_url(url))
|
70 |
+
st.write(f"[Lien]({url})")
|
71 |
+
|
72 |
+
with col2:
|
73 |
+
desc_low = desc[:50]
|
74 |
+
with st.expander(desc_low+'...'):
|
75 |
+
st.write(desc)
|
76 |
+
|
77 |
+
with col3:
|
78 |
+
percent = str(round(score*100, 1)) +"%"
|
79 |
+
st.metric('similarité', percent)
|
80 |
+
|
81 |
+
if __name__=='__main__':
|
82 |
+
main()
|
ranker.py
ADDED
@@ -0,0 +1,122 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from transformers import AutoModel, AutoTokenizer
|
2 |
+
import pandas as pd
|
3 |
+
import torch
|
4 |
+
from torch.utils.data import Dataset
|
5 |
+
import logging
|
6 |
+
from tqdm import tqdm
|
7 |
+
from torch.utils.data import DataLoader
|
8 |
+
import torch.nn.functional as F
|
9 |
+
import pickle
|
10 |
+
import string
|
11 |
+
from abc import abstractmethod
|
12 |
+
import json
|
13 |
+
|
14 |
+
|
15 |
+
class AbstractMoviesRanker:
|
16 |
+
"""Abstract class for ranking items"""
|
17 |
+
def __init__(self, df, index_matrix, score_name = "score"):
|
18 |
+
self.df = df
|
19 |
+
self.ids = self.df.index.values
|
20 |
+
self.index_matrix = index_matrix
|
21 |
+
self.score_name = score_name
|
22 |
+
|
23 |
+
@abstractmethod
|
24 |
+
def encode_query(self, query):
|
25 |
+
pass
|
26 |
+
|
27 |
+
def get_scores(self, encoded_query):
|
28 |
+
return torch.mm(encoded_query, self.index_matrix.transpose(0,1))[0].tolist()
|
29 |
+
|
30 |
+
def get_top_ids(self, scores, topn=6):
|
31 |
+
ids_scores_pairs = list(zip(self.ids.tolist(), scores))
|
32 |
+
ids_scores_pairs = sorted(ids_scores_pairs, key = lambda x:x[1], reverse = True)
|
33 |
+
sorted_ids = [v[0] for v in ids_scores_pairs]
|
34 |
+
sorted_scores = [v[1] for v in ids_scores_pairs]
|
35 |
+
sorted_df = self.df.loc[sorted_ids[:topn], :]
|
36 |
+
sorted_df.loc[:,self.score_name] = sorted_scores[:topn]
|
37 |
+
return sorted_df
|
38 |
+
|
39 |
+
def run_query(self, query, topn=6):
|
40 |
+
encoded_query = self.encode_query(query)
|
41 |
+
scores = self.get_scores(encoded_query)
|
42 |
+
return self.get_top_ids(scores, topn)
|
43 |
+
|
44 |
+
depunctuate = staticmethod(lambda x: x.translate(str.maketrans('','',string.punctuation)))
|
45 |
+
|
46 |
+
class SparseTfIdfRanker(AbstractMoviesRanker):
|
47 |
+
"""Sparse Ranking via TF iDF"""
|
48 |
+
def __init__(self, df, index_matrix, vectorizer_path):
|
49 |
+
super(SparseTfIdfRanker, self).__init__(df, index_matrix, score_name = 'tfidf-score')
|
50 |
+
self.vectorizer = pickle.load(open(vectorizer_path, 'rb'))
|
51 |
+
self.index_matrix = self.index_matrix.to_dense() ##For dot products
|
52 |
+
|
53 |
+
def encode_query(self, query):
|
54 |
+
encoded_query = torch.tensor(self.vectorizer.transform([self.depunctuate(query)]).todense(), dtype = torch.float32)
|
55 |
+
return F.normalize(encoded_query, p=2)
|
56 |
+
|
57 |
+
|
58 |
+
class BertRanker(AbstractMoviesRanker):
|
59 |
+
"""Dense Ranking with embedding matrix"""
|
60 |
+
def __init__(self, df, index_matrix, modelpath):
|
61 |
+
super(BertRanker, self).__init__(df, index_matrix, score_name = "bert-score")
|
62 |
+
self.tokenizer = AutoTokenizer.from_pretrained(modelpath)
|
63 |
+
self.model = AutoModel.from_pretrained(modelpath)
|
64 |
+
|
65 |
+
def encode_query(self, query):
|
66 |
+
tok_q = self.tokenizer(query, return_tensors="pt", padding="max_length", max_length = 128, truncation=True)
|
67 |
+
o = self.model(**tok_q)
|
68 |
+
encoded_query = self.mean_pooling(o, tok_q['attention_mask'])
|
69 |
+
return F.normalize(encoded_query, p=2)
|
70 |
+
|
71 |
+
@staticmethod
|
72 |
+
def mean_pooling(model_output, attention_mask):
|
73 |
+
token_embeddings = model_output.last_hidden_state #First element of model_output contains all token embeddings
|
74 |
+
input_mask_expanded = attention_mask.unsqueeze(-1).expand(token_embeddings.size()).float()
|
75 |
+
return torch.sum(token_embeddings * input_mask_expanded, 1) / torch.clamp(input_mask_expanded.sum(1), min=1e-9)
|
76 |
+
|
77 |
+
|
78 |
+
|
79 |
+
class SparseDenseMoviesRanker():
|
80 |
+
"""Sparse Ranking via TF iDF, filtering a first rank, then dense ranking on these items"""
|
81 |
+
def __init__(self, df, modelpath, bert_index, sparse_index, vectorizer_path):
|
82 |
+
self.df =df
|
83 |
+
self.ids = self.df.index.values
|
84 |
+
self.tfidf_engine = SparseTfIdfRanker(df, sparse_index, vectorizer_path)
|
85 |
+
self.modelpath = modelpath
|
86 |
+
self.bert_index = bert_index
|
87 |
+
|
88 |
+
def run_query(self, query, topn=6, first_ranking=1000):
|
89 |
+
tfidf_sorted_frame = self.tfidf_engine.run_query(query, topn=first_ranking)
|
90 |
+
firstranking_index = self.bert_index[tfidf_sorted_frame.index.values]
|
91 |
+
self.bert_engine = BertRanker(tfidf_sorted_frame, firstranking_index, self.modelpath)
|
92 |
+
bert_sorted_frame = self.bert_engine.run_query(query, topn=topn)
|
93 |
+
return bert_sorted_frame
|
94 |
+
|
95 |
+
@classmethod
|
96 |
+
def from_json_config(cls, jsonfile):
|
97 |
+
with open(jsonfile) as fp:
|
98 |
+
conf = json.loads(fp.read())
|
99 |
+
|
100 |
+
##Load data for ranking
|
101 |
+
df = pd.read_pickle(conf['dataframe'])
|
102 |
+
|
103 |
+
##Load indices, e.g. embeddings and encoding utilities
|
104 |
+
bert_index = torch.load(conf['bert_index'])
|
105 |
+
sparse_index = torch.load(conf['sparse_index'])
|
106 |
+
vectorizer_path = conf['vectorizer_path']
|
107 |
+
modelpath = conf['modelpath']
|
108 |
+
|
109 |
+
##Conf for first ranking
|
110 |
+
firstranking = conf.get('firstranking', 100)
|
111 |
+
ranker = cls(df, modelpath, bert_index, sparse_index, vectorizer_path)
|
112 |
+
return ranker
|
113 |
+
|
114 |
+
|
115 |
+
if __name__=='__main__':
|
116 |
+
|
117 |
+
engine = SparseDenseMoviesRanker.from_json_config('conf.json')
|
118 |
+
|
119 |
+
for query in ["une histoire de pirates et de chasse au trésor", "une histoire de gangsters avec de l'argent"]:
|
120 |
+
print(query)
|
121 |
+
final_df = engine.run_query(query)
|
122 |
+
print(final_df.head())
|
requirements.txt
ADDED
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
1 |
+
torch
|
2 |
+
scikit-learn
|
3 |
+
pandas
|
4 |
+
transformers
|