structverify 0.3.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.
- structverify/__init__.py +83 -0
- structverify/adaptation/__init__.py +0 -0
- structverify/adaptation/adapter_trainer.py +341 -0
- structverify/adaptation/feedback_store.py +31 -0
- structverify/adaptation/kosis_crawler.py +317 -0
- structverify/adaptation/sample_builder.py +149 -0
- structverify/adaptation/synthetic_generator.py +320 -0
- structverify/adaptation/update_embeddings.py +178 -0
- structverify/agent/__init__.py +21 -0
- structverify/agent/builder_agent.py +226 -0
- structverify/agent/conformance_agent.py +171 -0
- structverify/agent/dependency_planner.py +151 -0
- structverify/agent/indexing_agent.py +153 -0
- structverify/agent/indexing_planner.py +169 -0
- structverify/agent/integration_example.py +182 -0
- structverify/agent/loop.py +1165 -0
- structverify/agent/memory.py +207 -0
- structverify/agent/planner.py +817 -0
- structverify/agent/prompts/__init__.py +15 -0
- structverify/agent/prompts/planner_prompts.py +219 -0
- structverify/agent/prompts/reflect_prompts.py +387 -0
- structverify/agent/reflect.py +227 -0
- structverify/agent/runtime_agent.py +1272 -0
- structverify/agent/schemas.py +262 -0
- structverify/agent/source_profiler.py +229 -0
- structverify/agent/tools/__init__.py +64 -0
- structverify/agent/tools/base.py +222 -0
- structverify/agent/tools/calculate.py +244 -0
- structverify/agent/tools/catalog_search.py +859 -0
- structverify/agent/tools/deep_explore.py +293 -0
- structverify/agent/tools/explore_catalog.py +423 -0
- structverify/agent/tools/fetch_evidence.py +922 -0
- structverify/agent/tools/finish.py +423 -0
- structverify/agent/tools/meta_explore.py +267 -0
- structverify/agent/tools/query_rewriter.py +134 -0
- structverify/agent/tools/read_original.py +144 -0
- structverify/agent/tools/replan.py +365 -0
- structverify/agent/workspace.py +958 -0
- structverify/api.py +804 -0
- structverify/config/default.yaml +350 -0
- structverify/core/__init__.py +0 -0
- structverify/core/config_loader.py +30 -0
- structverify/core/pipeline.py +280 -0
- structverify/core/schemas.py +362 -0
- structverify/detection/__init__.py +26 -0
- structverify/detection/_config.py +163 -0
- structverify/detection/_llm.py +24 -0
- structverify/detection/candidate/__init__.py +1 -0
- structverify/detection/candidate/heuristic.py +60 -0
- structverify/detection/candidate/llm.py +51 -0
- structverify/detection/candidate_scorer.py +81 -0
- structverify/detection/claim_detector.py +164 -0
- structverify/detection/claims/__init__.py +1 -0
- structverify/detection/claims/worthiness.py +142 -0
- structverify/detection/domain/__init__.py +1 -0
- structverify/detection/domain/classify.py +84 -0
- structverify/detection/domain/preview.py +36 -0
- structverify/detection/domain/registry.py +99 -0
- structverify/detection/domain_classifier.py +75 -0
- structverify/detection/prompts/__init__.py +1 -0
- structverify/detection/prompts/candidate.py +38 -0
- structverify/detection/prompts/claim_worthiness.py +48 -0
- structverify/detection/prompts/domain.py +41 -0
- structverify/detection/prompts/schema.py +508 -0
- structverify/detection/prompts_loader.py +167 -0
- structverify/detection/schema/__init__.py +1 -0
- structverify/detection/schema/expand.py +83 -0
- structverify/detection/schema/induce.py +441 -0
- structverify/detection/schema/regenerate.py +162 -0
- structverify/detection/schema/temporal_hints.py +130 -0
- structverify/detection/schema/validate.py +193 -0
- structverify/detection/schema_inductor.py +112 -0
- structverify/detection/synthetic_generator.py +270 -0
- structverify/explanation/__init__.py +0 -0
- structverify/explanation/_config.py +18 -0
- structverify/explanation/_llm.py +25 -0
- structverify/explanation/explainer.py +183 -0
- structverify/explanation/fallback.py +29 -0
- structverify/explanation/formatters.py +75 -0
- structverify/explanation/prompts/__init__.py +1 -0
- structverify/explanation/prompts/match.py +27 -0
- structverify/explanation/prompts/mismatch.py +20 -0
- structverify/explanation/prompts/multihop.py +16 -0
- structverify/explanation/prompts/unverifiable.py +17 -0
- structverify/graph/__init__.py +0 -0
- structverify/graph/claim_graph.py +226 -0
- structverify/graph/document_graph.py +487 -0
- structverify/graph/graph_builder.py +238 -0
- structverify/graph/graph_multihop.py +335 -0
- structverify/graph/graph_store.py +281 -0
- structverify/graph/provenance.py +52 -0
- structverify/memory/__init__.py +44 -0
- structverify/memory/agent_memory.py +142 -0
- structverify/memory/embedder.py +69 -0
- structverify/memory/exemplar_store.py +241 -0
- structverify/memory/normalizer.py +91 -0
- structverify/memory/schema.py +119 -0
- structverify/memory/storage/__init__.py +29 -0
- structverify/memory/storage/jsonl_store.py +117 -0
- structverify/memory/working_memory.py +370 -0
- structverify/preprocessing/Dockerfile.scraper +27 -0
- structverify/preprocessing/__init__.py +0 -0
- structverify/preprocessing/extractor.py +574 -0
- structverify/preprocessing/pdf/__init__.py +16 -0
- structverify/preprocessing/pdf/fields.py +95 -0
- structverify/preprocessing/pdf/markdown.py +107 -0
- structverify/preprocessing/pdf/models.py +34 -0
- structverify/preprocessing/pdf/ocr.py +172 -0
- structverify/preprocessing/pdf/pipeline.py +74 -0
- structverify/preprocessing/pdf/reader.py +119 -0
- structverify/preprocessing/pdf/scoring.py +61 -0
- structverify/preprocessing/scraper_sandbox.py +561 -0
- structverify/preprocessing/segmenter.py +48 -0
- structverify/preprocessing/sir_builder.py +240 -0
- structverify/progress.py +591 -0
- structverify/retrieval/__init__.py +0 -0
- structverify/retrieval/base.py +208 -0
- structverify/retrieval/base_connector.py +85 -0
- structverify/retrieval/catalog_ranker.py +300 -0
- structverify/retrieval/catalog_search.py +583 -0
- structverify/retrieval/chunking.py +92 -0
- structverify/retrieval/custom_csv_source.py +386 -0
- structverify/retrieval/custom_db_source.py +396 -0
- structverify/retrieval/custom_docs_source.py +152 -0
- structverify/retrieval/dimension_resolver.py +281 -0
- structverify/retrieval/evidence_subgraph.py +63 -0
- structverify/retrieval/kosis_connector.py +1192 -0
- structverify/retrieval/kosis_relevance.py +142 -0
- structverify/retrieval/kosis_source.py +1541 -0
- structverify/retrieval/query_builder.py +72 -0
- structverify/retrieval/registry.py +133 -0
- structverify/retrieval/relevance_judge.py +141 -0
- structverify/retrieval/row_matcher.py +267 -0
- structverify/storage/__init__.py +0 -0
- structverify/storage/db_manager.py +157 -0
- structverify/storage/dwh_manager.py +92 -0
- structverify/storage/init_db.py +99 -0
- structverify/storage/raw_storage.py +29 -0
- structverify/training/__init__.py +26 -0
- structverify/training/curator.py +124 -0
- structverify/training/dataset.py +134 -0
- structverify/training/doctor.py +99 -0
- structverify/training/evalgate.py +96 -0
- structverify/training/generate.py +101 -0
- structverify/training/loop.py +116 -0
- structverify/training/recipe/train_mlx.py +99 -0
- structverify/training/recipe/train_qlora.py +104 -0
- structverify/training/tasks.py +79 -0
- structverify/utils/__init__.py +0 -0
- structverify/utils/embedding_client.py +248 -0
- structverify/utils/llm_client.py +809 -0
- structverify/utils/logger.py +81 -0
- structverify/verification/__init__.py +0 -0
- structverify/verification/_config.py +45 -0
- structverify/verification/adapters.py +405 -0
- structverify/verification/conformance.py +117 -0
- structverify/verification/decide_verdict.py +216 -0
- structverify/verification/decide_verdict_agent.py +454 -0
- structverify/verification/growth_diff.py +267 -0
- structverify/verification/row_match.py +345 -0
- structverify/verification/units.py +64 -0
- structverify/verification/verdict_thresholds.py +232 -0
- structverify/verification/verifier.py +84 -0
- structverify-0.3.0.dist-info/METADATA +903 -0
- structverify-0.3.0.dist-info/RECORD +168 -0
- structverify-0.3.0.dist-info/WHEEL +5 -0
- structverify-0.3.0.dist-info/licenses/LICENSE +21 -0
- structverify-0.3.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""structverify.training.doctor — TrainDoctor (학습 로그 이상 감지, 코어·GPU 불필요).
|
|
2
|
+
|
|
3
|
+
표준 HuggingFace `trainer_state.json`(log_history: step/loss/learning_rate/eval_loss)을 읽어
|
|
4
|
+
발산·NaN·spike·정체·overfit을 휴리스틱으로 감지하고, 자연어 진단 + 처방을 낸다.
|
|
5
|
+
표준 포맷만 보므로 TRL/unsloth/axolotl 어떤 트레이너로 학습하든 동작(백엔드 무관).
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import math
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class Diagnosis:
|
|
17
|
+
healthy: bool = True
|
|
18
|
+
issues: list[str] = field(default_factory=list) # [severity] 설명
|
|
19
|
+
prescriptions: list[str] = field(default_factory=list)
|
|
20
|
+
stats: dict[str, Any] = field(default_factory=dict)
|
|
21
|
+
|
|
22
|
+
def summary(self) -> str:
|
|
23
|
+
head = "🩺 TrainDoctor — 정상" if self.healthy else "🩺 TrainDoctor — 이상 감지"
|
|
24
|
+
lines = [head]
|
|
25
|
+
s = self.stats
|
|
26
|
+
if s:
|
|
27
|
+
lines.append(f" steps={s.get('steps')} · loss {s.get('first_loss')}→{s.get('last_loss')} "
|
|
28
|
+
f"· min={s.get('min_loss')}")
|
|
29
|
+
for i in self.issues:
|
|
30
|
+
lines.append(f" ⚠ {i}")
|
|
31
|
+
for p in self.prescriptions:
|
|
32
|
+
lines.append(f" → {p}")
|
|
33
|
+
return "\n".join(lines)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _load_history(path_or_state: Any) -> list[dict]:
|
|
37
|
+
if isinstance(path_or_state, dict):
|
|
38
|
+
return path_or_state.get("log_history", [])
|
|
39
|
+
with open(path_or_state, encoding="utf-8") as f:
|
|
40
|
+
return json.load(f).get("log_history", [])
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class TrainDoctor:
|
|
44
|
+
def diagnose(self, trainer_state: Any) -> Diagnosis:
|
|
45
|
+
hist = _load_history(trainer_state)
|
|
46
|
+
losses = [(h.get("step"), h["loss"]) for h in hist if "loss" in h]
|
|
47
|
+
evals = [(h.get("step"), h["eval_loss"]) for h in hist if "eval_loss" in h]
|
|
48
|
+
d = Diagnosis()
|
|
49
|
+
if len(losses) < 2:
|
|
50
|
+
d.healthy = False
|
|
51
|
+
d.issues.append("[정보부족] loss 기록이 2개 미만 — 학습이 거의 안 돎")
|
|
52
|
+
d.prescriptions.append("스텝 수·로깅 간격(logging_steps) 확인")
|
|
53
|
+
return d
|
|
54
|
+
|
|
55
|
+
vals = [v for _, v in losses]
|
|
56
|
+
d.stats = {
|
|
57
|
+
"steps": losses[-1][0], "first_loss": round(vals[0], 4),
|
|
58
|
+
"last_loss": round(vals[-1], 4), "min_loss": round(min(vals), 4),
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
# NaN/inf
|
|
62
|
+
if any(math.isnan(v) or math.isinf(v) for v in vals):
|
|
63
|
+
d.healthy = False
|
|
64
|
+
d.issues.append("[치명] loss에 NaN/inf — 발산")
|
|
65
|
+
d.prescriptions.append("learning_rate를 1/5로 낮추고 warmup·grad clipping(max_grad_norm=1.0) 적용")
|
|
66
|
+
return d
|
|
67
|
+
|
|
68
|
+
# 발산: 마지막이 최소보다 크게 상승
|
|
69
|
+
min_v = min(vals)
|
|
70
|
+
if vals[-1] > min_v * 1.6 and vals[-1] > vals[0]:
|
|
71
|
+
d.healthy = False
|
|
72
|
+
d.issues.append(f"[심각] loss 발산 — 최소 {min_v:.3f} 후 {vals[-1]:.3f}로 상승")
|
|
73
|
+
d.prescriptions.append("learning_rate 낮추기(÷2~5), 최소 지점 체크포인트 사용")
|
|
74
|
+
|
|
75
|
+
# spike: 국소 급등(직전 대비 2배↑)
|
|
76
|
+
spikes = [losses[i][0] for i in range(1, len(vals))
|
|
77
|
+
if vals[i] > vals[i - 1] * 2.0 and vals[i - 1] > 1e-6]
|
|
78
|
+
if spikes:
|
|
79
|
+
d.healthy = False
|
|
80
|
+
d.issues.append(f"[주의] loss spike @ step {spikes[:3]} — 특정 배치 의심")
|
|
81
|
+
d.prescriptions.append("해당 구간 데이터 재검토(DataCurator), batch shuffle·grad clipping 확인")
|
|
82
|
+
|
|
83
|
+
# 정체: 후반 개선 미미
|
|
84
|
+
tail = vals[max(0, len(vals) - 5):]
|
|
85
|
+
if len(tail) >= 3 and (max(tail) - min(tail)) < 0.01 and min_v > 0.5:
|
|
86
|
+
d.issues.append("[정보] 후반 loss 정체 — 이미 수렴했거나 lr 과소")
|
|
87
|
+
d.prescriptions.append("수렴이면 조기 종료, 아니면 lr↑ 또는 데이터 증강")
|
|
88
|
+
|
|
89
|
+
# overfit: eval_loss가 상승 전환
|
|
90
|
+
if len(evals) >= 2:
|
|
91
|
+
ev = [v for _, v in evals]
|
|
92
|
+
if ev[-1] > min(ev) * 1.15:
|
|
93
|
+
d.healthy = False
|
|
94
|
+
d.issues.append(f"[주의] overfit — eval_loss 최소 {min(ev):.3f} 후 {ev[-1]:.3f}로 상승")
|
|
95
|
+
d.prescriptions.append("early stopping, LoRA rank↓ 또는 dropout↑, 데이터 늘리기")
|
|
96
|
+
|
|
97
|
+
if d.healthy and not d.issues:
|
|
98
|
+
d.issues.append("[정상] 발산·spike·overfit 없음")
|
|
99
|
+
return d
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""structverify.training.evalgate — EvalGate (학습된 모델을 *검증 엔진으로* 자가평가).
|
|
2
|
+
|
|
3
|
+
StructVerify만 할 수 있는 셀프-루프: 라벨된 eval 셋(확정 정답)으로 base vs 파인튜닝 모델의
|
|
4
|
+
검증 정확도를 재고, 좋아졌을 때만 어댑터 채택(회귀 방지). GPU 불필요(엔진이 알아서 추론).
|
|
5
|
+
|
|
6
|
+
eval 셋 포맷(라벨): [{"claim": "...", "expected": "match|mismatch|unverifiable"}, ...]
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class EvalResult:
|
|
16
|
+
n: int = 0
|
|
17
|
+
correct: int = 0
|
|
18
|
+
by_verdict: dict[str, dict] = field(default_factory=dict) # expected → {n, correct}
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def accuracy(self) -> float:
|
|
22
|
+
return self.correct / self.n if self.n else 0.0
|
|
23
|
+
|
|
24
|
+
def summary(self) -> str:
|
|
25
|
+
parts = [f"정확도 {self.accuracy:.1%} ({self.correct}/{self.n})"]
|
|
26
|
+
for k, v in sorted(self.by_verdict.items()):
|
|
27
|
+
parts.append(f"{k} {v['correct']}/{v['n']}")
|
|
28
|
+
return " · ".join(parts)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class GateDecision:
|
|
33
|
+
before: EvalResult
|
|
34
|
+
after: EvalResult
|
|
35
|
+
margin: float
|
|
36
|
+
accepted: bool
|
|
37
|
+
reason: str
|
|
38
|
+
|
|
39
|
+
def summary(self) -> str:
|
|
40
|
+
mark = "✅ 채택" if self.accepted else "⛔ 거부"
|
|
41
|
+
return (f"🚦 EvalGate — {mark}\n"
|
|
42
|
+
f" before: {self.before.summary()}\n"
|
|
43
|
+
f" after : {self.after.summary()}\n"
|
|
44
|
+
f" Δ정확도 {self.after.accuracy - self.before.accuracy:+.1%} (margin {self.margin:+.1%}) — {self.reason}")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _claim_verdict(report: Any) -> str:
|
|
48
|
+
"""Report → 대표 판정. mismatch > match > unverifiable 우선(거짓을 놓치지 않게)."""
|
|
49
|
+
verds = [str(getattr(r, "verdict", "") or "") for r in report]
|
|
50
|
+
for pref in ("mismatch", "match", "unverifiable"):
|
|
51
|
+
if pref in verds:
|
|
52
|
+
return pref
|
|
53
|
+
return verds[0] if verds else "unverifiable"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class EvalGate:
|
|
57
|
+
"""검증 엔진(Verifier) 재사용해 라벨 셋에 대한 정확도 측정 + 채택 판정."""
|
|
58
|
+
|
|
59
|
+
def __init__(self, engine: Any):
|
|
60
|
+
self.engine = engine
|
|
61
|
+
|
|
62
|
+
def score(self, labeled: list[dict], engine: Any | None = None) -> EvalResult:
|
|
63
|
+
eng = engine or self.engine
|
|
64
|
+
res = EvalResult()
|
|
65
|
+
for row in labeled:
|
|
66
|
+
claim = str(row.get("claim", "")).strip()
|
|
67
|
+
exp = str(row.get("expected", "")).strip()
|
|
68
|
+
if not claim or not exp:
|
|
69
|
+
continue
|
|
70
|
+
try:
|
|
71
|
+
report = eng.verify(claim)
|
|
72
|
+
got = _claim_verdict(report)
|
|
73
|
+
except Exception: # noqa: BLE001 — 개별 실패는 오답 처리
|
|
74
|
+
got = "unverifiable"
|
|
75
|
+
b = res.by_verdict.setdefault(exp, {"n": 0, "correct": 0})
|
|
76
|
+
res.n += 1
|
|
77
|
+
b["n"] += 1
|
|
78
|
+
if got == exp:
|
|
79
|
+
res.correct += 1
|
|
80
|
+
b["correct"] += 1
|
|
81
|
+
return res
|
|
82
|
+
|
|
83
|
+
def evaluate(self, labeled: list[dict], *, tuned_engine: Any,
|
|
84
|
+
margin: float = 0.0) -> GateDecision:
|
|
85
|
+
"""base(self.engine) vs tuned_engine 정확도 비교 → 채택/거부.
|
|
86
|
+
|
|
87
|
+
tuned_engine = 어댑터 로드된 Verifier (provider=local + adapter 서빙).
|
|
88
|
+
margin: after가 before보다 이만큼은 나아야 채택(기본 0 = 안 나빠지면 채택).
|
|
89
|
+
"""
|
|
90
|
+
before = self.score(labeled, self.engine)
|
|
91
|
+
after = self.score(labeled, tuned_engine)
|
|
92
|
+
gain = after.accuracy - before.accuracy
|
|
93
|
+
accepted = gain >= margin
|
|
94
|
+
reason = ("개선됨" if gain > 0 else "동일" if gain == 0 else "악화") + \
|
|
95
|
+
(" → 채택" if accepted else " → 거부(회귀 방지)")
|
|
96
|
+
return GateDecision(before, after, margin, accepted, reason)
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""structverify.training.generate — 합성 학습 데이터 생성 (증류/부트스트랩).
|
|
2
|
+
|
|
3
|
+
초기엔 학습 데이터가 없다. 강한 클라우드 모델로 (주장→추출), (주장+근거→판정) 예시를
|
|
4
|
+
대량 생성해 작은 로컬 모델에 증류(distill)한다. 회사 지표 목록을 주면 그 도메인에 맞춰 생성.
|
|
5
|
+
|
|
6
|
+
from structverify.training import generate_dataset
|
|
7
|
+
n = generate_dataset(llm_config, out="gen.jsonl",
|
|
8
|
+
indicators=["매출","고객수","평균주문금액", ...], domain="유통", n=80)
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import re
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from .tasks import build_example
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _extract_json_array(text: str) -> list[dict]:
|
|
20
|
+
m = re.search(r"\[[\s\S]*\]", text)
|
|
21
|
+
if not m:
|
|
22
|
+
return []
|
|
23
|
+
try:
|
|
24
|
+
data = json.loads(m.group(0))
|
|
25
|
+
return data if isinstance(data, list) else []
|
|
26
|
+
except json.JSONDecodeError:
|
|
27
|
+
return []
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
_SCHEMA_GEN = """너는 학습 데이터 생성기다. {domain} 회사의 수치 주장 문장 {k}개를 만들고,
|
|
31
|
+
각 문장에서 검증 대상 수치를 추출한 결과를 함께 제시하라.
|
|
32
|
+
|
|
33
|
+
다양하게: 지표({inds} 등), 단위(명·건·개·억 달러·%·원), 시점(연도 유/무),
|
|
34
|
+
그리고 **한국어 숫자 표기를 반드시 섞어라**(예: "1,500만"=15000000, "23만 8천"=238000, "2조 1,791억").
|
|
35
|
+
|
|
36
|
+
JSON 배열로만:
|
|
37
|
+
[{{"claim": "문장", "extraction": {{"indicator":"지표", "value": 숫자, "unit":"단위",
|
|
38
|
+
"time_period":"시점 또는 null", "population":"대상/지역 또는 null"}}}}, ...]"""
|
|
39
|
+
|
|
40
|
+
_VERDICT_GEN = """너는 학습 데이터 생성기다. {domain} 회사 맥락의 (주장, 근거 데이터, 판정) 쌍 {k}개.
|
|
41
|
+
근거값과 주장이 *일치(match)* 하는 것과 *어긋나는(mismatch)* 것을 대략 반반 섞어라.
|
|
42
|
+
근거는 원시값(달러/명/건) 또는 %.
|
|
43
|
+
|
|
44
|
+
JSON 배열로만:
|
|
45
|
+
[{{"claim":"주장 문장", "evidence_value": 숫자, "evidence_unit":"단위",
|
|
46
|
+
"verdict":"match 또는 mismatch", "reason":"한 줄 근거"}}, ...]"""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
async def _agen(llm, prompt: str) -> list[dict]:
|
|
50
|
+
try:
|
|
51
|
+
raw = await llm.generate(prompt=prompt, system_prompt="JSON 배열만 출력.", model_tier="light")
|
|
52
|
+
except Exception: # noqa: BLE001
|
|
53
|
+
return []
|
|
54
|
+
return _extract_json_array(raw)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
async def agenerate_dataset(llm_config: dict, out: str = "gen.jsonl", *,
|
|
58
|
+
indicators: list[str] | None = None, domain: str = "회사",
|
|
59
|
+
n: int = 80, batch: int = 8) -> int:
|
|
60
|
+
"""비동기 생성. schema:verdict ≈ 6:4 비율로 n개 근처까지."""
|
|
61
|
+
from structverify.utils.llm_client import LLMClient
|
|
62
|
+
from .dataset import write_jsonl
|
|
63
|
+
llm = LLMClient(config=llm_config)
|
|
64
|
+
inds = ", ".join((indicators or ["매출", "고객수", "주문건수", "평균주문금액", "순매출", "공급업체수"])[:12])
|
|
65
|
+
rows: list[dict] = []
|
|
66
|
+
n_schema, n_verdict = int(n * 0.6), int(n * 0.4)
|
|
67
|
+
|
|
68
|
+
made = 0
|
|
69
|
+
while made < n_schema:
|
|
70
|
+
arr = await _agen(llm, _SCHEMA_GEN.format(domain=domain, k=min(batch, n_schema - made), inds=inds))
|
|
71
|
+
for e in arr:
|
|
72
|
+
ex = e.get("extraction") or {}
|
|
73
|
+
if e.get("claim") and ex.get("indicator") is not None and ex.get("value") is not None:
|
|
74
|
+
rows.append(build_example("schema", claim_text=str(e["claim"]),
|
|
75
|
+
output=json.dumps(ex, ensure_ascii=False)))
|
|
76
|
+
made += 1
|
|
77
|
+
if not arr:
|
|
78
|
+
break
|
|
79
|
+
|
|
80
|
+
made = 0
|
|
81
|
+
while made < n_verdict:
|
|
82
|
+
arr = await _agen(llm, _VERDICT_GEN.format(domain=domain, k=min(batch, n_verdict - made)))
|
|
83
|
+
for e in arr:
|
|
84
|
+
if e.get("claim") and e.get("verdict") in ("match", "mismatch"):
|
|
85
|
+
rows.append(build_example("verdict", claim_text=str(e["claim"]),
|
|
86
|
+
evidence_value=e.get("evidence_value"),
|
|
87
|
+
evidence_unit=e.get("evidence_unit", ""),
|
|
88
|
+
output=json.dumps({"verdict": e["verdict"],
|
|
89
|
+
"reason": str(e.get("reason", ""))[:200]},
|
|
90
|
+
ensure_ascii=False)))
|
|
91
|
+
made += 1
|
|
92
|
+
if not arr:
|
|
93
|
+
break
|
|
94
|
+
|
|
95
|
+
return write_jsonl(rows, out)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def generate_dataset(llm_config: dict, out: str = "gen.jsonl", **kw: Any) -> int:
|
|
99
|
+
"""동기 래퍼. 반환: 생성된 예시 수."""
|
|
100
|
+
import asyncio
|
|
101
|
+
return asyncio.run(agenerate_dataset(llm_config, out, **kw))
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""structverify.training.loop — LearningLoop (감독 학습 오케스트레이션).
|
|
2
|
+
|
|
3
|
+
OSS 원칙대로: *차별화되는 부분(데이터·감독)은 소유*, *학습 자체는 위임*.
|
|
4
|
+
· export/curate/diagnose/evaluate → 코어(여기), GPU 불필요
|
|
5
|
+
· train → 표준 데이터셋을 넘겨 생태계(TRL/unsloth/axolotl/회사 파이프라인)에 위임
|
|
6
|
+
|
|
7
|
+
loop = LearningLoop(engine)
|
|
8
|
+
loop.add_seed(); loop.add_reports(reports) # 데이터 모으기
|
|
9
|
+
ds = loop.prepare("train.jsonl") # ① export + 🕵 DataCurator
|
|
10
|
+
loop.train(ds, "./out", run=False) # ② 레시피/명령 핸드오프 (또는 run=True)
|
|
11
|
+
loop.diagnose("./out/trainer_state.json") # 🩺 TrainDoctor
|
|
12
|
+
gate = loop.evaluate(eval_set, tuned_engine) # 🚦 EvalGate
|
|
13
|
+
if gate.accepted: engine.use_adapter("./out") # 채택
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
import shutil
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from .curator import DataCurator, CurationReport
|
|
22
|
+
from .dataset import build_example, export_dataset, read_jsonl, write_jsonl, _SEED_SPECS
|
|
23
|
+
from .doctor import TrainDoctor, Diagnosis
|
|
24
|
+
from .evalgate import EvalGate, GateDecision
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class LearningLoop:
|
|
28
|
+
def __init__(self, engine: Any = None, base_model: str = "unsloth/Qwen2.5-7B-Instruct"):
|
|
29
|
+
self.engine = engine
|
|
30
|
+
self.base_model = base_model
|
|
31
|
+
self._rows: list[dict] = []
|
|
32
|
+
self.curator = DataCurator()
|
|
33
|
+
self.doctor = TrainDoctor()
|
|
34
|
+
|
|
35
|
+
# ── 데이터 모으기 ──
|
|
36
|
+
def add_seed(self) -> "LearningLoop":
|
|
37
|
+
self._rows.extend(build_example(**spec) for spec in _SEED_SPECS)
|
|
38
|
+
return self
|
|
39
|
+
|
|
40
|
+
def add_reports(self, reports, include=("verdict",)) -> "LearningLoop":
|
|
41
|
+
tmp = "_ll_tmp.jsonl"
|
|
42
|
+
export_dataset(reports, tmp, include=include)
|
|
43
|
+
self._rows.extend(read_jsonl(tmp))
|
|
44
|
+
os.remove(tmp)
|
|
45
|
+
return self
|
|
46
|
+
|
|
47
|
+
def add_jsonl(self, path: str) -> "LearningLoop":
|
|
48
|
+
self._rows.extend(read_jsonl(path))
|
|
49
|
+
return self
|
|
50
|
+
|
|
51
|
+
# ── ① 준비: export + DataCurator ──
|
|
52
|
+
def prepare(self, out: str = "train.jsonl", *, verbose: bool = True) -> tuple[str, CurationReport]:
|
|
53
|
+
clean, rep = self.curator.curate(self._rows)
|
|
54
|
+
write_jsonl(clean, out)
|
|
55
|
+
if verbose:
|
|
56
|
+
print(rep.summary())
|
|
57
|
+
print(f"[LearningLoop] clean 데이터셋 저장 → {out} ({rep.kept}건)")
|
|
58
|
+
return out, rep
|
|
59
|
+
|
|
60
|
+
# ── ② 학습: 위임 (레시피 핸드오프 or subprocess 실행) ──
|
|
61
|
+
def train(self, dataset: str, output: str = "./adapter", *, backend: str = "auto",
|
|
62
|
+
run: bool = False, steps: int = 60) -> dict:
|
|
63
|
+
"""학습을 위임. run=False(기본)면 실행 명령만 반환(핸드오프).
|
|
64
|
+
|
|
65
|
+
backend: "qlora"(NVIDIA/3060·T4) | "mlx"(Apple Silicon) | "auto"(플랫폼 감지).
|
|
66
|
+
run=True는 해당 extra + 하드웨어가 있을 때만 — 별도 프로세스(샌드박스)로 실행.
|
|
67
|
+
"""
|
|
68
|
+
import platform
|
|
69
|
+
if backend == "auto":
|
|
70
|
+
backend = "mlx" if platform.machine() == "arm64" and platform.system() == "Darwin" else "qlora"
|
|
71
|
+
_dir = os.path.join(os.path.dirname(__file__), "recipe")
|
|
72
|
+
if backend == "mlx":
|
|
73
|
+
recipe = os.path.join(_dir, "train_mlx.py")
|
|
74
|
+
model = self.base_model if "mlx" in self.base_model else "mlx-community/Qwen2.5-7B-Instruct-4bit"
|
|
75
|
+
cmd = ["python", "-m", "structverify.training.recipe.train_mlx",
|
|
76
|
+
"--model", model, "--data", os.path.abspath(dataset),
|
|
77
|
+
"--out", os.path.abspath(output), "--iters", str(steps)]
|
|
78
|
+
need = 'pip install "structverify[training-mac]" (Apple Silicon)'
|
|
79
|
+
_probe = "mlx_lm"
|
|
80
|
+
else:
|
|
81
|
+
recipe = os.path.join(_dir, "train_qlora.py")
|
|
82
|
+
cmd = ["python", "-m", "structverify.training.recipe.train_qlora",
|
|
83
|
+
"--model", self.base_model, "--data", os.path.abspath(dataset),
|
|
84
|
+
"--out", os.path.abspath(output), "--max-steps", str(steps)]
|
|
85
|
+
need = 'pip install "structverify[training]" (NVIDIA GPU)'
|
|
86
|
+
_probe = "torch"
|
|
87
|
+
info = {"backend": backend, "recipe": recipe, "command": " ".join(cmd), "output": output}
|
|
88
|
+
if not run:
|
|
89
|
+
print(f"[LearningLoop] 핸드오프({backend}) — 학습 머신에서 아래 실행:")
|
|
90
|
+
print(" " + info["command"])
|
|
91
|
+
return info
|
|
92
|
+
try:
|
|
93
|
+
__import__(_probe)
|
|
94
|
+
except Exception: # noqa: BLE001
|
|
95
|
+
raise RuntimeError(f"run=True에는 학습 백엔드가 필요합니다 → {need}. "
|
|
96
|
+
"또는 run=False로 명령만 받아 별도 실행하세요.")
|
|
97
|
+
import subprocess
|
|
98
|
+
import sys
|
|
99
|
+
print(f"[LearningLoop] 샌드박스 학습 시작 (backend={backend}) …")
|
|
100
|
+
subprocess.run([sys.executable] + cmd[1:], check=True)
|
|
101
|
+
return info
|
|
102
|
+
|
|
103
|
+
# ── 🩺 진단 ──
|
|
104
|
+
def diagnose(self, trainer_state: str, *, verbose: bool = True) -> Diagnosis:
|
|
105
|
+
d = self.doctor.diagnose(trainer_state)
|
|
106
|
+
if verbose:
|
|
107
|
+
print(d.summary())
|
|
108
|
+
return d
|
|
109
|
+
|
|
110
|
+
# ── 🚦 자가평가 ──
|
|
111
|
+
def evaluate(self, eval_set: list[dict], *, tuned_engine: Any,
|
|
112
|
+
margin: float = 0.0, verbose: bool = True) -> GateDecision:
|
|
113
|
+
gate = EvalGate(self.engine).evaluate(eval_set, tuned_engine=tuned_engine, margin=margin)
|
|
114
|
+
if verbose:
|
|
115
|
+
print(gate.summary())
|
|
116
|
+
return gate
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
"""StructVerify LoRA 학습 레시피 — Apple Silicon (M1~M4, MLX 백엔드).
|
|
3
|
+
|
|
4
|
+
Mac에는 CUDA가 없어 unsloth/bitsandbytes(QLoRA)가 안 돈다. 대신 Apple 네이티브 **MLX**로
|
|
5
|
+
LoRA 파인튜닝. 36GB 통합메모리면 7B도 여유(4bit MLX 모델이면 더).
|
|
6
|
+
|
|
7
|
+
준비 (Mac):
|
|
8
|
+
pip install "structverify[training-mac]" # mlx-lm
|
|
9
|
+
# 또는: pip install mlx-lm
|
|
10
|
+
|
|
11
|
+
실행:
|
|
12
|
+
python -m structverify.training.recipe.train_mlx \
|
|
13
|
+
--model mlx-community/Qwen2.5-7B-Instruct-4bit \
|
|
14
|
+
--data train.jsonl --out ./adapter --iters 100
|
|
15
|
+
|
|
16
|
+
산출: {out}/ 에 LoRA 어댑터 + trainer_state.json (mlx 로그를 표준 포맷으로 변환 → TrainDoctor 호환)
|
|
17
|
+
"""
|
|
18
|
+
import argparse
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import re
|
|
22
|
+
import shutil
|
|
23
|
+
import subprocess
|
|
24
|
+
import sys
|
|
25
|
+
import tempfile
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def to_mlx_data(chat_jsonl: str, data_dir: str, valid_frac: float = 0.1):
|
|
29
|
+
"""우리 chat jsonl → MLX가 먹는 data 폴더(train.jsonl/valid.jsonl, messages 포맷)."""
|
|
30
|
+
os.makedirs(data_dir, exist_ok=True)
|
|
31
|
+
rows = []
|
|
32
|
+
with open(chat_jsonl, encoding="utf-8") as f:
|
|
33
|
+
for line in f:
|
|
34
|
+
line = line.strip()
|
|
35
|
+
if line:
|
|
36
|
+
rows.append({"messages": json.loads(line)["messages"]})
|
|
37
|
+
n_valid = max(1, int(len(rows) * valid_frac))
|
|
38
|
+
valid, train = rows[:n_valid], rows[n_valid:] or rows
|
|
39
|
+
for name, part in (("train", train), ("valid", valid)):
|
|
40
|
+
with open(os.path.join(data_dir, f"{name}.jsonl"), "w", encoding="utf-8") as f:
|
|
41
|
+
for r in part:
|
|
42
|
+
f.write(json.dumps(r, ensure_ascii=False) + "\n")
|
|
43
|
+
return len(train), len(valid)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def parse_mlx_log(text: str) -> list[dict]:
|
|
47
|
+
"""mlx_lm.lora stdout("Iter N: Train loss X, ... Val loss Y")→ HF log_history 포맷."""
|
|
48
|
+
hist = []
|
|
49
|
+
for m in re.finditer(r"Iter\s+(\d+):.*?Train loss\s+([\d.]+)", text):
|
|
50
|
+
hist.append({"step": int(m.group(1)), "loss": float(m.group(2))})
|
|
51
|
+
for m in re.finditer(r"Iter\s+(\d+):\s*Val loss\s+([\d.]+)", text):
|
|
52
|
+
hist.append({"step": int(m.group(1)), "eval_loss": float(m.group(2))})
|
|
53
|
+
return sorted(hist, key=lambda h: h.get("step", 0))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def main():
|
|
57
|
+
ap = argparse.ArgumentParser()
|
|
58
|
+
ap.add_argument("--model", default="mlx-community/Qwen2.5-7B-Instruct-4bit",
|
|
59
|
+
help="MLX 모델 (mlx-community 허브). 메모리 빠듯하면 -3B-4bit 로.")
|
|
60
|
+
ap.add_argument("--data", required=True, help="chat jsonl (structverify export)")
|
|
61
|
+
ap.add_argument("--out", default="./adapter")
|
|
62
|
+
ap.add_argument("--iters", type=int, default=100)
|
|
63
|
+
ap.add_argument("--batch", type=int, default=1)
|
|
64
|
+
ap.add_argument("--rank", type=int, default=8)
|
|
65
|
+
ap.add_argument("--lr", type=float, default=1e-4)
|
|
66
|
+
args = ap.parse_args()
|
|
67
|
+
|
|
68
|
+
os.makedirs(args.out, exist_ok=True)
|
|
69
|
+
data_dir = tempfile.mkdtemp(prefix="sv_mlx_")
|
|
70
|
+
ntr, nva = to_mlx_data(args.data, data_dir)
|
|
71
|
+
print(f"[train_mlx] MLX 데이터 변환: train {ntr} · valid {nva} → {data_dir}")
|
|
72
|
+
|
|
73
|
+
cmd = [
|
|
74
|
+
sys.executable, "-m", "mlx_lm", "lora",
|
|
75
|
+
"--model", args.model, "--train",
|
|
76
|
+
"--data", data_dir, "--adapter-path", args.out,
|
|
77
|
+
"--iters", str(args.iters), "--batch-size", str(args.batch),
|
|
78
|
+
"--num-layers", "8", "--learning-rate", str(args.lr),
|
|
79
|
+
"--steps-per-report", "1", # TrainDoctor용 촘촘한 loss
|
|
80
|
+
]
|
|
81
|
+
print("[train_mlx] 실행:", " ".join(cmd))
|
|
82
|
+
proc = subprocess.run(cmd, capture_output=True, text=True)
|
|
83
|
+
log = proc.stdout + "\n" + proc.stderr
|
|
84
|
+
print(log[-2000:])
|
|
85
|
+
shutil.rmtree(data_dir, ignore_errors=True)
|
|
86
|
+
|
|
87
|
+
# mlx 로그 → 표준 trainer_state.json (TrainDoctor 호환)
|
|
88
|
+
hist = parse_mlx_log(log)
|
|
89
|
+
with open(os.path.join(args.out, "trainer_state.json"), "w", encoding="utf-8") as f:
|
|
90
|
+
json.dump({"log_history": hist}, f, ensure_ascii=False)
|
|
91
|
+
if proc.returncode != 0:
|
|
92
|
+
print(f"⚠ mlx_lm.lora 종료코드 {proc.returncode} — 로그 확인")
|
|
93
|
+
sys.exit(proc.returncode)
|
|
94
|
+
print(f"\n✅ 완료 — 어댑터: {args.out} (trainer_state.json {len(hist)}개 기록)")
|
|
95
|
+
print(" 다음: TrainDoctor 진단, EvalGate 채택 판정")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
if __name__ == "__main__":
|
|
99
|
+
main()
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
"""StructVerify QLoRA 학습 레시피 — RTX 3060 12GB / Colab T4 검증됨(사양 기준).
|
|
3
|
+
|
|
4
|
+
이 파일은 GPU 머신에서 실행하는 *샌드박스 레시피*다. 코어 라이브러리는 이 파일을 만들지도,
|
|
5
|
+
직접 import 하지도 않는다(무거운 deps 격리). 표준 chat jsonl 만 먹으므로 데이터는 어떤
|
|
6
|
+
경로로 만들어도 됨.
|
|
7
|
+
|
|
8
|
+
준비 (GPU 머신):
|
|
9
|
+
pip install "structverify[training]" # unsloth·trl·peft·bitsandbytes·torch
|
|
10
|
+
# 또는: pip install unsloth trl peft bitsandbytes accelerate datasets
|
|
11
|
+
|
|
12
|
+
실행:
|
|
13
|
+
python train_qlora.py --model unsloth/Qwen2.5-7B-Instruct \
|
|
14
|
+
--data train.jsonl --out ./adapter --max-steps 60
|
|
15
|
+
|
|
16
|
+
산출: {out}/ 에 LoRA 어댑터 + trainer_state.json (→ TrainDoctor가 읽음)
|
|
17
|
+
"""
|
|
18
|
+
import argparse
|
|
19
|
+
import json
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def load_chat_dataset(path):
|
|
23
|
+
from datasets import Dataset
|
|
24
|
+
rows = []
|
|
25
|
+
with open(path, encoding="utf-8") as f:
|
|
26
|
+
for line in f:
|
|
27
|
+
line = line.strip()
|
|
28
|
+
if line:
|
|
29
|
+
rows.append(json.loads(line))
|
|
30
|
+
# messages 만 남김 (task 필드는 학습에 불필요)
|
|
31
|
+
return Dataset.from_list([{"messages": r["messages"]} for r in rows])
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def main():
|
|
35
|
+
ap = argparse.ArgumentParser()
|
|
36
|
+
ap.add_argument("--model", default="unsloth/Qwen2.5-7B-Instruct",
|
|
37
|
+
help="4bit 베이스 모델 (unsloth 허브 권장). 3060 6GB면 3B 계열로.")
|
|
38
|
+
ap.add_argument("--data", required=True, help="chat jsonl (structverify가 export)")
|
|
39
|
+
ap.add_argument("--out", default="./adapter")
|
|
40
|
+
ap.add_argument("--max-steps", type=int, default=60)
|
|
41
|
+
ap.add_argument("--lr", type=float, default=2e-4)
|
|
42
|
+
ap.add_argument("--rank", type=int, default=16)
|
|
43
|
+
ap.add_argument("--seq-len", type=int, default=2048)
|
|
44
|
+
ap.add_argument("--batch", type=int, default=1)
|
|
45
|
+
ap.add_argument("--grad-accum", type=int, default=8)
|
|
46
|
+
args = ap.parse_args()
|
|
47
|
+
|
|
48
|
+
# unsloth: 3060/T4에서 QLoRA 2배 빠름 + 메모리 절약
|
|
49
|
+
from unsloth import FastLanguageModel
|
|
50
|
+
from trl import SFTTrainer, SFTConfig
|
|
51
|
+
|
|
52
|
+
model, tokenizer = FastLanguageModel.from_pretrained(
|
|
53
|
+
model_name=args.model,
|
|
54
|
+
max_seq_length=args.seq_len,
|
|
55
|
+
load_in_4bit=True, # QLoRA — 12GB VRAM에 7B 적재
|
|
56
|
+
)
|
|
57
|
+
model = FastLanguageModel.get_peft_model(
|
|
58
|
+
model,
|
|
59
|
+
r=args.rank,
|
|
60
|
+
target_modules=["q_proj", "k_proj", "v_proj", "o_proj",
|
|
61
|
+
"gate_proj", "up_proj", "down_proj"],
|
|
62
|
+
lora_alpha=args.rank * 2,
|
|
63
|
+
lora_dropout=0.0,
|
|
64
|
+
bias="none",
|
|
65
|
+
use_gradient_checkpointing="unsloth",
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
ds = load_chat_dataset(args.data)
|
|
69
|
+
|
|
70
|
+
def fmt(ex):
|
|
71
|
+
return {"text": tokenizer.apply_chat_template(
|
|
72
|
+
ex["messages"], tokenize=False, add_generation_prompt=False)}
|
|
73
|
+
ds = ds.map(fmt)
|
|
74
|
+
|
|
75
|
+
trainer = SFTTrainer(
|
|
76
|
+
model=model, tokenizer=tokenizer, train_dataset=ds,
|
|
77
|
+
args=SFTConfig(
|
|
78
|
+
output_dir=args.out,
|
|
79
|
+
per_device_train_batch_size=args.batch,
|
|
80
|
+
gradient_accumulation_steps=args.grad_accum,
|
|
81
|
+
warmup_steps=5,
|
|
82
|
+
max_steps=args.max_steps,
|
|
83
|
+
learning_rate=args.lr,
|
|
84
|
+
logging_steps=1, # TrainDoctor용 촘촘한 loss 기록
|
|
85
|
+
max_grad_norm=1.0, # 발산 방지
|
|
86
|
+
optim="adamw_8bit",
|
|
87
|
+
lr_scheduler_type="linear",
|
|
88
|
+
seed=42,
|
|
89
|
+
dataset_text_field="text",
|
|
90
|
+
report_to="none",
|
|
91
|
+
),
|
|
92
|
+
)
|
|
93
|
+
trainer.train()
|
|
94
|
+
|
|
95
|
+
# 어댑터 + trainer_state.json 저장 (TrainDoctor가 읽음)
|
|
96
|
+
model.save_pretrained(args.out)
|
|
97
|
+
tokenizer.save_pretrained(args.out)
|
|
98
|
+
trainer.state.save_to_json(f"{args.out}/trainer_state.json")
|
|
99
|
+
print(f"\n✅ 완료 — 어댑터: {args.out} (trainer_state.json 포함)")
|
|
100
|
+
print(" 다음: TrainDoctor로 진단, EvalGate로 채택 여부 판정")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
if __name__ == "__main__":
|
|
104
|
+
main()
|