| """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, |
| }, |
| } |
|
|