로그 분류 모델 비교: TF-IDF Logistic Regression으로 Cron 실패 원인 자동 분류하기
Cron 로그에 TimeoutExpired, 산출물 누락, Traceback이 한 사건 안에서 겹치면 로그 분류 모델 비교가 필요해 보인다. 하지만 2026년 8월 16일 같은 목적형 입력으로 검증하자 명시적 키워드 규칙과 TF-IDF 다항 로지스틱 회귀가 모두 accuracy 1.000, macro-F1 1.000으로 동률이었다. 현재 조건에서는 모델을 추가할 이유가 없었다.
이 글은 logs/2026-08-14.log와 scripts/run_daily_pipeline.py:246-300에서 확인한 실패 표현을 출발점으로 삼는다. 운영 로그 자체를 학습 데이터라고 간주하지 않고, 합성 fixture 21개로 규칙과 모델을 비교한 범위만 설명한다. 어떤 로그 블록을 표본으로 삼고, 어느 지표와 실패 조건을 통과해야 ML 도입을 다시 검토할지까지 다룬다.
20초 핵심 요약
- 무엇: Cron 실패 사건을
timeout,missing_artifact,traceback으로 나누는 규칙과 TF-IDF 로지스틱 회귀를 비교했다. - 왜: timeout 라우팅이 틀리면 시간 예산 대응이 늦고, 산출물 누락을 놓치면 종료코드 0인 빈 성공을 통과시킬 수 있어 동률인 모델의 복잡성을 택하지 않았다.
- 어떻게: 학습 12개·홀드아웃 9개를 같은 조건으로 평가하고, 전체 재현 명령과 unknown 거부의 종료 상태까지 확인했다.
한 줄이 아니라 한 실패 사건을 분류해야 한다
관측값 X는 물리적인 로그 한 줄이 아니라 하나의 Cron 실패를 구성하는 메시지와 필요한 스택 블록 전체다. 정답 y는 이번 범위에서 timeout, missing_artifact, traceback 가운데 하나이며, 예측값은 근본 원인 확정이 아니라 첫 라우팅에 쓰는 라벨이다.
실제 로그의 한 실패 사건에는 reason=timeout, subprocess.TimeoutExpired, PipelineError와 Traceback이 함께 나타났다. 줄마다 표본을 만들고 무작위로 나누면 같은 사건에서 파생된 거의 같은 문구가 학습과 검증 양쪽에 들어간다. 이런 누수를 피하려면 실행 ID나 실패 사건 ID로 묶어 분할해야 한다. scikit-learn의 그룹 교차검증 지침도 같은 그룹이 훈련과 검증에 함께 들어가는 문제를 설명한다.
세 라벨도 문자열의 겉모양만으로 정하면 겹친다. Traceback 안에 timeout 예외가 들어갈 수 있으므로 다음 우선순위를 먼저 고정했다.
| 라벨 | 포함 기준 | 제외 기준 |
|---|---|---|
timeout |
제한 시간 만료, TimeoutExpired, 명시적 deadline 초과 |
단지 오래 걸렸다는 INFO |
missing_artifact |
종료 뒤 필수 파일이나 산출물이 없음 | 입력 환경변수 또는 선택 산출물 누락 |
traceback |
앞의 두 원인으로 귀속되지 않은 Python 예외·스택 | Traceback 문자열이 있다는 이유만으로 모두 포함 |
프로젝트의 run_stage 경로는 subprocess.run에 timeout=timeout_seconds를 전달하고 subprocess.TimeoutExpired를 잡아 reason=timeout과 시간 제한 초과 메시지를 남긴다. Python의 TimeoutExpired 공식 설명과 코드의 의미가 맞는다. 코드가 이미 명시적 원인을 남긴다면 학습 모델이 가져올 추가 이득부터 입증해야 한다.
TF-IDF는 사건 블록을 희소한 단어 벡터로 바꾼다
TF-IDF는 한 사건 블록에서 자주 나오지만 전체 학습 문서에서는 드문 토큰에 더 큰 가중치를 주는 표현이다. 이번 실험은 소문자화한 영문·숫자·한글 토큰에서 단어 unigram과 인접 bigram을 만들고 L2 정규화했다. 다항 로지스틱 회귀는 이 벡터의 특성마다 클래스별 선형 가중치를 학습하고 softmax로 세 라벨의 상대 점수를 계산한다.
이 조합은 라벨을 가르는 어휘와 n-gram이 이후 로그에도 반복된다고 가정한다. 에이전트 이름, 언어, 버전이나 오류 문구가 바뀌면 이 가정은 깨진다. 타임스탬프, run ID, 절대 경로 같은 변동 식별자를 그대로 두면 원인이 아니라 우연한 문자열을 배울 수도 있다.
vocabulary와 IDF는 학습 세트에만 fit하고 홀드아웃에는 변환만 적용해야 한다. 전체 로그로 IDF를 계산한 뒤 나누면 검증할 데이터의 정보가 표현에 섞인다. 이는 scikit-learn의 데이터 누수 지침과 같은 경계다.
이번 구현은 scikit-learn 자체가 아니다. 프로젝트 .venv에 scikit-learn이 설치돼 있지 않아 의존성을 추가하지 않고 Python 표준 라이브러리로 TF-IDF와 3-class softmax를 구현했다. 공식 TfidfVectorizer와 LogisticRegression의 개념은 따르지만, solver의 수렴이나 확률 보정까지 수치적으로 같다고 볼 수 없다.
축약 없는 동일 입력 비교에서는 두 방식이 동률이었다
실험 환경은 Linux, Python 3.12.3, 프로젝트 .venv였다. 실제 로그에서 관측한 세 원인 표현을 바탕으로 목적형 fixture 21개를 만들고, 라벨별 4개씩 총 12개를 학습에, 라벨별 3개씩 총 9개를 홀드아웃에 배치했다. 문장 중복은 없지만 한 사람이 같은 개념 템플릿으로 만든 작은 합성 표본이므로 운영 분포를 대표하지 않는다.
아래 블록은 fixture, 표현 변환, 학습, 규칙과 평가를 모두 포함한 단일 실행 원문이다. 이전 문서의 0.889/0.886은 실행 코드가 생략돼 재현할 수 없었으므로 폐기했다.
$ .venv/bin/python - <<'PY'
import math, re
from collections import Counter
classes = ['timeout', 'missing_artifact', 'traceback']
train = [
('timeout','agent exceeded time limit while waiting for completion'),
('timeout','subprocess TimeoutExpired after 300 seconds'),
('timeout','planner timed out before producing a response'),
('timeout','deadline exceeded during cron stage execution'),
('missing_artifact','required topics artifact is missing after successful exit'),
('missing_artifact','expected research file was not generated'),
('missing_artifact','output artifact absent when stage completed'),
('missing_artifact','publish document was not created'),
('traceback','Traceback most recent call last ValueError invalid state'),
('traceback','unhandled exception raised while parsing agent output'),
('traceback','Python stack trace ended with RuntimeError'),
('traceback','PipelineError wrapped an unexpected stage failure')]
test = [
('timeout','worker timed out while waiting for the agent'),
('timeout','TimeoutExpired command exceeded 300 seconds'),
('timeout','cron stage stopped because deadline was exceeded'),
('missing_artifact','required output artifact is missing'),
('missing_artifact','topics file was not generated after exit'),
('missing_artifact','expected publish document is absent'),
('traceback','Traceback most recent call last KeyError topic'),
('traceback','PipelineError stage returned non-zero exit status'),
('traceback','unexpected exception produced a Python stack trace')]
def tokens(text):
words=re.findall(r'[a-z0-9가-힣]+',text.lower())
return words+[f'{a}__{b}' for a,b in zip(words,words[1:])]
docs=[tokens(text) for _,text in train]
vocab=sorted({token for doc in docs for token in doc})
df=Counter(token for doc in docs for token in set(doc))
idf={token:math.log((1+len(docs))/(1+df[token]))+1 for token in vocab}
def vectorize(text):
counts=Counter(tokens(text)); values=[counts[t]*idf[t] for t in vocab]
norm=math.sqrt(sum(v*v for v in values)) or 1.0
return [v/norm for v in values]
x_train=[vectorize(text) for _,text in train]
y_train=[classes.index(label) for label,_ in train]
weights=[[0.0]*len(vocab) for _ in classes]; bias=[0.0]*len(classes)
for epoch in range(1200):
rate=0.4/math.sqrt(1+epoch/100)
for vector,target in zip(x_train,y_train):
scores=[sum(w*x for w,x in zip(row,vector))+bias[i] for i,row in enumerate(weights)]
peak=max(scores); exps=[math.exp(s-peak) for s in scores]; total=sum(exps)
probs=[value/total for value in exps]
for i in range(len(classes)):
error=probs[i]-(i==target)
for feature,value in enumerate(vector):
weights[i][feature]-=rate*(error*value+0.001*weights[i][feature])
bias[i]-=rate*error
def model_predict(text):
vector=vectorize(text)
scores=[sum(w*x for w,x in zip(row,vector))+bias[i] for i,row in enumerate(weights)]
return classes[max(range(len(classes)),key=scores.__getitem__)]
def rule_predict(text):
lowered=text.lower()
if any(term in lowered for term in ('timeout','timed out','time limit','deadline')): return 'timeout'
if any(term in lowered for term in ('missing','not generated','absent','not created')): return 'missing_artifact'
if any(term in lowered for term in ('traceback','exception','pipelineerror','stack')): return 'traceback'
return 'unknown'
def metrics(expected,predicted):
accuracy=sum(a==b for a,b in zip(expected,predicted))/len(expected); scores=[]
for label in classes:
tp=sum(a==label and b==label for a,b in zip(expected,predicted))
fp=sum(a!=label and b==label for a,b in zip(expected,predicted))
fn=sum(a==label and b!=label for a,b in zip(expected,predicted))
precision=tp/(tp+fp) if tp+fp else 0.0; recall=tp/(tp+fn) if tp+fn else 0.0
scores.append(2*precision*recall/(precision+recall) if precision+recall else 0.0)
return accuracy,sum(scores)/len(scores)
expected=[label for label,_ in test]
rule_predictions=[rule_predict(text) for _,text in test]
model_predictions=[model_predict(text) for _,text in test]
rule_accuracy,rule_f1=metrics(expected,rule_predictions)
model_accuracy,model_f1=metrics(expected,model_predictions)
print(f'keyword_rule: accuracy={rule_accuracy:.3f} macro_f1={rule_f1:.3f} predictions={rule_predictions}')
print(f'tfidf_logreg: accuracy={model_accuracy:.3f} macro_f1={model_f1:.3f} predictions={model_predictions}')
print(f'train={len(train)} test={len(test)} classes={classes} vocab={len(vocab)} split=template_group_holdout')
print('exit_status=0')
PY
status=$?
printf 'shell_exit_status=%s\n' "$status"
keyword_rule: accuracy=1.000 macro_f1=1.000 predictions=['timeout', 'timeout', 'timeout', 'missing_artifact', 'missing_artifact', 'missing_artifact', 'traceback', 'traceback', 'traceback']
tfidf_logreg: accuracy=1.000 macro_f1=1.000 predictions=['timeout', 'timeout', 'timeout', 'missing_artifact', 'missing_artifact', 'missing_artifact', 'traceback', 'traceback', 'traceback']
train=12 test=9 classes=['timeout', 'missing_artifact', 'traceback'] vocab=133 split=template_group_holdout
exit_status=0
shell_exit_status=0

