Initial commit
This commit is contained in:
183
train/classifier_dataset.py
Normal file
183
train/classifier_dataset.py
Normal file
@@ -0,0 +1,183 @@
|
||||
"""
|
||||
files/json/{LABEL}/{파일명}/page_xxx.json 구조를 읽어
|
||||
페이지 단위로 샘플을 생성하여 캐시 저장
|
||||
문서 1개 = 샘플 N개 (페이지 수만큼)
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import pickle
|
||||
from pathlib import Path
|
||||
|
||||
from loguru import logger
|
||||
|
||||
import config
|
||||
|
||||
# JSON_DIR = os.getenv("CLASSIFIER_JSON_DIR", "./files/json")
|
||||
# CACHE_PATH = os.getenv("CLASSIFIER_CACHE_PATH", "./files/classifier_dataset.pkl")
|
||||
# MIN_WORDS = int(os.getenv("CLASSIFIER_MIN_WORDS", "20")) # 페이지당 최소 단어 수
|
||||
|
||||
JSON_DIR = config.CLASSIFIER_JSON_DIR
|
||||
CACHE_PATH = config.CLASSIFIER_CACHE_PATH
|
||||
MIN_WORDS = config.CLASSIFIER_MIN_WORDS
|
||||
|
||||
_status: dict = {"status": "idle", "message": "", "total": 0}
|
||||
|
||||
|
||||
def get_status() -> dict:
|
||||
return _status
|
||||
|
||||
|
||||
# def build() -> None:
|
||||
# global _status
|
||||
# try:
|
||||
# _status = {"status": "building", "message": "JSON 파일 스캔 중...", "total": 0}
|
||||
# json_path = Path(JSON_DIR)
|
||||
#
|
||||
# if not json_path.exists():
|
||||
# raise FileNotFoundError(f"JSON 디렉토리 없음: {JSON_DIR}")
|
||||
#
|
||||
# data = []
|
||||
# skip_count = 0
|
||||
#
|
||||
# for label_dir in sorted(json_path.iterdir()):
|
||||
# if not label_dir.is_dir():
|
||||
# continue
|
||||
# label = label_dir.name
|
||||
# label_count = 0
|
||||
#
|
||||
# for doc_dir in sorted(label_dir.iterdir()):
|
||||
# if not doc_dir.is_dir():
|
||||
# continue
|
||||
#
|
||||
# for page_file in sorted(doc_dir.glob("page_*.json")):
|
||||
# try:
|
||||
# with open(page_file, encoding="utf-8") as f:
|
||||
# d = json.load(f)
|
||||
# words = d.get("words", [])
|
||||
# except Exception as e:
|
||||
# logger.warning(f"파일 읽기 실패 {page_file}: {e}")
|
||||
# continue
|
||||
#
|
||||
# # 단어 수 미달 페이지 제외
|
||||
# if len(words) < MIN_WORDS:
|
||||
# print(f"skip file : {len(words)} : {page_file}")
|
||||
# skip_count += 1
|
||||
# continue
|
||||
#
|
||||
# data.append({
|
||||
# "text": " ".join(words),
|
||||
# "label": label,
|
||||
# "src": str(page_file), # 디버깅용
|
||||
# })
|
||||
# label_count += 1
|
||||
#
|
||||
# _status["message"] = f"{label}: {label_count}페이지 샘플 추가 (누적 {len(data)}개)"
|
||||
# logger.info(_status["message"])
|
||||
#
|
||||
# if not data:
|
||||
# raise ValueError("수집된 데이터가 없습니다.")
|
||||
#
|
||||
# os.makedirs(os.path.dirname(CACHE_PATH), exist_ok=True)
|
||||
# with open(CACHE_PATH, "wb") as f:
|
||||
# pickle.dump(data, f)
|
||||
#
|
||||
# _status = {
|
||||
# "status": "done",
|
||||
# "message": f"데이터셋 생성 완료 (단어 {MIN_WORDS}개 미만 {skip_count}페이지 제외)",
|
||||
# "total": len(data),
|
||||
# }
|
||||
# logger.info(f"데이터셋 저장: {CACHE_PATH} ({len(data)}개, 제외 {skip_count}개)")
|
||||
#
|
||||
# except Exception as e:
|
||||
# _status = {"status": "error", "message": str(e), "total": 0}
|
||||
# logger.exception(f"데이터셋 생성 실패: {e}")
|
||||
|
||||
|
||||
def _process_page_file(page_file: Path, label: str, data: list, skip_count: int) -> tuple[int, int]:
|
||||
"""단일 page_*.json 파일 처리. (label_count 증가분, skip_count) 반환"""
|
||||
try:
|
||||
with open(page_file, encoding="utf-8") as f:
|
||||
d = json.load(f)
|
||||
words = d.get("words", [])
|
||||
except Exception as e:
|
||||
logger.warning(f"파일 읽기 실패 {page_file}: {e}")
|
||||
return 0, skip_count
|
||||
|
||||
if len(words) < MIN_WORDS:
|
||||
print(f"skip file : {len(words)} : {page_file}")
|
||||
return 0, skip_count + 1
|
||||
|
||||
data.append({"text": " ".join(words), "label": label, "src": str(page_file)})
|
||||
return 1, skip_count
|
||||
|
||||
|
||||
def _process_label_dir(label_dir: Path, data: list, skip_count: int) -> tuple[int, int]:
|
||||
"""label 폴더 처리. label_count, skip_count 반환"""
|
||||
label = label_dir.name
|
||||
label_count = 0
|
||||
|
||||
# label 폴더 직속 page_*.json
|
||||
for page_file in sorted(label_dir.glob("*.json")):
|
||||
added, skip_count = _process_page_file(page_file, label, data, skip_count)
|
||||
label_count += added
|
||||
|
||||
# 하위 doc_dir 폴더
|
||||
for doc_dir in sorted(label_dir.iterdir()):
|
||||
if not doc_dir.is_dir():
|
||||
continue
|
||||
for page_file in sorted(doc_dir.glob("*.json")):
|
||||
added, skip_count = _process_page_file(page_file, label, data, skip_count)
|
||||
label_count += added
|
||||
|
||||
return label_count, skip_count
|
||||
|
||||
|
||||
def build() -> None:
|
||||
global _status
|
||||
try:
|
||||
_status = {"status": "building", "message": "JSON 파일 스캔 중...", "total": 0}
|
||||
json_path = Path(JSON_DIR)
|
||||
|
||||
if not json_path.exists():
|
||||
raise FileNotFoundError(f"JSON 디렉토리 없음: {JSON_DIR}")
|
||||
|
||||
data = []
|
||||
skip_count = 0
|
||||
|
||||
for label_dir in sorted(json_path.iterdir()):
|
||||
if not label_dir.is_dir():
|
||||
continue
|
||||
|
||||
label_count, skip_count = _process_label_dir(label_dir, data, skip_count)
|
||||
|
||||
_status["message"] = f"{label_dir.name}: {label_count}페이지 샘플 추가 (누적 {len(data)}개)"
|
||||
logger.info(_status["message"])
|
||||
|
||||
if not data:
|
||||
raise ValueError("수집된 데이터가 없습니다.")
|
||||
|
||||
os.makedirs(os.path.dirname(CACHE_PATH), exist_ok=True)
|
||||
with open(CACHE_PATH, "wb") as f:
|
||||
pickle.dump(data, f)
|
||||
|
||||
_status = {
|
||||
"status": "done",
|
||||
"message": f"데이터셋 생성 완료 (단어 {MIN_WORDS}개 미만 {skip_count}페이지 제외)",
|
||||
"total": len(data),
|
||||
}
|
||||
logger.info(f"데이터셋 저장: {CACHE_PATH} ({len(data)}개, 제외 {skip_count}개)")
|
||||
|
||||
except Exception as e:
|
||||
_status = {"status": "error", "message": str(e), "total": 0}
|
||||
logger.exception(f"데이터셋 생성 실패: {e}")
|
||||
|
||||
|
||||
def load() -> list[dict]:
|
||||
if not os.path.exists(CACHE_PATH):
|
||||
return []
|
||||
try:
|
||||
with open(CACHE_PATH, "rb") as f:
|
||||
return pickle.load(f)
|
||||
except Exception as e:
|
||||
logger.error(f"데이터셋 로드 실패: {e}")
|
||||
return []
|
||||
215
train/classifier_trainer.py
Normal file
215
train/classifier_trainer.py
Normal file
@@ -0,0 +1,215 @@
|
||||
"""
|
||||
classifier_dataset.load() 로 데이터 로드 후 TF-IDF + SVM 학습
|
||||
"""
|
||||
import random
|
||||
|
||||
from collections import Counter
|
||||
|
||||
from loguru import logger
|
||||
from sklearn.calibration import CalibratedClassifierCV
|
||||
from sklearn.feature_extraction.text import TfidfVectorizer
|
||||
from sklearn.metrics import classification_report
|
||||
from sklearn.model_selection import train_test_split
|
||||
from sklearn.pipeline import Pipeline
|
||||
from sklearn.svm import LinearSVC
|
||||
|
||||
from config import SERVICE
|
||||
from train import classifier_dataset
|
||||
from utils import classifier_model_store
|
||||
|
||||
_status: dict = {"status": "idle", "message": ""}
|
||||
|
||||
|
||||
def get_status() -> dict:
|
||||
return _status
|
||||
|
||||
|
||||
def augment_text(text: str, n: int = 25) -> list:
|
||||
"""텍스트 증강 - 단어 순서 변경, 일부 단어 제거"""
|
||||
words = text.split()
|
||||
augmented = []
|
||||
|
||||
for _ in range(n):
|
||||
aug = words.copy()
|
||||
|
||||
# 1. 랜덤 단어 10% 제거
|
||||
drop_count = max(1, int(len(aug) * 0.1))
|
||||
for _ in range(drop_count):
|
||||
if aug:
|
||||
aug.pop(random.randint(0, len(aug) - 1))
|
||||
|
||||
# 2. 랜덤 단어 일부 순서 섞기
|
||||
if len(aug) > 5:
|
||||
idx = random.randint(0, len(aug) - 3)
|
||||
aug[idx], aug[idx + 1] = aug[idx + 1], aug[idx]
|
||||
|
||||
augmented.append(" ".join(aug))
|
||||
|
||||
return augmented
|
||||
|
||||
|
||||
def run_train() -> None:
|
||||
global _status
|
||||
try:
|
||||
_status = {"status": "training", "message": "데이터셋 로딩 중..."}
|
||||
|
||||
data = classifier_dataset.load()
|
||||
if not data:
|
||||
raise ValueError("데이터셋이 없습니다. /classifier/dataset/build 먼저 실행하세요.")
|
||||
|
||||
texts = [d["text"] for d in data]
|
||||
labels = [d["label"] for d in data]
|
||||
|
||||
# ── 특정 클래스 데이터 증강 ──────────────────────
|
||||
if SERVICE == "amko":
|
||||
AUGMENT_LABELS = ["AMKOR TECHNOLOGY TAIWAN_B"] # 증강할 클래스
|
||||
AUGMENT_TARGET = 30 # 목표 샘플 수
|
||||
|
||||
augmented_texts, augmented_labels = [], []
|
||||
label_groups = {}
|
||||
for t, l in zip(texts, labels):
|
||||
label_groups.setdefault(l, []).append(t)
|
||||
|
||||
for label in AUGMENT_LABELS:
|
||||
samples = label_groups.get(label, [])
|
||||
current_count = len(samples)
|
||||
|
||||
if current_count < AUGMENT_TARGET:
|
||||
need = AUGMENT_TARGET - current_count
|
||||
logger.info(f"{label}: {current_count}개 → {AUGMENT_TARGET}개 증강 ({need}개 추가)")
|
||||
|
||||
for i in range(need):
|
||||
base_text = samples[i % current_count] # 기존 샘플 순환
|
||||
aug = augment_text(base_text, n=1)[0]
|
||||
augmented_texts.append(aug)
|
||||
augmented_labels.append(label)
|
||||
|
||||
texts = texts + augmented_texts
|
||||
labels = labels + augmented_labels
|
||||
logger.info(f"증강 후 클래스별 샘플: {Counter(labels)}")
|
||||
# ────────────────────────────────────────────────
|
||||
|
||||
# # ── 클래스 언더샘플링 ─────────────────────────
|
||||
# MAX_SAMPLES_PER_CLASS = 20 # B타입 8개의 2~3배 수준으로 제한
|
||||
#
|
||||
# balanced_data = []
|
||||
# class_counts = Counter(labels)
|
||||
#
|
||||
# for label in set(labels):
|
||||
# class_texts = [t for t, l in zip(texts, labels) if l == label]
|
||||
# # MAX_SAMPLES_PER_CLASS 초과 시 랜덤 샘플링
|
||||
# if len(class_texts) > MAX_SAMPLES_PER_CLASS:
|
||||
# class_texts = random.sample(class_texts, MAX_SAMPLES_PER_CLASS)
|
||||
# balanced_data.extend([(t, label) for t in class_texts])
|
||||
#
|
||||
# texts, labels = zip(*balanced_data)
|
||||
# texts, labels = list(texts), list(labels)
|
||||
# logger.info(f"언더샘플링 후: {Counter(labels)}")
|
||||
|
||||
# # ── 핵심 키워드 가중치 부여 (특정 클래스만) ──────────────────────
|
||||
# KEYWORD_BOOST_BY_LABEL = {
|
||||
# "AMKOR TECHNOLOGY TAIWAN_A": { # A타입에만
|
||||
# "Process Fee": 5,
|
||||
# "Total Process Fee": 5,
|
||||
# "Consigned Value": 5,
|
||||
# "Total Process Value": 5,
|
||||
# },
|
||||
# "AMKOR TECHNOLOGY TAIWAN_B": { # B타입에만
|
||||
# "PACKING LIST SUMMARY": 5,
|
||||
# "FOC Y": 3,
|
||||
# },
|
||||
# }
|
||||
#
|
||||
# def boost_keywords(text: str, label: str) -> str:
|
||||
# boost_map = KEYWORD_BOOST_BY_LABEL.get(label, {}) # 해당 label 없으면 빈 dict
|
||||
# boosted = text
|
||||
# for keyword, repeat in boost_map.items():
|
||||
# if keyword.lower() in text.lower():
|
||||
# boosted += f" {keyword}" * repeat
|
||||
# return boosted
|
||||
#
|
||||
# texts = [boost_keywords(t, l) for t, l in zip(texts, labels)]
|
||||
# logger.info("핵심 키워드 가중치 부여 완료")
|
||||
# # ────────────────────────────────────────────────
|
||||
|
||||
# # ── 샘플 부족 클래스 필터링 ──────────────────────
|
||||
# counts = Counter(labels)
|
||||
# MIN_SAMPLES = 5
|
||||
# before = len(texts)
|
||||
# filtered = [(t, l) for t, l in zip(texts, labels) if counts[l] >= MIN_SAMPLES]
|
||||
# if not filtered:
|
||||
# raise ValueError("모든 클래스가 샘플 부족으로 제외되었습니다.")
|
||||
# texts, labels = map(list, zip(*filtered))
|
||||
# excluded = {k: v for k, v in counts.items() if v < MIN_SAMPLES}
|
||||
# if excluded:
|
||||
# logger.warning(f"샘플 부족 제외: {excluded}")
|
||||
# logger.info(f"필터링: {before}개 → {len(texts)}개")
|
||||
# # ────────────────────────────────────────────────
|
||||
#
|
||||
# counts = Counter(labels)
|
||||
# min_count = min(counts.values())
|
||||
# cv = 5
|
||||
# stratify = labels if min_count >= cv else None
|
||||
|
||||
MIN_SAMPLES = 5
|
||||
|
||||
# ── 필터링 ───────────────────────────────────────
|
||||
counts = Counter(labels)
|
||||
before = len(texts)
|
||||
filtered = [(t, l) for t, l in zip(texts, labels) if counts[l] >= MIN_SAMPLES]
|
||||
if not filtered:
|
||||
raise ValueError("모든 클래스가 샘플 부족으로 제외되었습니다.")
|
||||
texts, labels = map(list, zip(*filtered))
|
||||
excluded = {k: v for k, v in counts.items() if v < MIN_SAMPLES}
|
||||
if excluded:
|
||||
logger.warning(f"샘플 부족 제외: {excluded}")
|
||||
logger.info(f"필터링: {before}개 → {len(texts)}개")
|
||||
|
||||
# ── MIN_SAMPLES 기준으로 cv/stratify 자동 결정 ──
|
||||
counts = Counter(labels)
|
||||
min_count = min(counts.values())
|
||||
cv = max(2, min(5, int(MIN_SAMPLES * 0.8))) # MIN_SAMPLES의 80% (train 비율)
|
||||
stratify = labels if min_count >= cv else None
|
||||
|
||||
logger.info(f"MIN_SAMPLES={MIN_SAMPLES} → cv={cv}, stratify={'적용' if stratify else '비적용'}")
|
||||
|
||||
_status["message"] = f"{len(texts)}개 샘플 / {len(counts)}개 클래스 학습 중..."
|
||||
_status["samples"] = len(texts)
|
||||
_status["classes"] = len(counts)
|
||||
logger.info(_status["message"])
|
||||
|
||||
X_train, X_test, y_train, y_test = train_test_split(
|
||||
texts, labels,
|
||||
test_size=0.2,
|
||||
stratify=stratify,
|
||||
random_state=42,
|
||||
)
|
||||
|
||||
# train_counts = Counter(y_train)
|
||||
# min_train_count = min(train_counts.values())
|
||||
# cv = max(2, min(5, min_train_count))
|
||||
|
||||
pipeline = Pipeline([
|
||||
("tfidf", TfidfVectorizer(max_features=50000, ngram_range=(1, 2))),
|
||||
("clf", CalibratedClassifierCV(LinearSVC(max_iter=2000, class_weight="balanced"), cv=cv)),
|
||||
])
|
||||
pipeline.fit(X_train, y_train)
|
||||
|
||||
report = classification_report(y_test, pipeline.predict(X_test), zero_division=0)
|
||||
logger.info(f"\n{report}")
|
||||
|
||||
classifier_model_store.set_model(pipeline)
|
||||
|
||||
_status = {
|
||||
"status": "done",
|
||||
"message": f"학습 완료 | 샘플={len(texts)}, 클래스={len(counts)}",
|
||||
"samples": len(texts),
|
||||
"classes": len(counts),
|
||||
"report": report, # 필요 시 별도 API로 노출
|
||||
}
|
||||
|
||||
logger.info("classifier 학습 완료")
|
||||
|
||||
except Exception as e:
|
||||
_status = {"status": "error", "message": str(e)}
|
||||
logger.exception(f"classifier 학습 실패: {e}")
|
||||
37
train/schemas.py
Normal file
37
train/schemas.py
Normal file
@@ -0,0 +1,37 @@
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class TrainRequest(BaseModel):
|
||||
epochs: int = Field(10, description="학습 에폭 수")
|
||||
lr: float = Field(1e-5, description="학습률")
|
||||
batch_size: int = Field(4, description="배치 사이즈")
|
||||
ewc_lambda: float = Field(1000., description="EWC 패널티 강도 (클수록 기존 지식 보호)")
|
||||
strategy: str = Field("full",description="auto | full | ewc | replay")
|
||||
|
||||
|
||||
class TrainStatus(BaseModel):
|
||||
running: bool
|
||||
epoch: int
|
||||
total: int
|
||||
loss: float
|
||||
val_acc: float = 0.0
|
||||
message: str
|
||||
batch: int = 0 # 현재 배치
|
||||
total_batches: int = 0 # 전체 배치 수
|
||||
|
||||
class PredictResult(BaseModel):
|
||||
filename: str
|
||||
label: str | None = None
|
||||
confidence: float | None = None
|
||||
all_probs: dict | None = None
|
||||
key_tokens: list[str] | None = None
|
||||
ocr_word_count: int | None = None
|
||||
total_pages: int | None = None
|
||||
per_page: list | None = None
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class DatasetStatus(BaseModel):
|
||||
total_samples: int
|
||||
by_label: dict
|
||||
label2id: dict
|
||||
348
train/trainer.py
Normal file
348
train/trainer.py
Normal file
@@ -0,0 +1,348 @@
|
||||
import random
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
from PIL import Image
|
||||
from sklearn.model_selection import train_test_split
|
||||
from sklearn.metrics import classification_report
|
||||
from torch.utils.data import Dataset, DataLoader
|
||||
from torch.optim import AdamW
|
||||
from transformers import (
|
||||
LayoutLMv3Processor,
|
||||
LayoutLMv3ForSequenceClassification,
|
||||
get_scheduler,
|
||||
)
|
||||
from tqdm import tqdm
|
||||
from loguru import logger
|
||||
|
||||
import config
|
||||
from train.schemas import TrainRequest, TrainStatus
|
||||
from utils.cache import (
|
||||
load_cache, save_label2id,
|
||||
load_replay_buffer, update_replay_buffer, load_label2id
|
||||
)
|
||||
|
||||
# 학습 상태 전역
|
||||
train_status = TrainStatus(
|
||||
running=False, epoch=0, total=0, loss=0.0, message="대기 중"
|
||||
)
|
||||
stop_requested = False
|
||||
|
||||
|
||||
# ── Dataset ───────────────────────────────────────
|
||||
class InvoiceDataset(Dataset):
|
||||
def __init__(self, samples: list, processor):
|
||||
self.samples = samples
|
||||
self.processor = processor
|
||||
|
||||
def __len__(self):
|
||||
return len(self.samples)
|
||||
|
||||
def __getitem__(self, idx):
|
||||
s = self.samples[idx]
|
||||
image = Image.open(s["image_path"]).convert("RGB")
|
||||
encoding = self.processor(
|
||||
image, text=s["words"], boxes=s["boxes"],
|
||||
return_tensors="pt", truncation=True,
|
||||
padding="max_length", max_length=config.MAX_LEN,
|
||||
)
|
||||
return {
|
||||
"input_ids": encoding["input_ids"].squeeze(),
|
||||
"attention_mask": encoding["attention_mask"].squeeze(),
|
||||
"bbox": encoding["bbox"].squeeze(),
|
||||
"pixel_values": encoding["pixel_values"].squeeze(),
|
||||
"labels": torch.tensor(s["label"], dtype=torch.long),
|
||||
}
|
||||
|
||||
|
||||
# ── Fisher (EWC) ──────────────────────────────────
|
||||
def compute_fisher(model, loader) -> dict:
|
||||
fisher = {n: torch.zeros_like(p)
|
||||
for n, p in model.named_parameters() if p.requires_grad}
|
||||
model.eval()
|
||||
for batch in loader:
|
||||
model.zero_grad()
|
||||
out = model(
|
||||
input_ids = batch["input_ids"].to(config.DEVICE),
|
||||
attention_mask = batch["attention_mask"].to(config.DEVICE),
|
||||
bbox = batch["bbox"].to(config.DEVICE),
|
||||
pixel_values = batch["pixel_values"].to(config.DEVICE),
|
||||
labels = batch["labels"].to(config.DEVICE),
|
||||
)
|
||||
out.loss.backward()
|
||||
for n, p in model.named_parameters():
|
||||
if p.requires_grad and p.grad is not None:
|
||||
fisher[n] += p.grad.pow(2)
|
||||
return {n: f / max(len(loader), 1) for n, f in fisher.items()}
|
||||
|
||||
|
||||
# ── 검증 ──────────────────────────────────────────
|
||||
def evaluate_loader(model, loader) -> float:
|
||||
model.eval()
|
||||
correct, total = 0, 0
|
||||
with torch.no_grad():
|
||||
for batch in loader:
|
||||
out = model(
|
||||
input_ids = batch["input_ids"].to(config.DEVICE),
|
||||
attention_mask = batch["attention_mask"].to(config.DEVICE),
|
||||
bbox = batch["bbox"].to(config.DEVICE),
|
||||
pixel_values = batch["pixel_values"].to(config.DEVICE),
|
||||
)
|
||||
preds = out.logits.argmax(dim=-1).cpu()
|
||||
correct += (preds == batch["labels"]).sum().item()
|
||||
total += len(batch["labels"])
|
||||
return correct / total if total else 0.0
|
||||
|
||||
|
||||
def _get_latest_model_path():
|
||||
"""
|
||||
날짜/차수 폴더에서 가장 최신 모델 경로 반환
|
||||
files/models/20260305/0002/ ← 최신
|
||||
"""
|
||||
base = config.MODEL_BASE_PATH
|
||||
all_models = sorted([
|
||||
d for d in base.rglob("config.json")
|
||||
])
|
||||
if not all_models:
|
||||
return None
|
||||
|
||||
print(all_models[-1].parent)
|
||||
return all_models[-1].parent
|
||||
|
||||
|
||||
# ── 학습 루프 ─────────────────────────────────────
|
||||
def run_train_loop(model, processor, train_s, val_s, req: TrainRequest,
|
||||
label2id: dict, strategy: str, save_path: Path):
|
||||
global train_status
|
||||
|
||||
train_loader = DataLoader(
|
||||
InvoiceDataset(train_s, processor),
|
||||
batch_size=req.batch_size, shuffle=True
|
||||
)
|
||||
val_loader = DataLoader(
|
||||
InvoiceDataset(val_s, processor),
|
||||
batch_size=req.batch_size
|
||||
)
|
||||
optimizer = AdamW(model.parameters(), lr=req.lr)
|
||||
scheduler = get_scheduler(
|
||||
"cosine", optimizer=optimizer,
|
||||
num_warmup_steps=len(train_loader),
|
||||
num_training_steps=len(train_loader) * req.epochs,
|
||||
)
|
||||
|
||||
# EWC 사전 계산
|
||||
ewc_params, ewc_fisher = {}, {}
|
||||
if strategy == "ewc":
|
||||
ewc_params = {n: p.clone().detach()
|
||||
for n, p in model.named_parameters() if p.requires_grad}
|
||||
ewc_fisher = compute_fisher(model, val_loader)
|
||||
logger.info("EWC Fisher 계산 완료")
|
||||
|
||||
best_acc = 0.0
|
||||
total_batches = len(train_loader) * req.epochs
|
||||
|
||||
for epoch in range(req.epochs):
|
||||
model.train()
|
||||
total_loss = 0
|
||||
|
||||
pbar = tqdm(
|
||||
train_loader,
|
||||
desc=f"Epoch {epoch+1}/{req.epochs}",
|
||||
ncols=100,
|
||||
leave=True,
|
||||
)
|
||||
for batch_idx, batch in enumerate(pbar):
|
||||
if stop_requested:
|
||||
logger.info("학습 중단 요청 감지")
|
||||
return
|
||||
|
||||
out = model(
|
||||
input_ids = batch["input_ids"].to(config.DEVICE),
|
||||
attention_mask = batch["attention_mask"].to(config.DEVICE),
|
||||
bbox = batch["bbox"].to(config.DEVICE),
|
||||
pixel_values = batch["pixel_values"].to(config.DEVICE),
|
||||
labels = batch["labels"].to(config.DEVICE),
|
||||
)
|
||||
loss = out.loss
|
||||
if strategy == "ewc" and ewc_fisher:
|
||||
for n, p in model.named_parameters():
|
||||
if n in ewc_fisher:
|
||||
loss += req.ewc_lambda * (
|
||||
ewc_fisher[n] * (p - ewc_params[n]).pow(2)
|
||||
).sum()
|
||||
|
||||
loss.backward()
|
||||
nn.utils.clip_grad_norm_(model.parameters(), 1.0)
|
||||
optimizer.step()
|
||||
scheduler.step()
|
||||
optimizer.zero_grad()
|
||||
total_loss += loss.item()
|
||||
|
||||
pbar.set_postfix(loss=f"{loss.item():.4f}")
|
||||
|
||||
# 배치 단위 상태 업데이트
|
||||
done_batches = epoch * len(train_loader) + (batch_idx + 1)
|
||||
train_status.epoch = epoch + 1
|
||||
train_status.total = req.epochs
|
||||
train_status.loss = round(loss.item(), 4)
|
||||
train_status.batch = done_batches # 완료된 전체 배치
|
||||
train_status.total_batches = total_batches # 전체 배치 수
|
||||
train_status.message = (
|
||||
f"Epoch {epoch+1}/{req.epochs} | "
|
||||
f"batch {batch_idx+1}/{len(train_loader)} | "
|
||||
f"loss={loss.item():.4f}"
|
||||
)
|
||||
|
||||
avg_loss = total_loss / len(train_loader)
|
||||
val_acc = evaluate_loader(model, val_loader)
|
||||
|
||||
msg = (
|
||||
f"Epoch {epoch+1}/{req.epochs} | "
|
||||
f"loss={avg_loss:.4f} | val_acc={val_acc:.4f}"
|
||||
)
|
||||
tqdm.write(f"[결과] {msg}")
|
||||
logger.info(msg)
|
||||
|
||||
train_status.epoch = epoch + 1
|
||||
train_status.loss = round(avg_loss, 4)
|
||||
train_status.val_acc = round(val_acc, 4)
|
||||
train_status.message = msg
|
||||
|
||||
if val_acc > best_acc:
|
||||
best_acc = val_acc
|
||||
model.save_pretrained(str(save_path))
|
||||
processor.save_pretrained(str(save_path))
|
||||
save_label2id(label2id)
|
||||
logger.info(f"모델 저장 (val_acc={val_acc:.4f}) → {save_path}")
|
||||
|
||||
|
||||
# ── 학습 진입점 ───────────────────────────────────
|
||||
def run_train(req: TrainRequest):
|
||||
global train_status
|
||||
stop_requested = False
|
||||
train_status = TrainStatus(
|
||||
running=True, epoch=0, total=req.epochs,
|
||||
loss=0.0, message="학습 준비 중"
|
||||
)
|
||||
try:
|
||||
from utils.cache import load_label2id
|
||||
|
||||
# 날짜/차수 폴더 생성
|
||||
save_path = config.get_model_save_path()
|
||||
logger.info(f"모델 저장 경로: {save_path}")
|
||||
train_status.message = f"저장 경로: {save_path}"
|
||||
|
||||
samples = load_cache()
|
||||
label2id = load_label2id()
|
||||
id2label = {v: k for k, v in label2id.items()}
|
||||
|
||||
processor_path = (
|
||||
str(config.MODEL_BASE_PATH / "preprocessor_config.json")
|
||||
if (config.MODEL_BASE_PATH / "preprocessor_config.json").exists()
|
||||
else config.MODEL_NAME
|
||||
)
|
||||
# 최신 모델 폴더 찾기 (이어 학습)
|
||||
latest_model = _get_latest_model_path()
|
||||
model_path = str(latest_model) if latest_model else config.MODEL_NAME
|
||||
logger.info(f"기반 모델: {model_path}")
|
||||
|
||||
processor = LayoutLMv3Processor.from_pretrained(
|
||||
latest_model and str(latest_model) or config.MODEL_NAME,
|
||||
apply_ocr=False
|
||||
)
|
||||
model = LayoutLMv3ForSequenceClassification.from_pretrained(
|
||||
model_path,
|
||||
num_labels=len(label2id),
|
||||
id2label=id2label,
|
||||
label2id=label2id,
|
||||
ignore_mismatched_sizes=True,
|
||||
).to(config.DEVICE)
|
||||
|
||||
# 전략 자동 선택
|
||||
strategy = req.strategy
|
||||
if strategy == "auto":
|
||||
buf = load_replay_buffer()
|
||||
existing_types = set(buf.keys())
|
||||
new_types = {id2label[s["label"]] for s in samples}
|
||||
strategy = "replay" if (new_types - existing_types) else "ewc"
|
||||
train_status.message = f"전략: {strategy}"
|
||||
logger.info(f"학습 전략: {strategy}")
|
||||
|
||||
train_s, val_s = train_test_split(
|
||||
samples, test_size=0.2, random_state=42,
|
||||
stratify=[s["label"] for s in samples],
|
||||
)
|
||||
|
||||
if strategy == "replay":
|
||||
replay_s = [s for v in load_replay_buffer().values() for s in v]
|
||||
combined = train_s + replay_s
|
||||
random.shuffle(combined)
|
||||
run_train_loop(model, processor, combined, val_s, req, label2id, strategy, save_path)
|
||||
update_replay_buffer(train_s, id2label)
|
||||
else:
|
||||
run_train_loop(model, processor, train_s, val_s, req, label2id, strategy, save_path)
|
||||
update_replay_buffer(train_s, id2label)
|
||||
|
||||
train_status.message = f"학습 완료 → {save_path}"
|
||||
logger.info(f"학습 완료: {save_path}")
|
||||
|
||||
except Exception:
|
||||
train_status.message = f"오류: {traceback.format_exc()}"
|
||||
logger.exception("학습 중 예외 발생")
|
||||
finally:
|
||||
stop_requested = False
|
||||
train_status.running = False
|
||||
|
||||
|
||||
# ── 평가 진입점 ───────────────────────────────────
|
||||
def run_evaluate(test_size: float = 0.2) -> dict:
|
||||
samples = load_cache()
|
||||
label2id = load_label2id()
|
||||
id2label = {v: k for k, v in label2id.items()}
|
||||
|
||||
_, test_s = train_test_split(
|
||||
samples, test_size=test_size, random_state=42,
|
||||
stratify=[s["label"] for s in samples],
|
||||
)
|
||||
|
||||
model_path = _get_latest_model_path()
|
||||
if not model_path:
|
||||
raise RuntimeError("저장된 모델이 없습니다.")
|
||||
|
||||
processor = LayoutLMv3Processor.from_pretrained(
|
||||
str(model_path), apply_ocr=False
|
||||
)
|
||||
model = LayoutLMv3ForSequenceClassification.from_pretrained(
|
||||
str(model_path)
|
||||
).to(config.DEVICE)
|
||||
model.eval()
|
||||
|
||||
loader = DataLoader(InvoiceDataset(test_s, processor), batch_size=config.BATCH_SIZE)
|
||||
all_preds, all_labels = [], []
|
||||
with torch.no_grad():
|
||||
for batch in loader:
|
||||
out = model(
|
||||
input_ids = batch["input_ids"].to(config.DEVICE),
|
||||
attention_mask = batch["attention_mask"].to(config.DEVICE),
|
||||
bbox = batch["bbox"].to(config.DEVICE),
|
||||
pixel_values = batch["pixel_values"].to(config.DEVICE),
|
||||
)
|
||||
all_preds.extend(out.logits.argmax(dim=-1).cpu().tolist())
|
||||
all_labels.extend(batch["labels"].tolist())
|
||||
|
||||
target_names = [id2label[i] for i in range(len(id2label))]
|
||||
report_dict = classification_report(
|
||||
all_labels, all_preds, target_names=target_names, output_dict=True
|
||||
)
|
||||
report_str = classification_report(
|
||||
all_labels, all_preds, target_names=target_names
|
||||
)
|
||||
logger.info(f"평가 완료 | accuracy={report_dict['accuracy']:.4f}")
|
||||
return {
|
||||
"test_samples": len(test_s),
|
||||
"accuracy": round(report_dict["accuracy"], 4),
|
||||
"report": report_dict,
|
||||
"report_text": report_str,
|
||||
}
|
||||
Reference in New Issue
Block a user