File size: 8,897 Bytes
9c84f9d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
"""Scoring utilities ported from chosun-projects."""


def issue_count_to_score(count: int) -> float:
    """Convert issue count to 0-1 score. Used by title and image_caption eval."""
    mapping = {0: 1.0, 1: 0.75, 2: 0.5, 3: 0.25}
    return mapping.get(count, 0.0)


def error_count_to_severity(count: int) -> str:
    """Convert error count to 7-tier severity. Used by body eval."""
    mapping = {0: "perfect", 1: "excellent", 2: "good", 3: "okay", 4: "fair", 5: "poor"}
    return mapping.get(count, "severe")


def severity_to_score(severity: str) -> float:
    """Convert severity label to 0-1 score."""
    mapping = {
        "perfect": 1.0,
        "excellent": 0.9,
        "good": 0.7,
        "okay": 0.5,
        "fair": 0.3,
        "poor": 0.1,
        "severe": 0.0,
    }
    return mapping.get(severity, 0.0)


def score_to_severity(score: float, tiers: int = 5) -> str:
    """Convert 0-1 score to severity label."""
    if tiers == 7:
        if score >= 0.95:
            return "perfect"
        elif score >= 0.8:
            return "excellent"
        elif score >= 0.6:
            return "good"
        elif score >= 0.4:
            return "okay"
        elif score >= 0.2:
            return "fair"
        elif score >= 0.05:
            return "poor"
        else:
            return "severe"
    else:  # tiers == 5
        if score >= 0.9:
            return "perfect"
        elif score >= 0.7:
            return "excellent"
        elif score >= 0.4:
            return "good"
        elif score >= 0.2:
            return "okay"
        else:
            return "poor"


def weighted_average(scores: dict[str, float], weights: dict[str, float]) -> float:
    """Compute weighted average of scores."""
    total = sum(scores.get(k, 0.0) * w for k, w in weights.items())
    return total


def compute_lcs(seq1: list[str], seq2: list[str]) -> list[tuple[int, int]]:
    """Compute Longest Common Subsequence. Returns list of (i, j) index pairs.

    Ported from chosun-projects proofread/eval/scoring.py.
    """
    m, n = len(seq1), len(seq2)
    dp = [[0] * (n + 1) for _ in range(m + 1)]

    for i in range(1, m + 1):
        for j in range(1, n + 1):
            if seq1[i - 1] == seq2[j - 1]:
                dp[i][j] = dp[i - 1][j - 1] + 1
            else:
                dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])

    # Backtrack to find LCS indices
    result = []
    i, j = m, n
    while i > 0 and j > 0:
        if seq1[i - 1] == seq2[j - 1]:
            result.append((i - 1, j - 1))
            i -= 1
            j -= 1
        elif dp[i - 1][j] > dp[i][j - 1]:
            i -= 1
        else:
            j -= 1

    return list(reversed(result))


def find_diffs(
    original_tokens: list[str],
    corrected_tokens: list[str],
    lcs_pairs: list[tuple[int, int]],
) -> list[dict]:
    """Find differences between original and corrected using LCS alignment.

    Returns list of diffs with keys: orig_tokens, corrected_tokens,
    orig_start, orig_end, corr_start, corr_end.
    """
    diffs = []
    prev_i, prev_j = -1, -1

    for curr_i, curr_j in lcs_pairs:
        if curr_i > prev_i + 1 or curr_j > prev_j + 1:
            orig_slice = original_tokens[prev_i + 1 : curr_i]
            corr_slice = corrected_tokens[prev_j + 1 : curr_j]
            if orig_slice or corr_slice:
                diffs.append({
                    "orig_tokens": orig_slice,
                    "corrected_tokens": corr_slice,
                    "orig_start": prev_i + 1,
                    "orig_end": curr_i,
                    "corr_start": prev_j + 1,
                    "corr_end": curr_j,
                })
        prev_i, prev_j = curr_i, curr_j

    # Handle trailing differences
    orig_tail = original_tokens[prev_i + 1 :]
    corr_tail = corrected_tokens[prev_j + 1 :]
    if orig_tail or corr_tail:
        diffs.append({
            "orig_tokens": orig_tail,
            "corrected_tokens": corr_tail,
            "orig_start": prev_i + 1,
            "orig_end": len(original_tokens),
            "corr_start": prev_j + 1,
            "corr_end": len(corrected_tokens),
        })

    return diffs