| 동일 홀드아웃 9개 | accuracy | macro-F1 | 오분류 |
|---|---|---|---|
| 키워드 규칙 baseline | 1.000 | 1.000 | 0 |
| TF-IDF+로지스틱 회귀 대안 | 1.000 | 1.000 | 0 |
두 방식 모두 PipelineError stage returned non-zero exit status를 traceback으로 맞혔다. 이 결과는 알려진 표현을 목적형 fixture가 두 판정기에 모두 쉽게 제공했다는 뜻이지 일반화 성능이 같다는 증거가 아니다. 동률이라면 학습과 버전 관리가 추가되는 모델보다 원인이 명시적인 규칙이 운영상 단순하다.
accuracy와 함께 macro-F1을 본 이유는 세 라벨을 같은 비중으로 평가하기 위해서다. 오류 비용은 대칭이 아니다. timeout을 traceback으로 보내면 시간 예산과 프로세스 정리 대응이 늦고, missing_artifact를 일반 예외로 보내면 종료코드 0인데 필수 결과가 없는 상태를 놓칠 수 있다. 실제 도입 평가에서는 precision·recall·F-score 정의에 맞춘 라벨별 recall과 비용을 반영한 confusion matrix도 함께 봐야 한다.
미등록 오류는 세 라벨 중 하나로 강제하면 안 된다
remote peer closed connection without status처럼 세 라벨의 근거 토큰이 전혀 없는 문장을 폐쇄형 분류기에 넣으면 셋 중 하나를 억지로 골라야 한다. 아래 별도 실행은 지원 근거가 없는 입력을 unknown_signature로 거부하고 실제 셸 종료 상태 2를 남긴다.
$ .venv/bin/python - <<'PY'
import sys
text = 'remote peer closed connection without status'
supported = ('timeout', 'timed out', 'deadline', 'missing', 'not generated',
'absent', 'not created', 'traceback', 'exception',
'pipelineerror', 'stack')
if not any(term in text.lower() for term in supported):
print('REJECT unknown_signature: no supported failure evidence')
sys.exit(2)
print('ACCEPT supported_signature')
PY
status=$?
printf 'shell_exit_status=%s\n' "$status"
REJECT unknown_signature: no supported failure evidence
shell_exit_status=2

