ozzono commited on
Commit
d0fcd17
·
verified ·
1 Parent(s): b629323

Upload backend/embeddings.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. backend/embeddings.py +340 -0
backend/embeddings.py ADDED
@@ -0,0 +1,340 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Word Embeddings Engine for Contexto game.
3
+ Handles cosine similarity calculation and ranking generation.
4
+ """
5
+
6
+ import os
7
+ import numpy as np
8
+ import unicodedata
9
+ import re
10
+ from gensim.models import KeyedVectors
11
+
12
+ MODEL_DIR = os.path.dirname(os.path.abspath(__file__))
13
+ MODEL_FILENAME = os.environ.get("PONELE_MODEL_FILE", "es_bert_vectors_50k.kv")
14
+ MODEL_PATH = os.path.join(MODEL_DIR, MODEL_FILENAME)
15
+
16
+
17
+ def normalize_word(word):
18
+ """
19
+ Normalize a word for lookup: lowercase, remove accents/tildes, strip whitespace.
20
+ Example: 'CAFÉ' -> 'cafe', 'Niño' -> 'nino'
21
+ """
22
+ word = word.strip().lower()
23
+ # Remove accents/diacritics
24
+ nfkd = unicodedata.normalize('NFKD', word)
25
+ word_clean = ''.join(c for c in nfkd if not unicodedata.combining(c))
26
+ # Replace ñ -> n is handled by NFKD? No, ñ is not a combining char.
27
+ # Keep ñ as-is since it's in the model vocabulary
28
+ # Actually, let's try both versions
29
+ return word_clean
30
+
31
+
32
+ nlp_model = None
33
+
34
+ def get_nlp():
35
+ global nlp_model
36
+ if nlp_model is None:
37
+ import spacy
38
+ nlp_model = spacy.load("es_core_news_sm", disable=["parser", "ner"])
39
+ return nlp_model
40
+
41
+ def normalize_for_lookup(word):
42
+ """
43
+ Try multiple normalizations to find the word in the model.
44
+ Returns a list of candidates to try (original, lemma, no_accents).
45
+ """
46
+ original = word.strip().lower()
47
+ candidates = [original]
48
+
49
+ # Get lemma using SpaCy
50
+ try:
51
+ doc = get_nlp()(original)
52
+ if len(doc) > 0:
53
+ lemma = doc[0].lemma_.lower()
54
+
55
+ # IMPROVEMENT: Manual bridge for -aría/-arían which SpaCy misses
56
+ if original.endswith(('aria', 'arian', 'ariais', 'ariamos', 'arias')):
57
+ # Try to see if stripping the suffix and adding 'ar', 'er', or 'ir' works
58
+ # For now, let's just make sure both 'llegar' and 'llegaría' are tried
59
+ pass
60
+
61
+ if lemma not in candidates:
62
+ candidates.append(lemma)
63
+
64
+ # Force infinitive if it ends in 'aría' (simple heuristic for this specific bug)
65
+ if original.endswith('aria') and len(original) > 4:
66
+ # We try common infinitive endings
67
+ for suffix in ['ar', 'er', 'ir']:
68
+ verb_candidate = original[:-4] + suffix
69
+ if verb_candidate not in candidates:
70
+ candidates.append(verb_candidate)
71
+ except:
72
+ pass
73
+
74
+ # Version without accents
75
+ nfkd = unicodedata.normalize('NFKD', original)
76
+ no_accents = ''.join(c for c in nfkd if not unicodedata.combining(c))
77
+ if no_accents not in candidates:
78
+ candidates.append(no_accents)
79
+
80
+ # Also try removing accents from the lemma
81
+ for cand in list(candidates):
82
+ nfkd_cand = unicodedata.normalize('NFKD', cand)
83
+ clean_cand = ''.join(c for c in nfkd_cand if not unicodedata.combining(c))
84
+ if clean_cand not in candidates:
85
+ candidates.append(clean_cand)
86
+
87
+ if no_accents not in candidates:
88
+ candidates.append(no_accents)
89
+
90
+ return candidates
91
+
92
+
93
+ class EmbeddingsEngine:
94
+ """
95
+ Core engine that loads word vectors and computes rankings
96
+ based on cosine similarity.
97
+ """
98
+
99
+ def __init__(self):
100
+ self.model = None
101
+ self._rankings_cache = {} # Cache: secret_word -> {word: rank}
102
+
103
+ def load_model(self):
104
+ """Load the pre-trained word vectors from disk."""
105
+ if not os.path.exists(MODEL_PATH):
106
+ raise FileNotFoundError(
107
+ f"Model not found at {MODEL_PATH}. "
108
+ "Run 'python build_bert_model.py' first."
109
+ )
110
+
111
+ print("🧠 Loading word embeddings model...")
112
+ self.model = KeyedVectors.load(MODEL_PATH)
113
+ print(f"✅ Model loaded! Vocabulary: {len(self.model.key_to_index)} words")
114
+
115
+ def get_vector(self, word):
116
+ """
117
+ Get the embedding vector for a word.
118
+ Tries multiple normalizations to find a match.
119
+ Returns (vector, found_word) or (None, None) if not found.
120
+ """
121
+ candidates = normalize_for_lookup(word)
122
+
123
+ for candidate in candidates:
124
+ if candidate in self.model.key_to_index:
125
+ return self.model[candidate], candidate
126
+
127
+ return None, None
128
+
129
+ def cosine_similarity(self, vec_a, vec_b):
130
+ """
131
+ Compute cosine similarity between two vectors.
132
+
133
+ Formula: sim(A, B) = (A · B) / (||A|| * ||B||)
134
+
135
+ Returns a value between -1 and 1, where:
136
+ 1 = identical direction
137
+ 0 = orthogonal (unrelated)
138
+ -1 = opposite direction
139
+ """
140
+ dot_product = np.dot(vec_a, vec_b)
141
+ norm_a = np.linalg.norm(vec_a)
142
+ norm_b = np.linalg.norm(vec_b)
143
+
144
+ if norm_a == 0 or norm_b == 0:
145
+ return 0.0
146
+
147
+ return float(dot_product / (norm_a * norm_b))
148
+
149
+ def compute_ranking(self, secret_word):
150
+ """
151
+ Compute the full ranking of all vocabulary words against the secret word.
152
+ The secret word always gets rank #1.
153
+
154
+ Returns a dict of {normalized_word: rank} sorted by cosine similarity (descending).
155
+ Results are cached per secret word.
156
+ """
157
+ cache_key = secret_word.lower()
158
+
159
+ if cache_key in self._rankings_cache:
160
+ print(f"📋 Using cached ranking for '{secret_word}'")
161
+ return self._rankings_cache[cache_key]
162
+
163
+ print(f"🔄 Computing ranking for secret word: '{secret_word}'...")
164
+
165
+ # Get the secret word's vector
166
+ secret_vec, secret_found = self.get_vector(secret_word)
167
+ if secret_vec is None:
168
+ raise ValueError(f"Secret word '{secret_word}' not found in vocabulary")
169
+
170
+ # Compute similarity for ALL words in vocabulary
171
+ vocab_words = list(self.model.key_to_index.keys())
172
+
173
+ # Batch computation using gensim's built-in method (much faster)
174
+ similarities = self.model.most_similar(positive=[secret_found], topn=len(vocab_words))
175
+
176
+ # Build ranking dict: rank 1 is the secret word itself
177
+ ranking = {secret_found: 1}
178
+ for rank, (word, _sim) in enumerate(similarities, start=2):
179
+ ranking[word] = rank
180
+
181
+ self._rankings_cache[cache_key] = ranking
182
+ print(f"✅ Ranking computed: {len(ranking)} words ranked")
183
+
184
+ return ranking
185
+
186
+ def get_rank_for_word(self, guess_word, secret_word):
187
+ """
188
+ Get the rank of a guessed word relative to the secret word.
189
+
190
+ Returns:
191
+ dict with 'rank', 'similarity', 'found', and 'normalized_word'
192
+ """
193
+ # Normalize the guess
194
+ guess_candidates = normalize_for_lookup(guess_word)
195
+ secret_candidates = normalize_for_lookup(secret_word)
196
+
197
+ # Check if it's the same word (exact match)
198
+ guess_norm = guess_candidates[0]
199
+ secret_norm = secret_candidates[0]
200
+
201
+ if guess_norm == secret_norm:
202
+ return {
203
+ "rank": 1,
204
+ "similarity": 1.0,
205
+ "found": True,
206
+ "normalized_word": guess_norm,
207
+ "is_correct": True
208
+ }
209
+
210
+ # Make sure both words exist in the model
211
+ guess_vec, guess_found = self.get_vector(guess_word)
212
+ secret_vec, secret_found = self.get_vector(secret_word)
213
+
214
+ if guess_vec is None:
215
+ return {
216
+ "rank": -1,
217
+ "similarity": 0.0,
218
+ "found": False,
219
+ "normalized_word": guess_norm,
220
+ "is_correct": False,
221
+ "error": f"Word '{guess_word}' not found in vocabulary"
222
+ }
223
+
224
+ if secret_vec is None:
225
+ return {
226
+ "rank": -1,
227
+ "similarity": 0.0,
228
+ "found": False,
229
+ "normalized_word": secret_norm,
230
+ "is_correct": False,
231
+ "error": f"Secret word '{secret_word}' not found in vocabulary"
232
+ }
233
+
234
+ # Get the precomputed ranking
235
+ ranking = self.compute_ranking(secret_word)
236
+
237
+ # Look up the guess word in the ranking
238
+ rank = None
239
+ for candidate in guess_candidates:
240
+ if candidate in ranking:
241
+ rank = ranking[candidate]
242
+ break
243
+
244
+ if rank is None:
245
+ # Word exists in model but wasn't in the ranking (shouldn't happen)
246
+ # Fallback: compute similarity directly
247
+ similarity = self.cosine_similarity(guess_vec, secret_vec)
248
+ # Estimate rank based on similarity
249
+ rank = max(1, int((1 - similarity) * len(ranking)))
250
+
251
+ # Determine max rank for normalization
252
+ max_rank = len(ranking) if ranking else 50000
253
+
254
+ # LOG-SCALING NORMALIZATION (Phase 2 Upgrade)
255
+ # We transform the raw cosine similarity into a 'game feel' similarity.
256
+ # This stretches the top 1500 ranks into the 50%-100% range to avoid the "dense blob" effect of BERT.
257
+ import math
258
+ if rank == 1:
259
+ stretched_sim = 1.0
260
+ else:
261
+ # Power law decay to make top ranks "pop"
262
+ stretched_sim = max(0.01, 1.0 - math.pow(rank / max_rank, 0.4))
263
+
264
+ # Boost the top 1500 specifically to make the transition satisfying
265
+ if rank <= 1500:
266
+ stretched_sim = max(stretched_sim, 0.5 + 0.5 * (1 - (rank / 1500)**0.8))
267
+
268
+ return {
269
+ "rank": rank,
270
+ "similarity": round(stretched_sim, 4),
271
+ "found": True,
272
+ "normalized_word": guess_found or guess_norm,
273
+ "is_correct": rank == 1
274
+ }
275
+
276
+ def get_hint(self, secret_word, excluded_words):
277
+ """
278
+ Get a hint word: a word that is semantically close but not yet guessed.
279
+ """
280
+ ranking = self.compute_ranking(secret_word)
281
+
282
+ # Normalize excluded words
283
+ norm_excluded = set()
284
+ for w in excluded_words:
285
+ candidates = normalize_for_lookup(w)
286
+ norm_excluded.update(candidates)
287
+
288
+ # Find the best rank guessed so far (excluding rank 1)
289
+ best_rank = 50000 # Default if no guesses or all very far
290
+ for word in norm_excluded:
291
+ if word in ranking:
292
+ r = ranking[word]
293
+ if r > 1 and r < best_rank:
294
+ best_rank = r
295
+
296
+ # Target rank: half of best rank, but at most 1000 (if best is very far)
297
+ # and at least 2 (can't give the secret word as a hint)
298
+ target_rank = max(2, min(1000, best_rank // 2))
299
+
300
+ # We want a word around the target rank.
301
+ # Since ranking is {word: rank}, we can iterate.
302
+ # But we want it to be efficient.
303
+
304
+ # Items are (word, rank)
305
+ # ranking.items() is already somewhat sorted by rank in compute_ranking?
306
+ # No, it's a dict. But similarities from model.most_similar were sorted.
307
+
308
+ # Find a word with rank close to target_rank
309
+ # For small vocab (50k), we can just iterate.
310
+ sorted_ranking = sorted(ranking.items(), key=lambda x: x[1])
311
+
312
+ for word, rank in sorted_ranking:
313
+ if rank >= target_rank and word not in norm_excluded and rank > 1:
314
+ # Add a bit of randomness so it's not always the same?
315
+ # For now let's just return the first one available after target.
316
+ return {"word": word, "rank": rank}
317
+
318
+ return None
319
+
320
+
321
+ def get_vocabulary_size(self):
322
+ """Return the number of words in the loaded model."""
323
+ if self.model is None:
324
+ return 0
325
+ return len(self.model.key_to_index)
326
+
327
+ def word_exists(self, word):
328
+ """Check if a word exists in the vocabulary."""
329
+ _, found = self.get_vector(word)
330
+ return found is not None
331
+
332
+ def get_all_words(self):
333
+ """
334
+ Return all vocabulary words in frequency order (most common first).
335
+ KeyedVectors stores keys in the order they appear in the original model,
336
+ which for FastText models is typically frequency-sorted.
337
+ """
338
+ if self.model is None:
339
+ return []
340
+ return list(self.model.key_to_index.keys())