File size: 10,709 Bytes
c4c9192
 
 
 
 
 
 
 
 
 
 
5ee3a5e
c4c9192
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5ee3a5e
c4c9192
 
 
5ee3a5e
 
c4c9192
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5ee3a5e
c4c9192
 
 
 
 
 
5ee3a5e
 
 
 
 
 
 
 
 
 
c4c9192
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
268
269
270
271
272
273
274
275
276
277
278
"""P Formula v1의 실제 symbol group을 formula-relative 128×19 학습 tensor로 만든다."""

from __future__ import annotations

from dataclasses import dataclass
import math
from typing import Any, Sequence

import torch
from torch import Tensor

from .ink06_canonical import CanonicalInk06, canonicalize_ink06
from .trajectory_sequence import visual_label_family


@dataclass(frozen=True, slots=True)
class PFormulaTensorBatch06:
    """필요 변수: symbol tensor·정답·identity·결측 flag. 작동 원리: metric 분모를 잃지 않는 split batch를 보존한다."""

    features: Tensor
    truths: tuple[str, ...]
    writer_ids: tuple[str, ...]
    device_ids: tuple[str, ...]
    source_ids: tuple[str, ...]
    timestamp_missing: Tensor
    pressure_missing: Tensor


def _formula_box06(record: dict[str, Any]) -> tuple[float, float, float, float]:
    """필요 변수: 한 P Formula record. 작동 원리: 모든 symbol stroke의 실제 ink bbox를 계산한다."""

    points = [
        point
        for symbol in record["symbols"]
        for stroke in symbol["strokes"]
        for point in stroke
    ]
    if not points:
        raise ValueError(f"formula {record.get('formula_id')}에 point가 없습니다.")
    try:
        xs = [float(point["x"]) for point in points]
        ys = [float(point["y"]) for point in points]
    except (KeyError, TypeError, ValueError) as error:
        raise ValueError("P Formula point x/y가 유효하지 않습니다.") from error
    if not all(math.isfinite(value) for value in (*xs, *ys)):
        raise ValueError("P Formula point에 비유한 좌표가 있습니다.")
    return min(xs), min(ys), max(xs), max(ys)


def p_formula_symbol_ink06(
    symbol: dict[str, Any],
    *,
    formula_box: tuple[float, float, float, float],
) -> CanonicalInk06:
    """필요 변수: 정답 symbol·전체 formula bbox. 작동 원리: 원본 필순과 formula-relative 위치를 canonical ink로 보존한다."""

    left, top, right, bottom = formula_box
    width = max(right - left, 1e-5)
    height = max(bottom - top, 1e-5)
    strokes = []
    observed_timestamps = True
    for order, raw_stroke in enumerate(symbol["strokes"]):
        if not isinstance(raw_stroke, list) or not raw_stroke:
            raise ValueError("P Formula symbol stroke가 비어 있습니다.")
        points = []
        for point in raw_stroke:
            timestamp = point.get("t")
            observed_timestamps = observed_timestamps and timestamp is not None
            points.append({
                "x": float(point["x"]) - left,
                "y": float(point["y"]) - top,
                "t_ms": None if timestamp is None else float(timestamp),
            })
        strokes.append({"stroke_id": order, "order": order, "points": points})
    return canonicalize_ink06(
        strokes,
        canvas_width=width,
        canvas_height=height,
        source_modality="online",
        trust_timestamps=observed_timestamps,
    )


def p_formula_symbol_feature06(
    symbol: dict[str, Any],
    *,
    formula_box: tuple[float, float, float, float],
) -> Tensor:
    """필요 변수: 정답 symbol·전체 formula bbox. 작동 원리: canonical ink의 128×19 feature를 학습 tensor로 변환한다."""

    ink = p_formula_symbol_ink06(symbol, formula_box=formula_box)
    return torch.from_numpy(ink.features)


def materialize_p_formula_split06(
    records: Sequence[dict[str, Any]],
    *,
    allowed_labels: Sequence[str],
) -> PFormulaTensorBatch06:
    """필요 변수: 한 split P Formula records·378 vocabulary. 작동 원리: truth group별 tensor와 identity/결측 slice를 함께 만든다."""

    allowed = set(str(label) for label in allowed_labels)
    if len(allowed) != len(allowed_labels):
        raise ValueError("P Formula vocabulary에 중복 label이 있습니다.")
    features: list[Tensor] = []
    truths: list[str] = []
    writers: list[str] = []
    devices: list[str] = []
    sources: list[str] = []
    timestamp_missing: list[bool] = []
    pressure_missing: list[bool] = []
    for record in records:
        formula_box = _formula_box06(record)
        writer = str(record["writer_id"])
        device = str(record["device_id"])
        source = str(record["source_id"])
        for symbol in record["symbols"]:
            label = str(symbol["token"])
            if label not in allowed:
                raise ValueError(f"0.6 vocabulary 밖 P Formula token입니다: {label}")
            points = [point for stroke in symbol["strokes"] for point in stroke]
            features.append(
                p_formula_symbol_feature06(symbol, formula_box=formula_box),
            )
            truths.append(label)
            writers.append(writer)
            devices.append(device)
            sources.append(source)
            timestamp_missing.append(any(point.get("t") is None for point in points))
            pressure_missing.append(any(point.get("pressure") is None for point in points))
    if not features:
        raise ValueError("P Formula split에 지원 symbol이 없습니다.")
    return PFormulaTensorBatch06(
        features=torch.stack(features),
        truths=tuple(truths),
        writer_ids=tuple(writers),
        device_ids=tuple(devices),
        source_ids=tuple(sources),
        timestamp_missing=torch.tensor(timestamp_missing, dtype=torch.bool),
        pressure_missing=torch.tensor(pressure_missing, dtype=torch.bool),
    )