운영에서는 최고 점수 임계값, 1·2위 점수 차이, 규칙 기반 unknown 검사 중 하나로 거부 경로를 두고 표본을 사람이 라벨링해야 한다. 이번 자체 구현의 softmax 값은 보정된 확률로 검증하지 않았으므로 특정 임계값을 제안할 근거는 없다. 분류 결과만으로 재시작, 삭제, 발행 같은 자동 조치를 실행해서도 안 된다.
공식 문서가 설명하는 학습 vocabulary·IDF와 다항 로지스틱 회귀 구조는 이번 구현의 개념과 일치했다. F1과 macro 평균도 공식 정의대로 계산했다. 그러나 scikit-learn의 실제 구현을 실행하지 않았고 자체 SGD의 수렴, 정규화와 확률 보정이 공식 구현과 동등한지는 검증하지 않았다.
모델은 시간 기반 검증에서 규칙을 이길 때 다시 본다
현재 세 원인에는 키워드 규칙을 유지하고 TF-IDF 모델 도입을 보류한다. 아래 조건이 갖춰져야 비교를 다시 할 의미가 생긴다.
- 실제 사건별로 사람이 확정한 라벨이 각 클래스에 충분히 쌓인다.
- 같은 사건의 여러 줄을 한 그룹으로 묶고, 최종 평가는 더 늦은 기간의 로그로 분리한다.
- 규칙의 unknown과 오분류가 반복돼 모델이 해결할 손실이 확인된다.
- macro-F1뿐 아니라 라벨별 recall과 비용 가중 confusion matrix에서 모델이 규칙을 앞선다.
- 로그 포맷이 바뀔 때 vocabulary coverage, unknown 비율과 라벨별 recall을 시간 창별로 감시한다.
한 사건의 여러 줄을 무작위로 나누거나, 겹치는 라벨의 우선순위가 없거나, accuracy만 높이려는 상황에는 적용하면 안 된다. 다국어 메시지나 코드·경로만 남은 오류에는 단어 vocabulary가 약할 수 있다. character n-gram은 후속 비교 후보지만 이번 실험에서는 검증하지 않았다.
남은 과제는 실제 사건 라벨, 시간 기반 홀드아웃, unknown 임계값 보정, character n-gram과 비용 행렬 비교다. 이 검증 전에는 합성 fixture의 1.000을 실제 Cron 운영 정확도, 장애 감소 또는 비용 절감으로 확대할 수 없다. 지금 필요한 선택은 더 복잡한 모델이 아니라 규칙이 놓친 독립 사건을 먼저 모으는 일이다.
정규식 기반 판정부터 설계하려면 Python 로그 성공 조건을 정규식으로 판정하는 방법을 이어서 보고, 자신의 실패 라벨과 겹칠 때의 우선순위를 먼저 적어두는 편이 좋다.