pusula / app.py
cihatyldz's picture
Upload app.py with huggingface_hub
14dc511 verified
Raw History Blame Contribute Delete
19.6 kB
"""
Pusula — Turkce arama modellerinin gercek kullanici sorgularina dayanikliligi.
Veri: leaderboard.json (yalnizca skorlar; benchmark metni icermez). Bozulmalar: pusula_perturb.py.
"""
import difflib
import html
import json
import gradio as gr
from pusula_perturb import PERTURBATIONS, perturb
with open("leaderboard.json", encoding="utf-8") as f:
DATA = json.load(f)
META, MODELS, FIND = DATA["meta"], DATA["models"], DATA.get("findings", {})
PERTS = [p["name"] for p in META["perturbations"]]
PLABEL = {p["name"]: p["label"] for p in META["perturbations"]}
PDESC = {p["name"]: p["description"] for p in META["perturbations"]}
DATASETS = META["datasets"]
RANKED = [d for d in DATASETS if d.get("in_ranking", True)]
HELD = [d for d in DATASETS if not d.get("in_ranking", True)] # gosterilir, siralamaya girmez
HELD_NAMES = {d["name"] for d in HELD}
METRIC = META["metric"]
METRIC_LABEL = {"ndcg@10": "nDCG@10", "recall@10": "Recall@10", "mrr@10": "MRR@10"}.get(METRIC, METRIC)
BY_SHORT = {m["short"]: m for m in MODELS}
# Kahraman cumlesi icin bozulmaya gore ifade (sayiya ek gelmeyecek bicimde kurulur)
PERT_PHRASE = {
"upper": "BÜYÜK HARFLE yazılmış sorgularda",
"ascii_full": "Türkçe karakter kullanılmadan yazılmış sorgularda",
"ascii_partial": "Türkçe karakterlerin bir kısmı atlanmış sorgularda",
"typo": "yazım hatalı sorgularda",
"colloquial": "gündelik dille yazılmış sorgularda",
"mobile": "telefondan aceleyle yazılmış sorgularda",
}
VIEWS = {"Mobil sorguda başarı": "mobile", "Temiz sorguda başarı": "clean", "Ortalama koruma": "avg"}
ALL = f"Sıralamadaki veri setleri ({len(RANKED)})" if HELD else "Tüm veri setleri"
DS_CHOICES = [ALL] + [(f"{d['name']} (sıralama dışı)" if d["name"] in HELD_NAMES else d["name"], d["name"]) for d in DATASETS]
def pct(x):
return "—" if x is None else f"%{100 * x:.0f}"
def fmt_params(p):
if not p:
return ""
return f"{p / 1000:.1f}B" if p >= 1000 else f"{p}M"
# ═══ Kahraman alani: veriden turetilen kapak ═══
TR_MTEB_URL = "https://aclanthology.org/2025.findings-emnlp.471/"
def _stat(value, label, kind=""):
return (f'<div class="pu-stat {kind}"><div class="pu-stat-value">{value}</div>'
f'<div class="pu-stat-label">{label}</div></div>')
def hero_html():
n_models, n_ds = len(MODELS), len(RANKED)
n_q = f"{sum(d['n_queries'] for d in RANKED):,}".replace(",", ".")
held_note = (" " + ", ".join(html.escape(d["name"]) for d in HELD) +
" tabloda ayrıca gösteriliyor ama eğitim verisiyle örtüştüğü için sıralamaya girmiyor.") if HELD else ""
stats = []
wd = FIND.get("worst_drop")
if wd:
stats.append(_stat(pct(wd["retention"]),
f'{html.escape(PLABEL.get(wd["pert"], wd["pert"]))} sorgularda <b>{html.escape(wd["model"])}</b> '
f'modelinin koruma oranı', "pu-stat-neg"))
cw = FIND.get("clean_winner")
if cw and cw["mobile_rank"] != 1:
stats.append(_stat(f'{cw["clean_rank"]}. → {cw["mobile_rank"]}.',
f'Temiz sorgularda lider olan <b>{html.escape(cw["model"])}</b>, mobil sorgulardaki sırası'))
mw = FIND.get("mobile_winner")
if mw:
score = BY_SHORT[mw["model"]]["by_pert"]["mobile"]["score"]
tied = f', {", ".join(map(html.escape, mw["tied_with"]))} ile aynı grupta' if mw["tied_with"] else ""
stats.append(_stat(f'<span class="pu-stat-name">{html.escape(mw["model"])}</span>',
f'Mobil sorgularda en iyi model ({METRIC_LABEL} {score:.3f}{tied})'))
fe = FIND.get("fold_effect")
if fe and fe["before"] is not None:
stats.append(_stat(f'{pct(fe["before"])} → {pct(fe["after"])}',
"BM25'e tek satırlık ASCII katlama eklenince ASCII sorgulardaki koruma oranı"))
return f"""
<header class="pu-hero">
<div class="pu-top">
<div class="pu-brand">
<svg class="pu-compass" viewBox="0 0 48 48" aria-hidden="true">
<circle cx="24" cy="24" r="21" fill="none" stroke="currentColor" stroke-width="1.6"/>
<path d="M24 6 L29 24 L24 24 Z" fill="#C8102E"/><path d="M24 6 L19 24 L24 24 Z" fill="#9E0B23"/>
<path d="M24 42 L19 24 L24 24 Z" fill="currentColor" opacity=".85"/><path d="M24 42 L29 24 L24 24 Z" fill="currentColor" opacity=".55"/>
<circle cx="24" cy="24" r="2.2" fill="currentColor"/>
</svg>
<div>
<h1>Pusula</h1>
<p class="pu-kicker">Türkçe retrieval dayanıklılık benchmark'ı</p>
</div>
</div>
<nav class="pu-links">
<a href="https://huggingface.co/datasets/{html.escape(META['dataset_repo'])}" target="_blank" rel="noopener">Veri ve kod ↗</a>
<a href="{TR_MTEB_URL}" target="_blank" rel="noopener">TR-MTEB ↗</a>
</nav>
</div>
<p class="pu-lede">Türkçe arama modelleri, kullanıcıların sorguları gerçekte yazdığı gibi yazıldığında ne kadar başarı kaybediyor?
{n_models} model, {n_ds} veri setinden {n_q} sorgu ve {len(PERTS)} tür sorgu bozulmasıyla ölçüldü.{held_note}</p>
<div class="pu-stats">{"".join(stats)}</div>
</header>"""
# ═══ Siralama tablosu ═══
def _values(m, dataset):
if dataset == ALL:
return m["clean"], {p: m["by_pert"][p] for p in PERTS}
d = m["by_dataset"][dataset]
return d["clean"], {p: d[p] for p in PERTS}
def _heat(r):
if r is None:
return '<td class="pu-num pu-na">—</td>'
alpha = min(0.8, max(0.0, (1 - r) * 0.9)) # %100 renksiz; %90→.09, %75→.23, %50→.45, %15→.77
return (f'<td class="pu-num pu-heat" style="--a:{alpha:.3f}" '
f'title="Değişen sorgularda koruma: %{100 * r:.1f} (100 üstü: bozulma tesadüfen işe yaradı)">{round(100 * r)}</td>')
def board_html(view_label, dataset):
view = VIEWS[view_label]
rows = []
for m in MODELS:
clean, perts = _values(m, dataset)
rets = [perts[p]["retention"] for p in PERTS if perts[p]["retention"] is not None]
rows.append((m, clean, perts, sum(rets) / len(rets) if rets else None))
key = {"mobile": lambda r: -r[2]["mobile"]["score"], "clean": lambda r: -r[1],
"avg": lambda r: -(r[3] if r[3] is not None else -1)}[view]
rows.sort(key=key)
show_groups = view == "mobile" and dataset == ALL
head = "".join(f'<th class="pu-num" title="{html.escape(PDESC[p])}">{html.escape(PLABEL[p])}</th>' for p in PERTS)
body = []
for i, (m, clean, perts, avg) in enumerate(rows):
g = m["group"] if show_groups else i + 1
band = "pu-band" if show_groups and m["group"] % 2 == 0 else ""
same = show_groups and i > 0 and rows[i - 1][0]["group"] == m["group"]
rank_cell = f'<td class="pu-rank">{"" if same else g}</td>'
fam = html.escape(m["family"])
body.append(
f'<tr class="{band}">{rank_cell}'
f'<td class="pu-model"><a href="https://huggingface.co/{html.escape(m["model_id"])}" target="_blank" rel="noopener">'
f'{html.escape(m["short"])}</a><div class="pu-meta"><span class="pu-fam pu-fam-{fam[:3].lower()}">{fam}</span>'
f'<span class="pu-params">{fmt_params(m.get("params_m"))}</span></div></td>'
f'<td class="pu-num">{clean:.3f}</td><td class="pu-num pu-strong">{perts["mobile"]["score"]:.3f}</td>'
+ "".join(_heat(perts[p]["retention"]) for p in PERTS)
+ f'<td class="pu-num pu-strong">{"—" if avg is None else round(100 * avg)}</td></tr>')
note = ("Aynı numaralı modeller arasındaki fark istatistiksel olarak ayırt edilemez "
"(veri seti dengeli eşleştirilmiş bootstrap, %95)." if show_groups
else "Gruplama yalnızca sıralamadaki veri setleri ve mobil sıralamada gösterilir.")
if dataset in HELD_NAMES:
reason = next(d.get("holdout_reason") or "" for d in HELD if d["name"] == dataset)
warn = (f'<p class="pu-warn"><b>Sıralama dışı veri seti.</b> {html.escape(reason)} '
f'Bu tablodaki skorlar bazı modeller için şişik olabilir; modelleri karşılaştırmak için kullanmayın.</p>')
else:
warn = ""
return f"""{warn}
<div class="pu-table-wrap"><table class="pu-table">
<thead><tr><th>{"Grup" if show_groups else "#"}</th><th>Model</th><th class="pu-num">Temiz<br>{METRIC_LABEL}</th>
<th class="pu-num">Mobil<br>{METRIC_LABEL}</th>{head}<th class="pu-num">Ort.</th></tr>
<tr class="pu-subhead"><th></th><th></th><th></th><th></th><th class="pu-num" colspan="{len(PERTS)}">Koruma oranı (%), yalnızca bozulmanın değiştirdiği sorgularda</th><th></th></tr></thead>
<tbody>{"".join(body)}</tbody></table></div>
<p class="pu-note">{note} Mutlak skorlar alt-örneklenmiş korpus nedeniyle TR-MTEB ile karşılaştırılamaz; benchmark'ın iddiası koruma oranlarıdır.</p>"""
# ═══ Sorgunu boz ═══
def _diff(a, b):
out = []
for op, i1, i2, j1, j2 in difflib.SequenceMatcher(None, a, b, autojunk=False).get_opcodes():
seg = html.escape(b[j1:j2])
if op == "equal":
out.append(seg)
elif seg:
out.append(f"<mark>{seg}</mark>")
else:
out.append(f'<mark class="pu-del" title="silinen: {html.escape(a[i1:i2])}"></mark>')
return "".join(out)
def perturb_html(text, seed):
text = (text or "").strip()
if not text:
return '<p class="pu-note">Bir sorgu yazın.</p>'
rows = []
for p in PERTS:
out = perturb(text, f"demo-{text}", p, seed=int(seed))
changed = out != text
fragile, robust = FIND.get("most_fragile", {}).get(p), FIND.get("most_robust", {}).get(p)
hint = ""
if fragile and robust:
fr, rb = BY_SHORT[fragile]["by_pert"][p]["retention"], BY_SHORT[robust]["by_pert"][p]["retention"]
hint = (f'<span class="pu-hint">En kırılgan: {html.escape(fragile)} {pct(fr)} &nbsp; '
f'En dayanıklı: {html.escape(robust)} {pct(rb)}</span>')
rows.append(f'<div class="pu-pert"><div class="pu-pert-head"><b>{html.escape(PLABEL[p])}</b>'
f'<span>{html.escape(PDESC[p])}</span></div>'
f'<p class="pu-pert-text">{_diff(text, out) if changed else html.escape(out)}'
f'{"" if changed else " <em>(bu sorguyu değiştirmedi)</em>"}</p>{hint}</div>')
return "".join(rows)
def next_seed(seed):
return int(seed) + 1
# ═══ Yontem ═══
def method_md():
ds_rows = "\n".join(
f"| {d['name']} | {d['domain']} | {'doğal Türkçe' if d.get('native') else 'çeviri'} | {d['n_queries']} | {d['corpus_size']:,} | "
.replace(",", ".") + ("Evet" if d.get("in_ranking", True) else "**Hayır**") + " |"
for d in DATASETS)
held_md = "".join(f"\n- **{d['name']}**: {d.get('holdout_reason') or ''}" for d in HELD)
held_md = (f"\n\n**Sıralama dışı veri setleri.** Bu setler tabloda gösterilir ama ortalamaya, sıralamaya, gruplara ve "
f"bulgulara girmez:{held_md}") if HELD else ""
pinned = "\n".join(f"- **{m['short']}**: `{m['model_id']}` @ `{m['revision'][:7]}`" for m in MODELS if m.get("revision"))
pinned_md = f"\n\n### Sabitlenmiş model sürümleri\n{pinned}" if pinned else ""
p_rows = "\n".join(f"| {PLABEL[p]} | {PDESC[p]} | {META['example']['outputs'][p]} |" for p in PERTS)
failed = "\n".join(f"- **{f['short']}** (`{f['model_id']}`): {f['reason']}" for f in META.get("failed", [])) or "- Yok"
return f"""
### Veri setleri
| Veri seti | Alan | Kaynak | Sorgu | Korpus | Sıralamada |
|---|---|---|---|---|---|
{ds_rows}{held_md}
Korpus, her sorgunun ilgili dokümanlarını koruyacak şekilde alt-örneklendi. TR-MTEB veri setleri
([Baysan & Güngör, Findings of EMNLP 2025](https://aclanthology.org/2025.findings-emnlp.471/)) ve
İletişim Başkanlığı DMM bültenlerinden türetilen doğal Türkçe başlık → analiz görevi kullanıldı.
### Bozulmalar
Yalnızca **sorgu** bozulur, dokümanlar temiz kalır. Örnek sorgu: *{META['example']['text']}*
| Bozulma | Açıklama | Örnek |
|---|---|---|
{p_rows}
### Metrik
**{METRIC_LABEL}** ve **koruma oranı** = bozuk sorgudaki {METRIC_LABEL} / temiz sorgudaki {METRIC_LABEL}; yalnızca bozulmanın
gerçekten değiştirdiği sorgular üzerinden, sıralamadaki veri setleri eşit ağırlıkla. Sıralama mobil sorgulardaki mutlak başarıya göredir.
### Değerlendirilemeyen modeller
{failed}{pinned_md}
### Sınırlamalar
- Üç veri seti makine çevirisi; sorgular resmî dil kullandığı için gündelik yazım bozulması az sorguyu etkiler.
- Mutlak skorlar alt-örneklenmiş korpus nedeniyle TR-MTEB ile karşılaştırılamaz.
- Bozulmalar kural tabanlıdır; gerçek kullanıcı hatalarının tüm çeşitliliğini kapsamaz.
### Yeniden üretim
Benchmark parmak izi: `{META['fingerprint']}`. Kod ve sorgu bazında skorlar:
[{META['dataset_repo']}](https://huggingface.co/datasets/{META['dataset_repo']}).
Benchmark metinleri lisans nedeniyle dağıtılmaz; kod, kaynak veri setlerinden birebir aynısını üretir.
"""
HEAD = ('<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>'
'<link href="https://fonts.googleapis.com/css2?family=Public+Sans:wght@400;500;600&family=Source+Serif+4:opsz,wght@8..60,500;8..60,600&display=swap" rel="stylesheet">')
CSS = """
:root { --pu-ink:#1B2430; --pu-muted:#5F6B7A; --pu-line:rgba(95,107,122,.25); --pu-red:#C8102E; --pu-good:#2F7D6D; --pu-bad:#C8102E; }
.gradio-container { max-width: 1280px !important; margin: 0 auto !important;
font-family: 'Public Sans', system-ui, -apple-system, 'Segoe UI', sans-serif !important; }
footer { display:none !important; }
.pu-hero { padding: 30px 4px 10px; }
.pu-top { display:flex; justify-content:space-between; align-items:flex-start; gap:20px; flex-wrap:wrap; }
.pu-brand { display:flex; gap:14px; align-items:center; color:var(--pu-ink); }
.dark .pu-brand { color:#E8ECF1; }
.pu-compass { width:50px; height:50px; flex:none; }
.pu-brand h1 { margin:0 !important; font:600 2.3rem/1.05 'Source Serif 4', Georgia, serif !important; letter-spacing:-.015em; color:inherit !important; }
.pu-kicker { margin:4px 0 0 !important; font-size:.95rem; color:var(--pu-muted) !important; }
.pu-links { display:flex; gap:8px; padding-top:6px; }
.pu-links a { border:1px solid var(--pu-line); border-radius:6px; padding:6px 12px; font-size:.86rem; font-weight:500;
text-decoration:none !important; color:inherit !important; white-space:nowrap; }
.pu-links a:hover { border-color: var(--pu-muted); }
.pu-lede { max-width:64ch; margin:18px 0 0 !important; font-size:1.02rem; line-height:1.6; color:var(--pu-muted) !important; }
.pu-stats { display:grid; grid-template-columns:repeat(auto-fit, minmax(220px, 1fr)); gap:22px; margin-top:28px; }
.pu-stat { border-top:2px solid var(--pu-ink); padding-top:14px; }
.dark .pu-stat { border-top-color:#E8ECF1; }
.pu-stat.pu-stat-neg { border-top-color: var(--pu-red); }
.pu-stat-value { font:600 2.25rem/1.1 'Source Serif 4', Georgia, serif; color:var(--pu-ink) !important; font-variant-numeric: tabular-nums; }
.dark .pu-stat-value { color:#E8ECF1 !important; }
.pu-stat-neg .pu-stat-value { color: var(--pu-red) !important; }
.pu-stat-name { font-size:1.45rem; letter-spacing:-.01em; }
.pu-stat-label { margin-top:8px; font-size:.88rem; line-height:1.5; color:var(--pu-muted) !important; }
.pu-stat-label b { color:inherit !important; font-weight:600; white-space:nowrap; }
/* contain:inline-size → kutunun genisligi icerikten bagimsiz; tablo atalari genisletemez, kendi icinde kayar */
.pu-table-wrap { overflow-x:auto; width:100%; contain:inline-size; }
.pu-table { width:100%; border-collapse:collapse; font-size:.9rem; border:0 !important; }
.pu-table tr { border:0 !important; } /* Gradio her satira 4 yonlu kenarlik veriyor */
.pu-table th, .pu-table td { border-left:0 !important; border-right:0 !important; border-top:0 !important; }
.pu-table th { text-align:left; font-weight:600; padding:8px 10px; border-bottom:1px solid var(--pu-line); white-space:nowrap; }
.pu-table .pu-subhead th { font-weight:400; font-size:.78rem; color:var(--pu-muted); padding-top:0; text-align:center; }
.pu-table td { padding:7px 9px; border-bottom:1px solid var(--pu-line) !important; vertical-align:middle; }
.pu-num { text-align:right; font-variant-numeric: tabular-nums; }
.pu-strong { font-weight:600; }
.pu-rank { width:3em; color:var(--pu-muted); font-variant-numeric: tabular-nums; }
.pu-band td:not(.pu-heat) { background: rgba(95,107,122,.06); }
.pu-model { white-space:nowrap; }
.pu-model a { color:inherit; text-decoration:none; font-weight:500; }
.pu-meta { margin-top:2px; display:flex; gap:8px; align-items:center; }
.pu-model a:hover { text-decoration:underline; }
.pu-fam { font-size:.72rem; padding:1px 6px; border-radius:4px; border:1px solid var(--pu-line); color:var(--pu-muted); }
.pu-fam-tür { border-color: rgba(200,16,46,.45); color: var(--pu-red); }
.pu-params { font-size:.78rem; color:var(--pu-muted); }
.pu-heat { background: rgba(200,16,46,var(--a)) !important; }
.pu-na { color:var(--pu-muted); }
.pu-note { color:var(--pu-muted); font-size:.85rem; margin-top:10px; }
.pu-warn { border-left:3px solid var(--pu-red); padding:8px 12px; margin:0 0 14px !important; font-size:.9rem; background:rgba(200,16,46,.06); }
.pu-pert { padding:12px 0; border-bottom:1px solid var(--pu-line); }
.pu-pert-head { display:flex; gap:12px; align-items:baseline; flex-wrap:wrap; }
.pu-pert-head span { color:var(--pu-muted); font-size:.85rem; }
.pu-pert-text { margin:6px 0 4px; font-size:1.05rem; }
.pu-pert-text mark { background: rgba(200,16,46,.16); color:inherit; border-radius:2px; padding:0 1px; }
.pu-pert-text mark.pu-del { display:inline-block; width:2px; height:1.05em; padding:0; margin:0 1px; vertical-align:text-bottom; background: var(--pu-red); }
.pu-pert-text em { color:var(--pu-muted); font-size:.85rem; }
.pu-hint { font-size:.82rem; color:var(--pu-muted); }
"""
EXAMPLES = [["İstanbul'daki kargo şubesi nerede, ne zaman açılıyor?"], ["Kredi kartı borcumu nasıl yapılandırabilirim?"],
["Çocuğum ateşleniyor, ne yapacağım?"], ["Emeklilik fonu getirisi vergiye tabi mi?"],
["Şirketimin hisseleri neden düşüyor?"]]
with gr.Blocks(title="Pusula — Türkçe retrieval dayanıklılık benchmark'ı", fill_width=True) as demo:
gr.HTML(hero_html())
with gr.Tabs():
with gr.Tab("Sıralama"):
with gr.Row():
view = gr.Radio(list(VIEWS), value="Mobil sorguda başarı", label="Sıralama ölçütü")
ds = gr.Dropdown(DS_CHOICES, value=ALL, label="Veri seti")
board = gr.HTML(board_html("Mobil sorguda başarı", ALL))
view.change(board_html, [view, ds], board)
ds.change(board_html, [view, ds], board)
with gr.Tab("Sorgunu boz"):
seed = gr.State(0)
with gr.Row():
q = gr.Textbox(label="Sorgu", value=EXAMPLES[0][0], lines=1, scale=5)
again = gr.Button("Yeni rastgele sürüm", scale=1)
gr.Examples(EXAMPLES, inputs=q, label="Örnekler")
out = gr.HTML(perturb_html(EXAMPLES[0][0], 0))
q.change(perturb_html, [q, seed], out)
again.click(next_seed, seed, seed).then(perturb_html, [q, seed], out)
with gr.Tab("Yöntem"):
gr.Markdown(method_md())
demo.launch(css=CSS, head=HEAD)