def p_formula_release_metrics06(
    exact_logits: Tensor,
    exact_targets: Tensor,
    *,
    labels: Sequence[str],
    writer_ids: Sequence[str],
    source_ids: Sequence[str],
    timestamp_missing: Tensor,
    pressure_missing: Tensor,
) -> dict[str, Any]:
    """필요 변수: exact logit/정답·identity·결측 mask. 작동 원리: top-k·macro-F1·writer/source floor·결측 하락을 같은 분모로 계산한다."""

    samples = len(exact_targets)
    if (
        exact_logits.ndim != 2
        or exact_logits.shape[0] != samples
        or len(writer_ids) != samples
        or len(source_ids) != samples
        or len(timestamp_missing) != samples
        or len(pressure_missing) != samples
    ):
        raise ValueError("P Formula release metric 분모가 서로 다릅니다.")
    top_k = min(5, exact_logits.shape[1])
    top = exact_logits.topk(top_k, dim=1).indices
    prediction = top[:, 0]
    correct = prediction.eq(exact_targets)
    top5_correct = top.eq(exact_targets.unsqueeze(1)).any(dim=1)
    visual_correct = torch.tensor([
        visual_label_family(str(labels[int(predicted)]))
        == visual_label_family(str(labels[int(truth)]))
        for predicted, truth in zip(
            prediction.tolist(),
            exact_targets.tolist(),
            strict=True,
        )
    ])
    supported = sorted(set(exact_targets.tolist()))
    f1_values = []
    for target in supported:
        truth_mask = exact_targets == target
        predicted_mask = prediction == target
        true_positive = int((truth_mask & predicted_mask).sum())
        false_positive = int((~truth_mask & predicted_mask).sum())
        false_negative = int((truth_mask & ~predicted_mask).sum())
        precision = true_positive / max(true_positive + false_positive, 1)
        recall = true_positive / max(true_positive + false_negative, 1)
        f1_values.append(
            2 * precision * recall / max(precision + recall, 1e-12),
        )

    def identity_slice06(values: Sequence[str]) -> dict[str, Any]:
        """필요 변수: writer 또는 source ID. 작동 원리: identity별 top-1과 floor/p10을 집계한다."""

        indices: dict[str, list[int]] = {}
        for index, value in enumerate(values):
            indices.setdefault(str(value), []).append(index)
        accuracies = {
            identity: float(correct[rows].float().mean())
            for identity, rows in indices.items()
        }
        ordered = sorted(accuracies.values())
        p10_index = min(int(len(ordered) * 0.10), max(len(ordered) - 1, 0))
        return {
            "count": len(accuracies),
            "floor": min(ordered) if ordered else 0.0,
            "p10": ordered[p10_index] if ordered else 0.0,
            "accuracies": dict(sorted(accuracies.items())),
        }

    overall = float(correct.float().mean())

    def missing_slice06(mask: Tensor) -> dict[str, float | int | None]:
        """필요 변수: symbol별 결측 mask. 작동 원리: 결측 분모와 전체 대비 top-1 하락을 분리한다."""

        count = int(mask.sum())
        if not count:
            return {"samples": 0, "top1": None, "drop_pp": None}
        accuracy = float(correct[mask].float().mean())
        return {
            "samples": count,
            "top1": accuracy,
            "drop_pp": (overall - accuracy) * 100.0,
        }

    return {
        "samples": samples,
        "exact_top1": overall,
        "exact_top5": float(top5_correct.float().mean()),
        "visual_family_top1": float(visual_correct.float().mean()),
        "macro_f1": sum(f1_values) / max(len(f1_values), 1),
        "supported_labels": len(supported),
        "writer": identity_slice06(writer_ids),
        "source": identity_slice06(source_ids),
        "missing_slices": {
            "timestamp": missing_slice06(timestamp_missing),
            "pressure": missing_slice06(pressure_missing),
        },
    }


def p_formula_seed_gate06(
    metrics: dict[str, Any],
    *,
    top1_minimum: float = 0.92,
    top5_minimum: float = 0.99,
    macro_f1_minimum: float = 0.90,
    writer_floor_minimum: float = 0.75,
    missing_drop_maximum_pp: float = 3.0,
) -> dict[str, Any]:
    """필요 변수: test release metric·명세 threshold. 작동 원리: 모든 정확도와 결측 slice를 AND gate로 판정한다."""

    missing_pass = all(
        row["drop_pp"] is None
        or float(row["drop_pp"]) <= missing_drop_maximum_pp
        for row in metrics["missing_slices"].values()
    )
    checks = {
        "exact_top1": float(metrics["exact_top1"]) >= top1_minimum,
        "exact_top5": float(metrics["exact_top5"]) >= top5_minimum,
        "macro_f1": float(metrics["macro_f1"]) >= macro_f1_minimum,
        "writer_floor": float(metrics["writer"]["floor"]) >= writer_floor_minimum,
        "missing_slices": missing_pass,
    }
    return {
        "checks": checks,
        "passed": all(checks.values()),
        "thresholds": {
            "exact_top1": top1_minimum,
            "exact_top5": top5_minimum,
            "macro_f1": macro_f1_minimum,
            "writer_floor": writer_floor_minimum,
            "missing_drop_maximum_pp": missing_drop_maximum_pp,
        },
    }