From 809823a8ae6d71b505334f45af4a22686ba676cf Mon Sep 17 00:00:00 2001
From: Claude Agent
Date: Tue, 7 Jul 2026 10:10:58 +0000
Subject: [PATCH] perf+calitate sugestii k-NN: matvec numpy, vot top-5 cu prag
calibrat, indicatori import
- embeddings: corpus ca matrice numpy cu norme precalculate; suggest_nearest
= un matvec (~0.6ms/query fata de ~500ms cosine pur-Python la 17k vectori)
- enrich_suggestions: vot ponderat cu similaritatea pe top-5 vecini (NUL =
eticheta proprie); prag 0.5 -> 0.88, calibrat LOO pe corpusul SILVER
(tools/mapare-llm/knn_calibrate.py): precizie 90.5% -> 93.1%, cod gresit
preselectat 7.2% -> 4.7%; sub prag abtinere -> preselectie fuzzy
- UI: codul sugerat de sistem afisat explicit cu sursa si scorul, separat de
lista fuzzy ("potrivire text"); indicator de progres reparat pe upload
(display:inline anula .htmx-indicator) si adaugat pe pasii 2->3 si
"Salveaza maparile"
Co-Authored-By: Claude Fable 5
---
app/embeddings.py | 51 +++++++
app/mapping.py | 52 +++++--
app/web/routes.py | 10 +-
app/web/templates/_mapari.html | 7 +
app/web/templates/_mapcoloane.html | 8 +-
app/web/templates/_preview_import.html | 17 ++-
app/web/templates/_upload.html | 7 +-
app/web/templates/base.html | 5 +
requirements.txt | 2 +
tests/test_enrich_corpus_etichetat.py | 52 +++++++
tests/test_mapare_integrare_l14.py | 5 +-
tests/test_web_preview_paritate_mapari.py | 5 +-
tools/mapare-llm/knn_calibrate.py | 161 ++++++++++++++++++++++
13 files changed, 358 insertions(+), 24 deletions(-)
create mode 100644 tools/mapare-llm/knn_calibrate.py
diff --git a/app/embeddings.py b/app/embeddings.py
index 17e4e0b..0445a8a 100644
--- a/app/embeddings.py
+++ b/app/embeddings.py
@@ -25,6 +25,11 @@ import math
import threading
from typing import Protocol, runtime_checkable
+try:
+ import numpy as _np # garantat de fastembed/onnxruntime; fallback pur-Python daca lipseste
+except ImportError: # pragma: no cover
+ _np = None
+
log = logging.getLogger(__name__)
# Modelul ales: paraphrase-multilingual-MiniLM-L12-v2
@@ -94,6 +99,10 @@ class EmbeddingEngine:
self._corpus_vecs: list[list[float]] = []
self._corpus_items: list[dict] = []
self._corpus_sig: str | None = None
+ # Matrice numpy + norme precalculate: cautarea NN devine un matvec
+ # (~ms) in loc de cosine pur-Python per item (~0.5s la 17k vectori).
+ self._corpus_matrix = None
+ self._corpus_norms = None
def is_available(self) -> bool:
"""True daca backend-ul e disponibil si gata de folosire."""
@@ -128,6 +137,8 @@ class EmbeddingEngine:
self._corpus_vecs = []
self._corpus_items = []
self._corpus_sig = None
+ self._corpus_matrix = None
+ self._corpus_norms = None
if not items or not self.is_available():
return
@@ -147,10 +158,32 @@ class EmbeddingEngine:
self._corpus_vecs = self._backend.embed(texts)
self._corpus_items = list(items)
self._corpus_sig = signature
+ self._build_matrix()
except Exception as exc:
log.warning("embeddings: index_corpus esuat: %s", exc)
# corpus ramane gol -- suggest_nearest va returna []
+ def _build_matrix(self) -> None:
+ """Precalculeaza matricea numpy + normele corpusului pentru cautarea NN.
+
+ Esecul (numpy lipsa, vectori neregulati) lasa matricea None --
+ suggest_nearest cade pe scanarea pur-Python, corpusul ramane valid.
+ """
+ if _np is None:
+ return
+ try:
+ matrix = _np.asarray(self._corpus_vecs, dtype=_np.float32)
+ if matrix.ndim != 2:
+ raise ValueError(f"vectori corpus neregulati (ndim={matrix.ndim})")
+ norms = _np.linalg.norm(matrix, axis=1)
+ norms[norms == 0.0] = 1.0 # vector nul -> similaritate 0, fara div/0
+ self._corpus_matrix = matrix
+ self._corpus_norms = norms
+ except Exception as exc:
+ log.warning("embeddings: matrice numpy esuata, fallback pur-Python: %s", exc)
+ self._corpus_matrix = None
+ self._corpus_norms = None
+
def embed(self, texts: list[str]) -> list[list[float]]:
"""Vectorizeaza texte brute prin backend (folosit la miss-uri de cache).
@@ -179,6 +212,24 @@ class EmbeddingEngine:
try:
query_vecs = self._backend.embed([str(denumire)])
query_vec = query_vecs[0]
+ if self._corpus_matrix is not None:
+ q = _np.asarray(query_vec, dtype=_np.float32)
+ qn = float(_np.linalg.norm(q)) or 1.0
+ sims = (self._corpus_matrix @ q) / (self._corpus_norms * qn)
+ k = min(top_k, sims.shape[0])
+ if k <= 0:
+ return []
+ # argpartition + sort doar pe top_k: O(n) in loc de O(n log n)
+ idx = _np.argpartition(-sims, k - 1)[:k]
+ idx = idx[_np.argsort(-sims[idx])]
+ return [
+ {
+ "cod": self._corpus_items[i]["cod"],
+ "is_nul": bool(self._corpus_items[i].get("is_nul", False)),
+ "similaritate": float(sims[i]),
+ }
+ for i in idx
+ ]
scored = [
{
"cod": item["cod"],
diff --git a/app/mapping.py b/app/mapping.py
index 2f4ffd7..a962b63 100644
--- a/app/mapping.py
+++ b/app/mapping.py
@@ -631,10 +631,17 @@ def delete_text_rule(conn, account_id: int | None, pattern: str) -> None:
)
-# Prag minim de similaritate cosine pentru sugestia din embeddings NN.
-# Sub acest scor, sugestia NN e prea incerta si nu o afisam (previne recomandari
-# irelevante cand corpus-ul e mic sau neindexat corect).
-EMB_MIN_SIMILARITATE = 0.5
+# Prag minim de similaritate cosine pentru un vecin k-NN luat in calcul la vot.
+# Calibrat prin leave-one-out pe corpusul SILVER (tools/mapare-llm/knn_calibrate.py):
+# modelul e anizotrop (perechi nelegate scoreaza 0.79-0.87), deci sub ~0.88 vecinul
+# e zgomot. La 0.88: coverage 83%, precizie 93%; la 0.5 (vechiul prag): coverage
+# 100% dar 7.4% cod gresit preselectat.
+EMB_MIN_SIMILARITATE = 0.88
+
+# Cati vecini intra in votul ponderat cu similaritatea. Vot > top-1: corpusul SILVER
+# are etichete contradictorii pe denumiri aproape identice; votul e imun la ele
+# (+~1pp precizie la acelasi coverage, masurat LOO).
+EMB_VOTE_TOP_K = 5
# Protejeaza secventa hash->load->embed->save->purge->index (embedding_cache) de
# executie concurenta intre warmup-ul de fundal (block=True) si calea de request
@@ -769,7 +776,8 @@ def enrich_suggestions(
Returneaza:
{
'sugestie_principala': {'cod_prestatie': str, 'sursa': str} | None,
- 'surse': {'gold_partajat': str|None, 'silver': str|None, 'embedding': str|None, 'nul': bool}
+ 'surse': {'gold_partajat': str|None, 'silver': str|None, 'embedding': str|None,
+ 'embedding_similaritate': float|None, 'nul': bool}
}
INVARIANTE:
@@ -783,7 +791,10 @@ def enrich_suggestions(
importa normalize_for_match din mapping).
"""
sugestie_principala: dict | None = None
- surse: dict = {"gold_partajat": None, "silver": None, "embedding": None, "nul": False}
+ surse: dict = {
+ "gold_partajat": None, "silver": None,
+ "embedding": None, "embedding_similaritate": None, "nul": False,
+ }
if not denumire:
return {"sugestie_principala": sugestie_principala, "surse": surse}
@@ -829,15 +840,28 @@ def enrich_suggestions(
# Corpusul k-NN e text NORMALIZAT (denumire_normalizata),
# deci query-ul TREBUIE normalizat la fel — altfel cosine degradeaza si
# nu mai e configul sub care s-a masurat 94.3%.
- nn = _emb.suggest_nearest(normalize_for_match(denumire), top_k=1)
- # Prag minim: similaritate prea mica = sugestie inutila.
- # Evita recomandari irelevante cand corpus-ul e mic/partial.
- if nn and nn[0].get("similaritate", 0) >= EMB_MIN_SIMILARITATE:
- if nn[0].get("is_nul"):
- # Vecin NUL (non-operatie) = semnal de SUPRESIE, nu cod.
+ nn = _emb.suggest_nearest(normalize_for_match(denumire), top_k=EMB_VOTE_TOP_K)
+ # Vot ponderat cu similaritatea pe vecinii peste prag; NUL e eticheta
+ # proprie (castiga -> supresie, nu cod). Vecinii sub prag nu voteaza.
+ scoruri: dict[str, float] = {}
+ sim_max: dict[str, float] = {}
+ for v in nn:
+ sim = float(v.get("similaritate", 0))
+ if sim < EMB_MIN_SIMILARITATE:
+ continue
+ lab = "NUL" if v.get("is_nul") else (str(v["cod"]) if v.get("cod") else None)
+ if lab is None:
+ continue
+ scoruri[lab] = scoruri.get(lab, 0.0) + sim
+ sim_max[lab] = max(sim_max.get(lab, 0.0), sim)
+ if scoruri:
+ castigator = max(scoruri, key=lambda k: scoruri[k])
+ if castigator == "NUL":
+ # Vecinatate NUL (non-operatie) = semnal de SUPRESIE, nu cod.
surse["nul"] = True
- elif nn[0].get("cod"):
- surse["embedding"] = str(nn[0]["cod"])
+ else:
+ surse["embedding"] = castigator
+ surse["embedding_similaritate"] = sim_max[castigator]
except Exception:
pass # degradare gratioasa (#16b): motorul absent nu blocheaza
diff --git a/app/web/routes.py b/app/web/routes.py
index e3fa171..b821cf9 100644
--- a/app/web/routes.py
+++ b/app/web/routes.py
@@ -1392,7 +1392,10 @@ def _nemapate_pentru_submission(row, nomenclator: list[dict], conn=None) -> list
"denumire": item.get("denumire"),
"suggestions": suggest_codes(item.get("denumire"), nomenclator, limit=5),
"sugestie_principala": None,
- "surse_sugestie": {"gold_partajat": None, "silver": None, "embedding": None, "nul": False},
+ "surse_sugestie": {
+ "gold_partajat": None, "silver": None,
+ "embedding": None, "embedding_similaritate": None, "nul": False,
+ },
}
# L14-S6: imbogatire cu GOLD partajat > SILVER > embeddings (SUGGESTION-ONLY, #13)
if conn is not None:
@@ -3034,7 +3037,10 @@ def _collect_unmapped_ops(preview_rows: list[dict], nomenclator: list[dict], con
entry["suggestions"] = suggest_codes(entry["denumire"], nomenclator, limit=5)
# Init default pe FIECARE entry -> contract template identic (conn=None inclus).
entry["sugestie_principala"] = None
- entry["surse_sugestie"] = {"gold_partajat": None, "silver": None, "embedding": None, "nul": False}
+ entry["surse_sugestie"] = {
+ "gold_partajat": None, "silver": None,
+ "embedding": None, "embedding_similaritate": None, "nul": False,
+ }
# L14-S6: imbogatire cu GOLD partajat > SILVER > embeddings (SUGGESTION-ONLY, #13)
if conn is not None:
enriched = enrich_suggestions(conn, entry["denumire"])
diff --git a/app/web/templates/_mapari.html b/app/web/templates/_mapari.html
index ff75cc1..54cd9cc 100644
--- a/app/web/templates/_mapari.html
+++ b/app/web/templates/_mapari.html
@@ -62,10 +62,17 @@
{% else %}
similar
{% endif %}
+ {# Codul sugerat de sistem, explicit (nu doar preselectat in dropdown);
+ pentru k-NN si scorul de similaritate. #}
+ {{ e.sugestie_principala.cod_prestatie }}
+ {%- if e.sugestie_principala.sursa == 'embedding' and e.surse_sugestie.embedding_similaritate %}
+ ({{ (e.surse_sugestie.embedding_similaritate * 100)|round|int }}%)
+ {%- endif %}
{% elif e.surse_sugestie and e.surse_sugestie.nul %}
non-operatie
{% endif %}
{% if e.suggestions %}
+ {% if e.sugestie_principala or (e.surse_sugestie and e.surse_sugestie.nul) %}· potrivire text:{% endif %}
{% for s in e.suggestions[:3] %}
{{ s.cod_prestatie }} ({{ s.score|round|int }}%){% if not loop.last %}, {% endif %}
{% endfor %}
diff --git a/app/web/templates/_mapcoloane.html b/app/web/templates/_mapcoloane.html
index f227caf..69ea21b 100644
--- a/app/web/templates/_mapcoloane.html
+++ b/app/web/templates/_mapcoloane.html
@@ -65,7 +65,8 @@
{% endif %}
+ {# FARA display in stilul inline: ar suprascrie .htmx-indicator{display:none}
+ si indicatorul ar ramane vizibil permanent (deci invizibil ca progres). #}
- se parseaza fisierul...
+ style="font-size:var(--fs-sm); margin-top:6px;" role="status">
+
+ se parseaza fisierul si se pregatesc sugestiile...
diff --git a/app/web/templates/base.html b/app/web/templates/base.html
index 3a9e1cd..d863052 100644
--- a/app/web/templates/base.html
+++ b/app/web/templates/base.html
@@ -176,6 +176,11 @@
flex-wrap:wrap; z-index:10; }
/* Indicator HTMX — ascuns pana la request */
.htmx-indicator { display:none; }
+ /* Cerc rotitor pentru indicatorii de procesare (import: parsare + sugestii) */
+ .spin-dot { display:inline-block; width:13px; height:13px; border:2px solid var(--line);
+ border-top-color:var(--accent); border-radius:50%; vertical-align:-2px;
+ margin-right:6px; animation:spin-dot .7s linear infinite; }
+ @keyframes spin-dot { to { transform:rotate(360deg); } }
/* Selector tema stil pill — icon + eticheta temei curente.
Eticheta se ascunde pe <=560px (spatiu ingust), ramane iconita. */
.tema-btn { display:inline-flex; align-items:center; gap:6px; height:36px; padding:0 12px;
diff --git a/requirements.txt b/requirements.txt
index 1f77636..50c54e2 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -25,3 +25,5 @@ dbfread==2.0.7
# Model multilingv (~230MB pe disc, ONNX quantizat), fastembed fara torch, lazy-load la runtime.
# Degradare gratioasa daca lipseste la runtime (is_available()=False, suggest_nearest=[]).
fastembed>=0.8.0
+# Folosit direct la cautarea NN (matvec pe corpus); oricum tras de fastembed/onnxruntime.
+numpy>=1.26
diff --git a/tests/test_enrich_corpus_etichetat.py b/tests/test_enrich_corpus_etichetat.py
index f4767c1..84d5382 100644
--- a/tests/test_enrich_corpus_etichetat.py
+++ b/tests/test_enrich_corpus_etichetat.py
@@ -113,6 +113,58 @@ def test_abtinere_sub_prag(conn, monkeypatch):
assert out["sugestie_principala"] is None
+def _mock_embedding_multi(monkeypatch, vecini):
+ """Mock suggest_nearest cu o lista de vecini [(cod, sim, is_nul), ...]."""
+ import app.embeddings as emb
+ monkeypatch.setattr(emb, "has_corpus", lambda: True)
+ monkeypatch.setattr(
+ emb, "suggest_nearest",
+ lambda text, top_k=1: [
+ {"cod": c, "is_nul": n, "similaritate": s} for c, s, n in vecini
+ ][:top_k],
+ )
+
+
+def test_vot_topk_bate_top1_pe_etichete_contradictorii(conn, monkeypatch):
+ """Corpus cu etichete contradictorii pe denumiri aproape identice:
+ top-1 ar da OE-1, dar votul ponderat (2x OE-8 vs 1x OE-1) da OE-8."""
+ from app.mapping import enrich_suggestions
+ _mock_embedding_multi(monkeypatch, [
+ ("OE-1", 0.94, False),
+ ("OE-8", 0.93, False),
+ ("OE-8", 0.92, False),
+ ])
+ out = enrich_suggestions(conn, "INLOCUIRE ANVELOPE")
+ assert out["surse"]["embedding"] == "OE-8"
+ assert out["surse"]["embedding_similaritate"] == 0.93
+
+
+def test_vot_vecini_sub_prag_nu_voteaza(conn, monkeypatch):
+ """Vecinii sub EMB_MIN_SIMILARITATE nu intra in vot, chiar daca sunt majoritari."""
+ from app.mapping import enrich_suggestions, EMB_MIN_SIMILARITATE
+ _mock_embedding_multi(monkeypatch, [
+ ("OE-3", EMB_MIN_SIMILARITATE + 0.01, False),
+ ("OE-1", EMB_MIN_SIMILARITATE - 0.05, False),
+ ("OE-1", EMB_MIN_SIMILARITATE - 0.05, False),
+ ])
+ out = enrich_suggestions(conn, "CEVA NEVAZUT")
+ assert out["surse"]["embedding"] == "OE-3"
+
+
+def test_vot_nul_majoritar_supreseaza(conn, monkeypatch):
+ """Majoritate NUL in vecinatate -> supresie, chiar daca top-1 e un cod."""
+ from app.mapping import enrich_suggestions
+ _mock_embedding_multi(monkeypatch, [
+ ("OE-1", 0.93, False),
+ (None, 0.92, True),
+ (None, 0.92, True),
+ ])
+ out = enrich_suggestions(conn, "CEVA CARE SEAMANA CU GUNOI")
+ assert out["surse"]["embedding"] is None
+ assert out["surse"]["nul"] is True
+ assert out["sugestie_principala"] is None
+
+
def test_vecin_knn_nul_supreseaza(conn, monkeypatch):
from app.mapping import enrich_suggestions
_mock_embedding(monkeypatch, None, 0.99, is_nul=True) # vecin NUL peste prag
diff --git a/tests/test_mapare_integrare_l14.py b/tests/test_mapare_integrare_l14.py
index 99a3be2..f1eec66 100644
--- a/tests/test_mapare_integrare_l14.py
+++ b/tests/test_mapare_integrare_l14.py
@@ -278,7 +278,7 @@ def test_embeddings_functional_cand_flag_activ(conn, monkeypatch):
conn.execute(
"INSERT OR REPLACE INTO mapping_suggestions "
"(denumire_normalizata, cod_prestatie, is_nul, source, confidence) VALUES (?, ?, ?, ?, ?)",
- ("Schimb ulei", "UL-1", 0, "llm", 0.95),
+ ("Schimb ulei motor", "UL-1", 0, "llm", 0.95),
)
conn.execute(
"INSERT OR REPLACE INTO mapping_suggestions "
@@ -292,7 +292,8 @@ def test_embeddings_functional_cand_flag_activ(conn, monkeypatch):
ensure_embeddings_corpus(conn)
assert emb_mod.has_corpus(), "corpusul trebuie indexat cand flagul e activ"
- # "schimbat uleiul motor" -> vector [1,1,0] -> cel mai apropiat = UL-1 (Schimb ulei).
+ # "schimbat uleiul motor" -> vector [1,1,0] -> identic cu "Schimb ulei motor"
+ # (cosine 1.0, peste EMB_MIN_SIMILARITATE calibrat) -> UL-1.
result = enrich_suggestions(conn, "schimbat uleiul motor", include_embeddings=True)
assert result["surse"]["embedding"] == "UL-1", (
f"embeddings trebuie sa sugereze UL-1, got {result['surse']}"
diff --git a/tests/test_web_preview_paritate_mapari.py b/tests/test_web_preview_paritate_mapari.py
index 6129f8b..0bb3ffd 100644
--- a/tests/test_web_preview_paritate_mapari.py
+++ b/tests/test_web_preview_paritate_mapari.py
@@ -217,4 +217,7 @@ def test_collect_unmapped_ops_conn_none_contract_template(env):
assert len(out) == 1
e = out[0]
assert e["sugestie_principala"] is None
- assert e["surse_sugestie"] == {"gold_partajat": None, "silver": None, "embedding": None, "nul": False}
+ assert e["surse_sugestie"] == {
+ "gold_partajat": None, "silver": None,
+ "embedding": None, "embedding_similaritate": None, "nul": False,
+ }
diff --git a/tools/mapare-llm/knn_calibrate.py b/tools/mapare-llm/knn_calibrate.py
new file mode 100644
index 0000000..8fd7aac
--- /dev/null
+++ b/tools/mapare-llm/knn_calibrate.py
@@ -0,0 +1,161 @@
+"""Calibrare politica k-NN pentru sugestia embeddings (top-1 vs vot ponderat top-k).
+
+Metodologie: leave-one-out pe corpusul SILVER (`mapping_suggestions`) folosind
+vectorii persistati in `embedding_cache` (zero re-embedding). Pentru fiecare
+exemplu: se exclude din corpus, se prezice din vecinii ramasi, se compara cu
+eticheta lui SILVER (cod sau NUL).
+
+ATENTIE la interpretare: SILVER e etichetat de LLM (~95% calitate la scara),
+NU ground-truth uman — valorile absolute sunt optimiste; comparatia RELATIVA
+intre politici si calibrarea pragului raman valide (aceeasi tinta pentru toate).
+
+Politici evaluate:
+ top1 — eticheta primului vecin (comportamentul curent din enrich_suggestions)
+ vote — vot ponderat cu similaritatea pe top-k vecini peste prag;
+ NUL e eticheta proprie (castiga -> supresie)
+
+Metrice (per politica x prag):
+ coverage — % din exemple pentru care se emite o predictie (cod sau NUL)
+ precizie — % predictii corecte din cele emise
+ cod-gresit — % din TOATE exemplele unde s-a emis un COD diferit de gold
+ (critic: cod gresit preselectat = risc FINALIZATA eronata)
+
+Rulare:
+ python3 tools/mapare-llm/knn_calibrate.py --db data/autopass.db
+ python3 tools/mapare-llm/knn_calibrate.py --db data/autopass.db --sample 5000 --seed 42
+"""
+from __future__ import annotations
+
+import argparse
+import os
+import random
+import sqlite3
+import sys
+
+import numpy as np
+
+_HERE = os.path.dirname(os.path.abspath(__file__))
+_ROOT = os.path.abspath(os.path.join(_HERE, "..", ".."))
+if _ROOT not in sys.path:
+ sys.path.insert(0, _ROOT)
+
+from app import embedding_cache as cache # noqa: E402
+from app.embeddings import FASTEMBED_MODEL # noqa: E402
+
+NUL = "NUL"
+
+
+def load_corpus(db_path: str):
+ """Incarca (etichete, matrice vectori) aliniate pozitional din SQLite."""
+ conn = sqlite3.connect(db_path)
+ conn.row_factory = sqlite3.Row
+ rows = conn.execute(
+ "SELECT denumire_normalizata, cod_prestatie, is_nul FROM mapping_suggestions "
+ "WHERE denumire_normalizata IS NOT NULL"
+ ).fetchall()
+ vecmap = {
+ h: np.frombuffer(b, dtype=np.float32)
+ for h, b in conn.execute(
+ "SELECT text_hash, vector FROM embedding_cache WHERE model=?", (FASTEMBED_MODEL,)
+ )
+ }
+ labels: list[str] = []
+ vecs: list[np.ndarray] = []
+ lipsa = 0
+ for r in rows:
+ v = vecmap.get(cache.text_hash(r["denumire_normalizata"]))
+ if v is None:
+ lipsa += 1
+ continue
+ labels.append(NUL if r["is_nul"] else str(r["cod_prestatie"]))
+ vecs.append(v)
+ if lipsa:
+ print(f"AVERTISMENT: {lipsa} exemple fara vector in cache (excluse)")
+ matrix = np.vstack(vecs)
+ norms = np.linalg.norm(matrix, axis=1)
+ norms[norms == 0.0] = 1.0
+ return labels, matrix / norms[:, None]
+
+
+def predict_top1(nbr_labels: list[str], nbr_sims: np.ndarray, prag: float) -> str | None:
+ """Politica curenta: eticheta celui mai apropiat vecin, daca trece pragul."""
+ if nbr_sims[0] < prag:
+ return None
+ return nbr_labels[0]
+
+
+def predict_vote(nbr_labels: list[str], nbr_sims: np.ndarray, prag: float) -> str | None:
+ """Vot ponderat cu similaritatea pe vecinii peste prag."""
+ scoruri: dict[str, float] = {}
+ for lab, sim in zip(nbr_labels, nbr_sims):
+ if sim < prag:
+ continue
+ scoruri[lab] = scoruri.get(lab, 0.0) + float(sim)
+ if not scoruri:
+ return None
+ return max(scoruri, key=lambda k: scoruri[k])
+
+
+def evalueaza(labels, unit, indices, k_max, politici, praguri):
+ """LOO: pentru fiecare index, top-k_max vecini (fara el insusi), apoi
+ aplica fiecare (politica, prag) pe aceiasi vecini. Intoarce metrice agregate."""
+ stats = {(p, prag): {"emis": 0, "corect": 0, "cod_gresit": 0} for p in politici for prag in praguri}
+ n = len(indices)
+ for count, i in enumerate(indices, 1):
+ sims = unit @ unit[i]
+ sims[i] = -1.0 # exclude exemplul insusi (leave-one-out)
+ top = np.argpartition(-sims, k_max)[:k_max]
+ top = top[np.argsort(-sims[top])]
+ nbr_labels = [labels[j] for j in top]
+ nbr_sims = sims[top]
+ gold = labels[i]
+ for (pname, prag), st in stats.items():
+ pred = politici[pname](nbr_labels, nbr_sims, prag)
+ if pred is None:
+ continue
+ st["emis"] += 1
+ if pred == gold:
+ st["corect"] += 1
+ elif pred != NUL:
+ st["cod_gresit"] += 1 # a emis un COD gresit (sau cod in loc de NUL)
+ if count % 2000 == 0:
+ print(f" ...{count}/{n}", file=sys.stderr)
+ return stats, n
+
+
+def main() -> None:
+ ap = argparse.ArgumentParser(description=__doc__)
+ ap.add_argument("--db", default=os.path.join(_ROOT, "data", "autopass.db"))
+ ap.add_argument("--sample", type=int, default=0, help="LOO doar pe un esantion (0 = tot corpusul)")
+ ap.add_argument("--seed", type=int, default=42)
+ ap.add_argument("--k", type=int, default=5, help="k pentru politica de vot")
+ args = ap.parse_args()
+
+ labels, unit = load_corpus(args.db)
+ print(f"corpus: {len(labels)} exemple ({sum(1 for l in labels if l == NUL)} NUL)")
+
+ indices = list(range(len(labels)))
+ if args.sample and args.sample < len(indices):
+ random.Random(args.seed).shuffle(indices)
+ indices = indices[: args.sample]
+ print(f"evaluare LOO pe {len(indices)} exemple, k={args.k}")
+
+ politici = {
+ "top1": predict_top1,
+ f"vote{args.k}": predict_vote,
+ }
+ praguri = [0.5, 0.7, 0.8, 0.85, 0.88, 0.9, 0.92, 0.95]
+ stats, n = evalueaza(labels, unit, indices, args.k, politici, praguri)
+
+ print(f"\n{'politica':<8} {'prag':>5} {'coverage':>9} {'precizie':>9} {'cod-gresit':>11}")
+ for pname in politici:
+ for prag in praguri:
+ st = stats[(pname, prag)]
+ cov = st["emis"] / n
+ prec = (st["corect"] / st["emis"]) if st["emis"] else 0.0
+ wc = st["cod_gresit"] / n
+ print(f"{pname:<8} {prag:>5.2f} {cov:>8.1%} {prec:>8.1%} {wc:>10.2%}")
+
+
+if __name__ == "__main__":
+ main()