def merge_adjacent_diffs(diffs: list[dict], max_gap: int = 2) -> list[dict]:
    """Merge adjacent diffs that are within max_gap tokens of each other."""
    if not diffs:
        return []
    merged = [diffs[0]]
    for d in diffs[1:]:
        prev = merged[-1]
        if d["orig_start"] - prev["orig_end"] <= max_gap:
            prev["orig_tokens"].extend(d["orig_tokens"])
            prev["corrected_tokens"].extend(d["corrected_tokens"])
            prev["orig_end"] = d["orig_end"]
            prev["corr_end"] = d["corr_end"]
        else:
            merged.append(d)
    return merged


def compute_proofread_metrics(
    original: str, golden: str, predicted: str
) -> dict[str, int | float]:
    """Compute proofread evaluation metrics using token-level LCS.

    Returns: {tp, fp, missing, redundant, precision, recall}
    """
    orig_tokens = original.split()
    gold_tokens = golden.split()
    pred_tokens = predicted.split()

    # Find golden diffs (what should have been corrected)
    gold_lcs = compute_lcs(orig_tokens, gold_tokens)
    gold_diffs = find_diffs(orig_tokens, gold_tokens, gold_lcs)
    gold_diffs = merge_adjacent_diffs(gold_diffs)

    # Find predicted diffs (what was actually corrected)
    pred_lcs = compute_lcs(orig_tokens, pred_tokens)
    pred_diffs = find_diffs(orig_tokens, pred_tokens, pred_lcs)
    pred_diffs = merge_adjacent_diffs(pred_diffs)

    # Compare diffs using 2-pass matching
    tp = 0
    fp = 0

    gold_by_start = {d["orig_start"]: d for d in gold_diffs}
    pred_by_start = {d["orig_start"]: d for d in pred_diffs}

    # 1st pass: exact orig_start position matching
    matched_gold = set()
    matched_pred = set()

    for start in set(gold_by_start.keys()) & set(pred_by_start.keys()):
        gold_corr = " ".join(gold_by_start[start]["corrected_tokens"])
        pred_corr = " ".join(pred_by_start[start]["corrected_tokens"])
        if gold_corr == pred_corr:
            tp += 1
        else:
            fp += 1
        matched_gold.add(start)
        matched_pred.add(start)

    # 2nd pass: content-based matching for unmatched diffs
    # Handles cases where merge_adjacent_diffs grouped differently due to
    # extra corrections shifting token positions.
    # Match if golden's orig_tokens are a subset of (or equal to) pred's orig_tokens
    # and the golden correction appears within the pred correction.
    unmatched_gold = [d for d in gold_diffs if d["orig_start"] not in matched_gold]
    unmatched_pred = [d for d in pred_diffs if d["orig_start"] not in matched_pred]

    for g in list(unmatched_gold):
        g_orig = " ".join(g["orig_tokens"])
        g_corr = " ".join(g["corrected_tokens"])

        candidates = []
        for p in unmatched_pred:
            p_orig = " ".join(p["orig_tokens"])
            p_corr = " ".join(p["corrected_tokens"])

            # Check if golden's original text is contained in pred's original text
            # (merge may have grouped more tokens together in pred)
            if g_orig in p_orig or p_orig in g_orig:
                # Check if the golden correction appears in pred correction
                if g_corr in p_corr:
                    candidates.append((p, True))   # correction matches
                else:
                    candidates.append((p, False))  # same spot, different correction

        if not candidates:
            continue

        # Prefer matching corrections, then closest position
        candidates.sort(key=lambda x: (not x[1], abs(x[0]["orig_start"] - g["orig_start"])))
        best, corr_matches = candidates[0]

        if corr_matches:
            tp += 1
        else:
            fp += 1

        unmatched_gold.remove(g)
        unmatched_pred.remove(best)

    missing = len(unmatched_gold)
    redundant = len(unmatched_pred)

    # Compute precision and recall
    # Default 100 only when nothing to correct AND nothing was corrected (perfect no-op)
    # Default 0 when there were things to correct but model did nothing, or vice versa
    no_golden = (tp + fp + missing) == 0   # nothing to correct
    no_pred = (tp + fp + redundant) == 0   # model corrected nothing

    if no_golden and no_pred:
        precision, recall = 100.0, 100.0  # perfect: nothing to fix, nothing changed
    else:
        precision = (tp / (tp + fp + redundant) * 100) if not no_pred else 0.0
        recall = (tp / (tp + fp + missing) * 100) if not no_golden else 0.0

    f1 = (2 * precision * recall / (precision + recall)) if (precision + recall) > 0 else 0.0

    return {
        "tp": tp,
        "fp": fp,
        "missing": missing,
        "redundant": redundant,
        "precision": precision,
        "recall": recall,
        "f1": f1,
    }