turkish-rag-eval 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- turkish_rag_eval/__init__.py +14 -0
- turkish_rag_eval/abstain.py +122 -0
- turkish_rag_eval/agreement.py +233 -0
- turkish_rag_eval/bootstrap.py +330 -0
- turkish_rag_eval/charts.py +251 -0
- turkish_rag_eval/chunking.py +133 -0
- turkish_rag_eval/cli.py +159 -0
- turkish_rag_eval/corpus.py +216 -0
- turkish_rag_eval/export_hf.py +119 -0
- turkish_rag_eval/fetch_corpus.py +204 -0
- turkish_rag_eval/gold.py +60 -0
- turkish_rag_eval/groundedness.py +538 -0
- turkish_rag_eval/leaderboard.py +257 -0
- turkish_rag_eval/metrics.py +108 -0
- turkish_rag_eval/models.py +32 -0
- turkish_rag_eval/paths.py +40 -0
- turkish_rag_eval/report.py +275 -0
- turkish_rag_eval/retrieval.py +84 -0
- turkish_rag_eval/run_abstain.py +69 -0
- turkish_rag_eval/run_eval.py +270 -0
- turkish_rag_eval/turkish_text.py +54 -0
- turkish_rag_eval/validate_gold.py +174 -0
- turkish_rag_eval-0.2.0.dist-info/METADATA +487 -0
- turkish_rag_eval-0.2.0.dist-info/RECORD +29 -0
- turkish_rag_eval-0.2.0.dist-info/WHEEL +5 -0
- turkish_rag_eval-0.2.0.dist-info/entry_points.txt +2 -0
- turkish_rag_eval-0.2.0.dist-info/licenses/LICENSE +21 -0
- turkish_rag_eval-0.2.0.dist-info/licenses/NOTICE.md +30 -0
- turkish_rag_eval-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""turkish-rag-eval: which parts of a Turkish RAG pipeline actually pay off.
|
|
2
|
+
|
|
3
|
+
The package is a retrieval measurement harness. Point it at a corpus and a set
|
|
4
|
+
of labelled questions and it reports, with confidence intervals, what each
|
|
5
|
+
pipeline choice - chunking strategy, stemming, dense model, fusion - is worth
|
|
6
|
+
and what it costs.
|
|
7
|
+
|
|
8
|
+
Every published number is tagged with this version, because a benchmark whose
|
|
9
|
+
results cannot be reproduced against a fixed release is not comparable.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
__version__ = "0.2.0"
|
|
13
|
+
|
|
14
|
+
__all__ = ["__version__"]
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Decide when retrieval is too weak to answer, and escalate to a human.
|
|
2
|
+
|
|
3
|
+
In a regulated domain the useful question is not "how accurate is the system"
|
|
4
|
+
but "how accurate is it on the queries it chose to answer, and how many did it
|
|
5
|
+
choose to answer". Those move against each other, so this module sweeps the
|
|
6
|
+
threshold and reports the trade-off instead of picking one number and hiding
|
|
7
|
+
the curve.
|
|
8
|
+
|
|
9
|
+
Two confidence signals are measured, because the obvious one fails:
|
|
10
|
+
|
|
11
|
+
margin - normalised gap between the top hit and the runner-up. Intuitive,
|
|
12
|
+
and useless on RRF output: fused scores are 1/(60+rank), so the
|
|
13
|
+
top-two gap is ~2% for every query, confident or not.
|
|
14
|
+
score - the dense retriever's raw top-1 cosine similarity. An absolute
|
|
15
|
+
measure of how close anything in the corpus came to the query,
|
|
16
|
+
which is what an abstain decision actually needs.
|
|
17
|
+
|
|
18
|
+
The comparison is the point. See results/abstain_curve_*.json.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import argparse
|
|
22
|
+
import json
|
|
23
|
+
|
|
24
|
+
from .paths import ROOT
|
|
25
|
+
|
|
26
|
+
RESULTS = ROOT / "results"
|
|
27
|
+
SIGNALS = ("margin", "score")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def margin_confidence(hits: list[tuple[int, float]]) -> float:
|
|
31
|
+
"""Normalised top-1 margin in [0, 1]. 0 when the top two are tied."""
|
|
32
|
+
if not hits:
|
|
33
|
+
return 0.0
|
|
34
|
+
top = hits[0][1]
|
|
35
|
+
if top <= 0:
|
|
36
|
+
return 0.0
|
|
37
|
+
runner_up = hits[1][1] if len(hits) > 1 else 0.0
|
|
38
|
+
return max(0.0, (top - runner_up) / top)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def score_confidence(hits: list[tuple[int, float]]) -> float:
|
|
42
|
+
"""Raw top-1 score, clamped to [0, 1]. Meaningful for cosine similarity."""
|
|
43
|
+
if not hits:
|
|
44
|
+
return 0.0
|
|
45
|
+
return max(0.0, min(1.0, hits[0][1]))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def sweep(records: list[dict], signal: str, steps: int = 21) -> list[dict]:
|
|
49
|
+
"""Coverage / selective-accuracy curve over thresholds of one signal."""
|
|
50
|
+
curve = []
|
|
51
|
+
for i in range(steps):
|
|
52
|
+
threshold = i / (steps - 1)
|
|
53
|
+
answered = [r for r in records if r[signal] >= threshold]
|
|
54
|
+
correct = sum(1 for r in answered if r["correct"])
|
|
55
|
+
curve.append({
|
|
56
|
+
"threshold": round(threshold, 3),
|
|
57
|
+
"coverage": len(answered) / len(records) if records else 0.0,
|
|
58
|
+
"selective_accuracy": correct / len(answered) if answered else None,
|
|
59
|
+
"answered": len(answered),
|
|
60
|
+
"escalated": len(records) - len(answered),
|
|
61
|
+
})
|
|
62
|
+
return curve
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def pick_threshold(curve: list[dict], min_accuracy: float,
|
|
66
|
+
min_answered: int = 5) -> dict | None:
|
|
67
|
+
"""Highest-coverage threshold that still clears an accuracy floor.
|
|
68
|
+
|
|
69
|
+
None means no threshold reaches the floor - a real answer, not a
|
|
70
|
+
failure: the system should not run unattended at that requirement.
|
|
71
|
+
"""
|
|
72
|
+
viable = [p for p in curve
|
|
73
|
+
if p["selective_accuracy"] is not None
|
|
74
|
+
and p["selective_accuracy"] >= min_accuracy
|
|
75
|
+
and p["answered"] >= min_answered]
|
|
76
|
+
return max(viable, key=lambda p: p["coverage"]) if viable else None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def report(records: list[dict]) -> None:
|
|
80
|
+
baseline = sum(1 for r in records if r["correct"]) / len(records)
|
|
81
|
+
print(f"{len(records)} sorgu | esiksiz top-1 dogrulugu {baseline:.3f}\n")
|
|
82
|
+
|
|
83
|
+
for signal in SIGNALS:
|
|
84
|
+
curve = sweep(records, signal)
|
|
85
|
+
(RESULTS / f"abstain_curve_{signal}.json").write_text(
|
|
86
|
+
json.dumps(curve, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
87
|
+
|
|
88
|
+
print(f"--- sinyal: {signal} ---")
|
|
89
|
+
print(f"{'esik':>6} {'kapsam':>8} {'dogruluk':>9} {'cevaplanan':>11} {'insana':>7}")
|
|
90
|
+
for point in curve:
|
|
91
|
+
if point["answered"] == 0:
|
|
92
|
+
continue
|
|
93
|
+
accuracy = f"{point['selective_accuracy']:.3f}"
|
|
94
|
+
print(f"{point['threshold']:>6.2f} {point['coverage']:>8.3f} "
|
|
95
|
+
f"{accuracy:>9} {point['answered']:>11} {point['escalated']:>7}")
|
|
96
|
+
|
|
97
|
+
for floor in (0.70, 0.80, 0.90):
|
|
98
|
+
choice = pick_threshold(curve, floor)
|
|
99
|
+
if choice is None:
|
|
100
|
+
print(f" >= {floor:.0%} dogruluk: hicbir esik saglamiyor")
|
|
101
|
+
else:
|
|
102
|
+
print(f" >= {floor:.0%} dogruluk -> esik {choice['threshold']:.2f}: "
|
|
103
|
+
f"{choice['coverage']:.0%} otomatik, "
|
|
104
|
+
f"{choice['escalated']} soru insana")
|
|
105
|
+
print()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def run(args=None) -> int:
|
|
109
|
+
path = RESULTS / "abstain_records.json"
|
|
110
|
+
if not path.exists():
|
|
111
|
+
raise SystemExit("once run_abstain.py calistirilmali")
|
|
112
|
+
report(json.loads(path.read_text(encoding="utf-8")))
|
|
113
|
+
return 0
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def main() -> int:
|
|
117
|
+
argparse.ArgumentParser(description=__doc__).parse_args()
|
|
118
|
+
return run(None)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
if __name__ == "__main__":
|
|
122
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""Inter-annotator agreement for second-annotated questions.
|
|
2
|
+
|
|
3
|
+
A second annotation reuses the original ``question``, ``doc_id`` and
|
|
4
|
+
``doc_title`` but is written by a different ``annotator`` without looking at
|
|
5
|
+
the first answer span (see CONTRIBUTING.md), and links back with
|
|
6
|
+
``"second_of": "<original qid>"``. A question can end up with more than two
|
|
7
|
+
annotators, so agreement is computed over every pairwise combination and
|
|
8
|
+
reported as the mean per question - not just the first pair - before being
|
|
9
|
+
averaged again, equally per question, into the headline numbers.
|
|
10
|
+
|
|
11
|
+
Two measurements, decided in the issue #12 discussion:
|
|
12
|
+
|
|
13
|
+
iou - Jaccard similarity of the two spans' token sets. The headline
|
|
14
|
+
number: one symmetric score that, unlike F1, correctly charges the
|
|
15
|
+
extra material when one span is a superset of the other (the
|
|
16
|
+
common case of two annotators bracketing the same fact with
|
|
17
|
+
different amounts of surrounding clause).
|
|
18
|
+
token_f1 - reported alongside IoU because span-extraction/QA evaluation
|
|
19
|
+
traditionally looks for it.
|
|
20
|
+
kappa - Cohen's kappa, per chunking strategy, over the binary "is this
|
|
21
|
+
chunk relevant" label each span would assign to each chunk of the
|
|
22
|
+
source document (same doc_id and body contains the span, exactly
|
|
23
|
+
the definition run_eval.is_relevant uses to score retrievers).
|
|
24
|
+
Kappa, not IoU, is used here because most chunks are irrelevant to
|
|
25
|
+
both annotators - raw or set-based overlap would be inflated by
|
|
26
|
+
that chance agreement, which kappa corrects for.
|
|
27
|
+
|
|
28
|
+
All three use `turkish_text.tokenize`, so the casing / I-ı folding is
|
|
29
|
+
consistent with the rest of the harness. Only the standard library is used,
|
|
30
|
+
so this runs without the heavier retrieval dependencies (numpy,
|
|
31
|
+
sentence-transformers) that run_eval.py needs.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
import argparse
|
|
35
|
+
import json
|
|
36
|
+
from collections import Counter
|
|
37
|
+
from itertools import combinations
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
|
|
40
|
+
from .chunking import STRATEGIES
|
|
41
|
+
from .gold import ROOT, load_gold, normalise
|
|
42
|
+
from .turkish_text import tokenize, turkish_lower
|
|
43
|
+
|
|
44
|
+
CORPUS = ROOT / "data" / "raw" / "corpus.json"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def is_relevant(chunk: dict, item: dict) -> bool:
|
|
48
|
+
"""Same definition as run_eval.is_relevant, kept dependency-free here."""
|
|
49
|
+
if chunk["doc_id"] != item["doc_id"]:
|
|
50
|
+
return False
|
|
51
|
+
return normalise(item["answer_span"]) in normalise(chunk["body"])
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def group_by_question(items: list[dict]) -> list[list[dict]]:
|
|
55
|
+
"""Every item answering the same question, keyed by normalised text.
|
|
56
|
+
|
|
57
|
+
A second annotation is required to reuse the question unchanged (see
|
|
58
|
+
validate_gold.py), so this alone finds every annotator of a question -
|
|
59
|
+
``second_of`` does not need to be followed to group them.
|
|
60
|
+
"""
|
|
61
|
+
groups: dict[str, list[dict]] = {}
|
|
62
|
+
for item in items:
|
|
63
|
+
key = normalise(turkish_lower(item["question"]))
|
|
64
|
+
groups.setdefault(key, []).append(item)
|
|
65
|
+
return [group for group in groups.values() if len(group) >= 2]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def root_qid(group: list[dict]) -> str:
|
|
69
|
+
"""The qid other items in the group point at with ``second_of``."""
|
|
70
|
+
for item in group:
|
|
71
|
+
if not isinstance(item.get("second_of"), str):
|
|
72
|
+
return item["qid"]
|
|
73
|
+
return min(item["qid"] for item in group)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def pairwise_items(group: list[dict]) -> list[tuple[dict, dict]]:
|
|
77
|
+
"""Every combination in the group with independent annotators."""
|
|
78
|
+
return [(a, b) for a, b in combinations(group, 2)
|
|
79
|
+
if a.get("annotator") != b.get("annotator")]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def span_iou(a: str, b: str) -> float:
|
|
83
|
+
"""Jaccard similarity of the two spans' token sets."""
|
|
84
|
+
tokens_a, tokens_b = set(tokenize(a)), set(tokenize(b))
|
|
85
|
+
union = tokens_a | tokens_b
|
|
86
|
+
return len(tokens_a & tokens_b) / len(union) if union else 1.0
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def span_f1(a: str, b: str) -> float:
|
|
90
|
+
"""SQuAD-style token F1 between two raw answer spans."""
|
|
91
|
+
tokens_a, tokens_b = tokenize(a), tokenize(b)
|
|
92
|
+
if not tokens_a or not tokens_b:
|
|
93
|
+
return float(tokens_a == tokens_b)
|
|
94
|
+
overlap = sum((Counter(tokens_a) & Counter(tokens_b)).values())
|
|
95
|
+
if overlap == 0:
|
|
96
|
+
return 0.0
|
|
97
|
+
precision = overlap / len(tokens_a)
|
|
98
|
+
recall = overlap / len(tokens_b)
|
|
99
|
+
return 2 * precision * recall / (precision + recall)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def cohens_kappa(labels_a: list[bool], labels_b: list[bool]) -> float | None:
|
|
103
|
+
"""Chance-corrected agreement between two binary label lists."""
|
|
104
|
+
n = len(labels_a)
|
|
105
|
+
if n == 0:
|
|
106
|
+
return None
|
|
107
|
+
observed = sum(a == b for a, b in zip(labels_a, labels_b)) / n
|
|
108
|
+
rate_a, rate_b = sum(labels_a) / n, sum(labels_b) / n
|
|
109
|
+
expected = rate_a * rate_b + (1 - rate_a) * (1 - rate_b)
|
|
110
|
+
if expected >= 1.0:
|
|
111
|
+
return 1.0
|
|
112
|
+
return (observed - expected) / (1 - expected)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def chunk_kappa(item_a: dict, item_b: dict, doc: dict) -> dict[str, float | None]:
|
|
116
|
+
result = {}
|
|
117
|
+
for name, chunker in STRATEGIES.items():
|
|
118
|
+
chunks = chunker(doc)
|
|
119
|
+
labels_a = [is_relevant(c, item_a) for c in chunks]
|
|
120
|
+
labels_b = [is_relevant(c, item_b) for c in chunks]
|
|
121
|
+
result[name] = cohens_kappa(labels_a, labels_b)
|
|
122
|
+
return result
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def evaluate_groups(groups: list[list[dict]],
|
|
126
|
+
docs_by_id: dict[str, dict]) -> list[dict]:
|
|
127
|
+
"""One row per question, averaging every annotator pair it has."""
|
|
128
|
+
rows = []
|
|
129
|
+
for group in groups:
|
|
130
|
+
pairs = pairwise_items(group)
|
|
131
|
+
if not pairs:
|
|
132
|
+
continue
|
|
133
|
+
pair_metrics = []
|
|
134
|
+
for a, b in pairs:
|
|
135
|
+
doc = docs_by_id.get(a["doc_id"])
|
|
136
|
+
pair_metrics.append({
|
|
137
|
+
"annotators": (a.get("annotator", "gold"), b.get("annotator", "gold")),
|
|
138
|
+
"iou": span_iou(a["answer_span"], b["answer_span"]),
|
|
139
|
+
"token_f1": span_f1(a["answer_span"], b["answer_span"]),
|
|
140
|
+
"kappa": chunk_kappa(a, b, doc) if doc is not None else {},
|
|
141
|
+
})
|
|
142
|
+
rows.append({
|
|
143
|
+
"qid": root_qid(group),
|
|
144
|
+
"question": group[0]["question"],
|
|
145
|
+
"annotators": sorted({m for p in pair_metrics for m in p["annotators"]}),
|
|
146
|
+
"pairs": len(pairs),
|
|
147
|
+
"mean_iou": _mean(p["iou"] for p in pair_metrics),
|
|
148
|
+
"mean_token_f1": _mean(p["token_f1"] for p in pair_metrics),
|
|
149
|
+
"mean_kappa": {
|
|
150
|
+
name: _mean_or_none(p["kappa"].get(name) for p in pair_metrics
|
|
151
|
+
if p["kappa"].get(name) is not None)
|
|
152
|
+
for name in STRATEGIES
|
|
153
|
+
},
|
|
154
|
+
"pair_details": pair_metrics,
|
|
155
|
+
})
|
|
156
|
+
return rows
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def summarise(rows: list[dict]) -> dict:
|
|
160
|
+
"""Headline numbers: each question counts once, regardless of how many
|
|
161
|
+
annotators (and therefore pairs) it has."""
|
|
162
|
+
summary = {
|
|
163
|
+
"questions": len(rows),
|
|
164
|
+
"pairs": sum(r["pairs"] for r in rows),
|
|
165
|
+
"mean_iou": _mean(r["mean_iou"] for r in rows),
|
|
166
|
+
"mean_token_f1": _mean(r["mean_token_f1"] for r in rows),
|
|
167
|
+
}
|
|
168
|
+
for name in STRATEGIES:
|
|
169
|
+
summary[f"mean_kappa_{name}"] = _mean_or_none(
|
|
170
|
+
r["mean_kappa"][name] for r in rows if r["mean_kappa"][name] is not None)
|
|
171
|
+
return summary
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _mean(values) -> float:
|
|
175
|
+
values = list(values)
|
|
176
|
+
return sum(values) / len(values) if values else 0.0
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _mean_or_none(values) -> float | None:
|
|
180
|
+
values = list(values)
|
|
181
|
+
return sum(values) / len(values) if values else None
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def add_arguments(parser: argparse.ArgumentParser) -> None:
|
|
185
|
+
parser.add_argument("--corpus", default=str(CORPUS))
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def run(args) -> int:
|
|
189
|
+
groups = group_by_question(load_gold())
|
|
190
|
+
if not groups:
|
|
191
|
+
print("birden fazla etiketleyicisi olan soru yok")
|
|
192
|
+
return 0
|
|
193
|
+
|
|
194
|
+
corpus_path = Path(args.corpus)
|
|
195
|
+
if corpus_path.exists():
|
|
196
|
+
docs = json.loads(corpus_path.read_text(encoding="utf-8"))
|
|
197
|
+
else:
|
|
198
|
+
print(f"uyarı: {corpus_path} yok - parça düzeyinde kappa atlanıyor")
|
|
199
|
+
docs = []
|
|
200
|
+
docs_by_id = {d["doc_id"]: d for d in docs}
|
|
201
|
+
|
|
202
|
+
rows = evaluate_groups(groups, docs_by_id)
|
|
203
|
+
summary = summarise(rows)
|
|
204
|
+
|
|
205
|
+
for row in rows:
|
|
206
|
+
kappa = ", ".join(f"{k}={v:.2f}" for k, v in row["mean_kappa"].items()
|
|
207
|
+
if v is not None)
|
|
208
|
+
print(f"{row['qid']} ({', '.join(row['annotators'])}, {row['pairs']} çift): "
|
|
209
|
+
f"iou={row['mean_iou']:.2f} token_f1={row['mean_token_f1']:.2f}"
|
|
210
|
+
+ (f", kappa: {kappa}" if kappa else ""))
|
|
211
|
+
print(f"\n{summary['questions']} soru, {summary['pairs']} çift, "
|
|
212
|
+
f"ortalama IoU={summary['mean_iou']:.3f} "
|
|
213
|
+
f"ortalama token_f1={summary['mean_token_f1']:.3f}")
|
|
214
|
+
for name in STRATEGIES:
|
|
215
|
+
kappa = summary[f"mean_kappa_{name}"]
|
|
216
|
+
print(f" ortalama kappa ({name}): "
|
|
217
|
+
+ (f"{kappa:.3f}" if kappa is not None else "n/a"))
|
|
218
|
+
|
|
219
|
+
out = ROOT / "results" / "agreement.json"
|
|
220
|
+
out.parent.mkdir(exist_ok=True)
|
|
221
|
+
out.write_text(json.dumps({"summary": summary, "questions": rows},
|
|
222
|
+
ensure_ascii=False, indent=2), encoding="utf-8")
|
|
223
|
+
return 0
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def main() -> int:
|
|
227
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
228
|
+
add_arguments(parser)
|
|
229
|
+
return run(parser.parse_args())
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
if __name__ == "__main__":
|
|
233
|
+
raise SystemExit(main())
|