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 @@
+ hx-swap="outerHTML" + hx-indicator="#mapcol-spinner">
@@ -162,6 +163,11 @@ style="min-height:44px; padding:10px 24px; font-size:var(--fs-md);{% if not prima_inreg %} opacity:0.5; cursor:not-allowed;{% endif %}"> Salveaza si continua la preview + + + se verifica randurile si se calculeaza sugestiile... + {% if not prima_inreg %} Fisierul nu contine randuri de date — incarca un fisier cu cel putin o inregistrare. diff --git a/app/web/templates/_preview_import.html b/app/web/templates/_preview_import.html index b2c6438..4463de3 100644 --- a/app/web/templates/_preview_import.html +++ b/app/web/templates/_preview_import.html @@ -81,7 +81,8 @@ ok si maparea se retine pentru fisierele viitoare.

+ hx-target="#import-section" hx-swap="outerHTML" + hx-indicator="#mapop-spinner"> {% for e in unmapped_ops %} {%- set top = e.suggestions[0] if e.suggestions else None -%} @@ -106,9 +107,16 @@ {% 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 and (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 %} @@ -127,8 +135,13 @@
{% endfor %} -
+
+ + + se salveaza si se recalculeaza preview-ul... +
diff --git a/app/web/templates/_upload.html b/app/web/templates/_upload.html index 5733492..eac3800 100644 --- a/app/web/templates/_upload.html +++ b/app/web/templates/_upload.html @@ -122,9 +122,12 @@

{% 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()