15년 치 현장 이슈 데이터 약 3,000건으로 이슈 등급(등급·경미·아차)을 추천하는 분류 모델을 만들었다. 코드 체계가 다른 두 시스템의 데이터를 텍스트 기준으로 합친 방법, 10개 모델 교차검증, 재학습 때 엉뚱한 모델 교체를 막는 가드 로직을 정리했다.
이 글의 순서
무엇을 풀려고 했나
현장 이슈가 접수되면 담당자가 이슈 등급을 판단한다. 치료가 필요한 등급 이슈, 가벼운 경미 이슈, 다칠 뻔한 잠재 이슈로 나뉘고, 그보다 위에 고위험 이슈가 있다. 판단은 사람마다 조금씩 다르고, 등급에 따라 이후 절차가 달라진다.
목표는 접수 시점에 등급을 추천하는 것이다. 자동으로 확정하는 게 아니라 담당자가 참고할 추천값이다. 하나 더 있었다. '일부 데이터로 만든 데모'가 아니라 '보유한 전체 데이터로 학습했다'고 말할 수 있어야 했다. 현업에 내놓는 모델은 성능만큼 정당성이 중요하다.
첫 버전: 약 340건, 나이브베이즈
처음에는 새 이슈 관리 시스템에 쌓인 데이터만 썼다. 이슈 유형, 기인물, 장소, 작업 상황, 사업장 5개 코드값을 피처로 썼고, 6개 모델을 비교해 나이브베이즈가 뽑혔다. macro-F1 0.54였다.
문제는 데이터였다. 300여 건으로는 클래스마다 몇십 건 수준이고, 고위험 이슈는 0건이었다. 새 시스템이 생긴 지 얼마 안 됐기 때문이다. 과거 15년 데이터는 예전 시스템에 따로 있었다.
코드 체계가 다른 두 시스템 합치기
예전 시스템 약 2,600건(2011~2025)과 새 시스템 약 400건(2026~)을 합치기로 했다. 바로 막혔다. 두 시스템은 이슈 유형과 장소를 분류하는 코드 체계가 달랐다. 코드 매핑표를 만들 수도 있지만 15년 사이 바뀐 코드까지 맞추려면 끝이 없다.
그래서 두 시스템에 공통으로 있는 것만 쓰기로 했다. 이슈 제목과 발생 내용, 즉 텍스트다. 앞서 텍스트만 쓴 모델과 코드까지 함께 쓴 모델을 비교했을 때 차이가 거의 없었던 것도 근거가 됐다.
통합 학습 데이터 만들기
예전 시스템 약 2,600건
새 시스템 약 400건
from sklearn.feature_extraction.text import TfidfVectorizer
text = df["title"].fillna("") + " " + df["occur"].fillna("")
vec = TfidfVectorizer(max_features=30000)
X = vec.fit_transform(text)
y = df["grade"] # GRADE / MINOR / NEARMISS누설 주의새 시스템의 등급 컬럼이 최종 확정값인지 꼭 확인해야 한다. 접수 직후의 잠정 등급이 섞여 있으면 정답 자체가 흔들린다. 새 시스템 건수가 운영 중인 이슈 건수와 맞는지 대조하는 것으로 1차 확인했다.
10개 모델을 돌리고 자동으로 고른다
어떤 모델이 좋을지 미리 정하지 않고, 후보를 전부 같은 조건으로 교차검증한 다음 점수가 가장 높은 모델을 자동으로 저장하는 구조로 만들었다. 데이터가 바뀌어 재학습할 때도 같은 코드가 다시 고른다.
| 계열 | 후보 |
|---|---|
| 확률 모델 | Multinomial NB, Complement NB |
| 선형 모델 | Logistic Regression, Ridge, SGD, LinearSVC |
| 트리 앙상블 | RandomForest, ExtraTrees, LightGBM 등 |
| 제외 | CatBoost (학습 환경 메모리 부족) |
from sklearn.model_selection import StratifiedKFold, cross_val_score
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
scores = {
name: cross_val_score(model, X, y, cv=cv, scoring="f1_macro").mean()
for name, model in candidates.items()
}
best_name = max(scores, key=scores.get)평가 지표는 정확도가 아니라 macro-F1로 잡았다. 경미 이슈가 가장 많아서, 전부 경미라고 찍어도 정확도는 꽤 나온다. macro-F1은 클래스별 F1을 단순 평균하기 때문에 적은 클래스를 못 맞히면 점수가 바로 떨어진다.
결과는 LightGBM이 0.585로 가장 높았다.
0.585는 낮은 점수인가
숫자만 보면 높지 않다. 그래도 받아들인 이유가 있다.
- 3개 클래스를 무작위로 찍으면 macro-F1은 0.33 근처다.
- 접수 시점 텍스트에는 결과 정보가 없다. 치료 기간이나 요양 여부처럼 등급을 실제로 가르는 정보는 이슈 후에 생긴다. 이 한계는 모델을 바꿔서 넘을 수 있는 게 아니다.
- 용도가 추천이다. 최종 판단은 사람이 하고, 모델은 판단의 출발점을 준다.
그래서 화면에는 점수를 부풀리지 않고 그대로 표시했다. 비전문가에게 이 숫자를 어떻게 보여줬는지는 6편에서, 고위험 이슈를 어떻게 따로 처리했는지는 5편에서 다룬다.
재학습 가드: 엉뚱한 비교를 막는다
재학습 구조에는 안전장치가 하나 있었다. 새 모델의 점수가 기존 모델보다 낮으면 교체하지 않는다. 당연해 보이는 이 규칙 때문에 문제가 생겼다.
개발 초기에 합성 데이터로 만든 모델이 운영 중이었는데, 합성 데이터는 패턴이 깔끔해서 F1이 높게 나온다. 실데이터로 재학습한 모델은 점수가 더 낮게 나왔고, 가드가 교체를 거부했다. 실데이터 모델이 영영 올라가지 못하는 상황이었다.
원인은 다른 데이터로 낸 점수를 같은 저울에 올린 것이다. 데이터 출처가 다르면 점수 비교를 건너뛰도록 고쳤다. 아래에서 상황별로 가드 수정 전후 결과를 비교해 볼 수 있다.
수정 전
수정 후
점수는 예시다.
def should_replace(new_meta, cur_meta):
if cur_meta is None:
return True, "기존 모델 없음"
if new_meta["data_source"] != cur_meta["data_source"]:
# 합성 데이터와 실데이터처럼 출처가 다르면 점수 비교가 의미 없다
return True, "데이터 출처 변경, 비교 생략"
if new_meta["macro_f1"] >= cur_meta["macro_f1"]:
return True, "점수 같거나 상승"
return False, "점수 하락, 기존 모델 유지"실데이터에서 나온 자잘한 문제도 하나 있었다. 데이터 레이크에서 비어 있는 값이 문자열이 아니라 float NaN으로 와서 .strip()에서 오류가 났다. 텍스트 전처리 앞단에 타입을 정리하는 함수를 두고 모든 입력이 그 함수를 거치게 했다.
정리
- 코드 체계가 다른 데이터는 매핑하느라 애쓰기보다 공통인 텍스트로 합치는 게 빠를 수 있다.
- 모델은 미리 고르지 말고 후보 전체를 같은 조건으로 검증해 자동 선택한다.
- 불균형 데이터는 정확도 대신 macro-F1로 본다.
- 재학습 가드는 '같은 저울'에서만 비교한다. 데이터 출처를 메타에 꼭 남긴다.