File size: 11,671 Bytes
2948983
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Exact OCR anchor와 수학 Tray 계약을 lattice positive/negative score로 공동 검증한다."""

from __future__ import annotations

import argparse
from datetime import datetime, timezone
import json
from pathlib import Path

import numpy as np

from math_grid_drawer.research.cross_visual import CrossVisualModel
from math_grid_drawer.research.equality_visual import EqualityVisualModel
from math_grid_drawer.research.math_tray import fraction_tray_boundary_penalties
from math_grid_drawer.research.segmentation_lattice import select_lattice_partition
from math_grid_drawer.research.tray_joint import (
    TrayJointWeights,
    adjusted_logits,
    candidate_signals,
    component_competition_penalties,
    local_baseline_boundary_penalties,
)
from scripts.crohme_lattice_common import load_cache_for_samples, load_cached_split, writer_fit_validation
from scripts.evaluate_crohme_lattice_ocr_fusion import _fit_geometry, _score
from scripts.train_crohme_segmentation_lattice_joint_selector import _metrics


def _prepared_signals(
    samples: list[dict], cached: list[dict], model, *, strict_equality: bool = False,
    equality_model: EqualityVisualModel | None = None, cross_model: CrossVisualModel | None = None,
    cross_gap_ratio: float = 0.40,
    multistroke_family_boost: float = 6.0,
) -> list[dict]:
    """필요 변수: 원본·v2 cache·geometry model·cross gap. 작동 원리: base fusion과 구조 signal을 한 번 계산한다."""

    output = []
    for sample, scored in zip(samples, _score(cached, model, ocr_weight=0.5), strict=True):
        partition = select_lattice_partition(scored["candidates"], scored["logits"], scored["stroke_count"], group_bias=-2.0)
        tray_signal, symbol_signal, infix_signal = candidate_signals(
            sample["profiled_strokes"], scored["candidates"], scored["ocr_labels"],
            scored["features"], partition, ocr_families=scored.get("ocr_families"),
            strict_equality=strict_equality, equality_model=equality_model,
            cross_model=cross_model, cross_gap_ratio=cross_gap_ratio,
            multistroke_family_boost=multistroke_family_boost,
        )
        fraction_penalty = fraction_tray_boundary_penalties(scored["candidates"], sample["profiled_strokes"])
        competition_penalty = component_competition_penalties(
            scored["candidates"], scored["features"], mode="joint",
        )
        raw_local_baseline_penalty = local_baseline_boundary_penalties(
            sample["profiled_strokes"], scored["candidates"], scored["ocr_labels"],
            scored["features"], partition, ocr_families=scored.get("ocr_families"),
        )
        local_baseline_penalty = raw_local_baseline_penalty.copy()
        # 신뢰할 수 있는 완성 다획 기호는 구조 경계와 겹쳐도 기존 positive evidence를 우선한다.
        protected = (tray_signal > 0.0) | (symbol_signal > 0.0) | (infix_signal > 0.0)
        local_baseline_penalty[protected] = 0.0
        output.append({
            **scored, "tray_signal": tray_signal, "symbol_signal": symbol_signal,
            "infix_signal": infix_signal, "fraction_penalty": fraction_penalty,
            "competition_penalty": competition_penalty,
            "raw_local_baseline_penalty": raw_local_baseline_penalty,
            "local_baseline_penalty": local_baseline_penalty,
        })
    return output


def _weighted(
    rows: list[dict], *, tray_weight: float, symbol_weight: float,
    fraction_weight: float, infix_weight: float = 0.0, competition_weight: float = 0.0,
    local_baseline_weight: float = 0.0,
) -> list[dict]:
    """필요 변수: 사전 계산 signal·가중치. 작동 원리: positive 구조와 세 negative guard를 결합한다."""

    weights = TrayJointWeights(
        tray=tray_weight, symbol=symbol_weight, fraction=fraction_weight,
        infix=infix_weight, competition=competition_weight,
        local_baseline=local_baseline_weight,
    )
    return [{**row, "logits": adjusted_logits(
        row["logits"], row["tray_signal"], row["symbol_signal"], row["fraction_penalty"], weights,
        row["infix_signal"], row["competition_penalty"], row["local_baseline_penalty"],
    )} for row in rows]


def main() -> None:
    """필요 변수: CROHME train/test·v2 cache. 작동 원리: writer-validation에서 joint 가중치를 고정하고 official test에 한 번 적용한다."""

    parser = argparse.ArgumentParser(description="Evaluate CROHME Tray joint selector")
    parser.add_argument("--train-root", type=Path, required=True)
    parser.add_argument("--test-root", type=Path, required=True)
    parser.add_argument("--cache-dir", type=Path, required=True)
    parser.add_argument("--bundle", type=Path, required=True)
    parser.add_argument("--output", type=Path, required=True)
    parser.add_argument("--profile", default="median_height_32")
    parser.add_argument("--equality-model", type=Path, help="선택적 equality visual JSON head")
    parser.add_argument("--cross-model", type=Path, help="선택적 cross visual JSON head")
    parser.add_argument("--cross-gap-ratio", type=float, default=0.40, help="cross head 사전 bbox gap/수식 높이 비율")
    parser.add_argument("--multistroke-family-boost", type=float, default=6.0, help="OCR family 호환 다획 후보의 추가 symbol signal")
    parser.add_argument("--fixed-selected", action="store_true", help="기존 validation 선택값 4/4/8/-2를 재검증하고 sweep을 생략")
    parser.add_argument("--sweep-infix", action="store_true", help="기존 가중치는 고정하고 x/= 구조 가중치만 validation 선택")
    equality_mode = parser.add_mutually_exclusive_group()
    equality_mode.add_argument("--strict-equality", dest="strict_equality", action="store_true", help="= 후보에 부분식 parser 계약 적용")
    equality_mode.add_argument("--relaxed-equality", dest="strict_equality", action="store_false", help="=를 수식 완결성과 독립된 shape로 인식")
    parser.set_defaults(strict_equality=False)
    args = parser.parse_args()
    equality_model = EqualityVisualModel.load(args.equality_model) if args.equality_model else None
    cross_model = CrossVisualModel.load(args.cross_model) if args.cross_model else None
    fit, validation = writer_fit_validation(args.train_root, args.profile)
    model = _fit_geometry(fit)
    validation_cache = load_cache_for_samples(
        validation, args.cache_dir, split="validation", profile=args.profile, bundle=args.bundle, version=2,
    )
    validation_rows = _prepared_signals(
        validation, validation_cache, model, strict_equality=args.strict_equality,
        equality_model=equality_model,
        cross_model=cross_model, cross_gap_ratio=args.cross_gap_ratio,
        multistroke_family_boost=args.multistroke_family_boost,
    )
    if args.sweep_infix:
        fixed = TrayJointWeights()
        trials = []
        for infix_weight in (0.0, 0.5, 1.0, 2.0, 4.0, 6.0, 8.0, 12.0, 16.0):
            trials.append({
                "tray_weight": fixed.tray, "symbol_weight": fixed.symbol,
                "fraction_weight": fixed.fraction, "infix_weight": infix_weight,
                "group_bias": fixed.group_bias,
                "metrics": _metrics(_weighted(
                    validation_rows, tray_weight=fixed.tray, symbol_weight=fixed.symbol,
                    fraction_weight=fixed.fraction, infix_weight=infix_weight,
                ), fixed.group_bias),
            })
        winner = max(trials, key=lambda row: (row["metrics"]["exact_partition"], row["metrics"]["pair_f1"]))
    elif args.fixed_selected:
        fixed = TrayJointWeights()
        winner = {
            "tray_weight": fixed.tray, "symbol_weight": fixed.symbol,
            "fraction_weight": fixed.fraction, "infix_weight": fixed.infix, "group_bias": fixed.group_bias,
            "metrics": _metrics(_weighted(
                validation_rows, tray_weight=fixed.tray, symbol_weight=fixed.symbol,
                fraction_weight=fixed.fraction, infix_weight=fixed.infix,
            ), fixed.group_bias),
        }
    else:
        trials = []
        for tray_weight in (0.0, 0.5, 1.0, 2.0, 4.0, 8.0):
            for symbol_weight in (0.0, 0.5, 1.0, 2.0, 4.0):
                for fraction_weight in (0.0, 8.0):
                    weighted = _weighted(
                        validation_rows, tray_weight=tray_weight,
                        symbol_weight=symbol_weight, fraction_weight=fraction_weight,
                    )
                    for bias in (-2.5, -2.0, -1.5, -1.0):
                        trials.append({
                            "tray_weight": tray_weight, "symbol_weight": symbol_weight,
                            "fraction_weight": fraction_weight, "group_bias": bias,
                            "infix_weight": 0.0,
                            "metrics": _metrics(weighted, bias),
                        })
        winner = max(trials, key=lambda row: (row["metrics"]["exact_partition"], row["metrics"]["pair_f1"]))
    test, test_cache = load_cached_split(
        args.test_root, args.cache_dir, split="official_test", profile=args.profile,
        bundle=args.bundle, version=2,
    )
    test_rows = _prepared_signals(
        test, test_cache, model, strict_equality=args.strict_equality,
        equality_model=equality_model,
        cross_model=cross_model, cross_gap_ratio=args.cross_gap_ratio,
        multistroke_family_boost=args.multistroke_family_boost,
    )
    weighted_test = _weighted(
        test_rows, tray_weight=float(winner["tray_weight"]), symbol_weight=float(winner["symbol_weight"]),
        fraction_weight=float(winner["fraction_weight"]),
        infix_weight=float(winner.get("infix_weight", 0.0)),
    )
    report = {
        "experiment": "R-CROHME-TRAY-JOINT-SELECTOR-001",
        "generated_at": datetime.now(timezone.utc).isoformat(), "track": "R_noncommercial_only",
        "selection_mode": "infix_validation_sweep" if args.sweep_infix else ("fixed_refactor_verification" if args.fixed_selected else "writer_validation_sweep"),
        "strict_equality": args.strict_equality,
        "equality_model": str(args.equality_model) if args.equality_model else None,
        "cross_model": str(args.cross_model) if args.cross_model else None,
        "selected": winner, "official_test": _metrics(weighted_test, float(winner["group_bias"])),
        "validation_trials": trials if args.sweep_infix else None,
        "signal_coverage": {
            "validation_tray_candidates": int(sum(np.count_nonzero(row["tray_signal"]) for row in validation_rows)),
            "validation_symbol_candidates": int(sum(np.count_nonzero(row["symbol_signal"]) for row in validation_rows)),
            "test_tray_candidates": int(sum(np.count_nonzero(row["tray_signal"]) for row in test_rows)),
            "test_symbol_candidates": int(sum(np.count_nonzero(row["symbol_signal"]) for row in test_rows)),
            "validation_infix_candidates": int(sum(np.count_nonzero(row["infix_signal"]) for row in validation_rows)),
            "test_infix_candidates": int(sum(np.count_nonzero(row["infix_signal"]) for row in test_rows)),
        },
        "reference_test": {"exact_partition": 0.4836065574, "pair_f1": 0.86628},
        "product_validation": False,
        "interpretation_limit": "HWRT expanded top-label + CROHME R-track Tray joint selector",
    }
    args.output.parent.mkdir(parents=True, exist_ok=True)
    args.output.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
    print(json.dumps(report, ensure_ascii=False, indent=2))


if __name__ == "__main__":
    main()