268 KiB
268 KiB
In [ ]:
# ============================================================================
# [Google Colab 환경 설정] 필수 라이브러리 설치 및 임포트
# ============================================================================
!pip install -q numpy pandas matplotlib scipy scikit-learn
import os
import sys
import time
import math
import json
import re
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import warnings
warnings.filterwarnings('ignore')
# 그래프 영문 폰트 기본 설정 (환경 독립성 보장)
plt.rcParams['font.sans-serif'] = 'DejaVu Sans'
plt.rcParams['axes.unicode_minus'] = False
print(f"실행 환경 준비 완료 (Python {sys.version.split()[0]})")
In [ ]:
"""야전 장비 품질결함보고(QDR) 데이터(보고 정보, 결함 서술 텍스트, 외주정비·검사 실적, 조치 결과)의 스키마·데이터 딕셔너리(피처 20개 이상)와 결함 분석 업무 프로세스 맵을 정의하고, 가상 QDR 표본을 검증하며 오류 주입으로 검출 여부를 확인한다."""
import json
import re
import numpy as np
import pandas as pd
# ---------------------------------------------------------------------------
# [교육용가상자료] 가상 야전 장비 품질결함보고(QDR) 생성기 (실제 부대·장비·업체·정비 실적 없음)
# 가상 장비 부위 12종, 결함 유형 8종, 치명도(Critical/Major/Minor), 원인 5종, 가상 외주정비 업체 4곳 + 부대정비.
# QDR 1건마다 한국어 서술 텍스트를 만들고, 월별 건수는 계절·추세·훈련 강도(usage)·일부 제조 불량 이벤트를 따른다.
# ---------------------------------------------------------------------------
PARTS = [ # (이름, 별칭, 월 기준 건수, 계절 진폭, 정점 월, (Critical, Major 비율), 가상 단가지수, 제조사, 주요 결함 가중치)
("엔진 연료계통", ["연료계통", "연료 라인", "연료 펌프"], 26, 0.15, 7, (0.10, 0.35), 6.0, "MK1", "누유:4 마모:2 체결불량:2 오작동:2"),
("변속기", ["변속기", "T/M", "미션"], 22, 0.10, 8, (0.12, 0.38), 9.0, "MK1", "마모:4 누유:2 과열:3 오작동:1"),
("제동 장치", ["제동 장치", "브레이크", "제동 라인"], 20, 0.20, 12, (0.26, 0.40), 5.0, "MK2", "마모:4 균열:2 누유:2 체결불량:2"),
("조향 장치", ["조향 장치", "핸들 연동부", "조향 로드"], 14, 0.10, 6, (0.30, 0.40), 5.0, "MK2", "체결불량:4 균열:2 마모:3 오작동:1"),
("냉각 계통", ["냉각 계통", "라디에이터", "냉각수 라인"], 24, 0.30, 7, (0.05, 0.30), 3.0, "MK3", "누유:4 과열:4 부식:2"),
("전기·배터리", ["배터리", "전기 계통", "발전기"], 28, 0.35, 1, (0.04, 0.28), 2.5, "MK3", "접촉불량:4 부식:2 과열:2 오작동:2"),
("통신 모듈", ["통신 모듈", "무전기 연동부", "안테나 접속부"], 16, 0.05, 4, (0.03, 0.25), 4.0, "MK4", "접촉불량:5 오작동:4 부식:1"),
("유압 계통", ["유압 계통", "유압 호스", "유압 실린더"], 18, 0.10, 9, (0.10, 0.35), 4.5, "MK2", "누유:5 균열:2 마모:2 체결불량:1"),
("현가 장치", ["현가 장치", "서스펜션", "완충기"], 15, 0.08, 5, (0.06, 0.30), 3.5, "MK4", "마모:4 균열:3 체결불량:2 부식:1"),
("차체 용접부", ["차체 용접부", "프레임 용접부", "차체 이음부"], 10, 0.05, 10, (0.28, 0.40), 7.0, "MK1", "균열:6 부식:3 체결불량:1"),
("광학·조준 장비", ["광학 장비", "조준경", "관측 장비"], 8, 0.05, 3, (0.03, 0.22), 8.0, "MK4", "오작동:4 접촉불량:3 부식:2 체결불량:1"),
("공기 정화 필터", ["공기 필터", "흡기 필터", "정화 장치"], 12, 0.25, 4, (0.01, 0.12), 1.0, "MK3", "마모:5 부식:2 체결불량:3")]
NP = len(PARTS)
DEFECTS = { # 결함 유형: 증상 표현(동의어)
"균열": ["균열이 발견됨", "크랙이 확인됨", "갈라짐이 있음"], "누유": ["누유가 발생함", "오일이 새어 나옴", "누설 흔적이 있음"],
"마모": ["마모가 심함", "과도한 마모가 확인됨", "닳은 흔적이 있음"], "부식": ["부식이 진행됨", "녹이 발생함", "산화 흔적이 있음"],
"접촉불량": ["접촉 불량이 발생함", "커넥터 단선이 의심됨", "신호가 끊김"], "체결불량": ["볼트 풀림이 확인됨", "체결 상태가 불량함", "고정부가 헐거움"],
"과열": ["과열 경고가 발생함", "온도가 과도하게 상승함", "열이 심하게 남"], "오작동": ["오작동이 반복됨", "작동이 불안정함", "간헐적으로 멈춤"]}
DNAMES = list(DEFECTS)
SEV = ["Minor", "Major", "Critical"]
SEV_CUE = {0: ["경미한 수준임", "운용에는 지장이 없음", "육안 점검상 사소한 이상임", "기능에는 영향이 없음"],
1: ["성능 저하가 있음", "조속한 정비가 필요함", "출력이 떨어지는 상태임", "기능이 부분적으로 제한됨"],
2: ["즉시 운용 중지함", "안전에 위험이 있어 사용 불가", "작동 정지로 임무 수행이 어려움", "인명 안전 위협이 우려됨"]}
CAUSES = ["제조 불량", "정비 품질", "운용 부주의", "설계 취약", "자연 노후"]
CAUSE_CUE = {
"제조 불량": ["신품 인수 직후 발생함", "출고 상태에서 이미 이상이 있었음", "인수 후 얼마 지나지 않아 발생함"],
"정비 품질": ["외주 정비 후 얼마 되지 않아 재발함", "정비 후 재조립 상태가 불량함", "정비 직후 같은 부위에서 다시 발생함"],
"운용 부주의": ["험지 기동 중 충격 이후 발생함", "과부하 운용 후 발생함", "점검 없이 연속 운용한 뒤 발생함"],
"설계 취약": ["동일 증상이 다른 차량에서도 반복됨", "구조적으로 취약한 부위로 보임", "여러 부대에서 같은 형태로 보고됨"],
"자연 노후": ["사용 연한이 도래함", "장기간 사용으로 노후됨", "수명 주기 말기에 발생함"]}
VENDORS = ["외주A", "외주B", "외주C", "외주D", "부대정비"]
ACTIONS = ["정비소 입고를 요청함", "현장 임시 조치 후 운용 중임", "부품 교환을 요청함", "재검사를 요청함", "원인 확인이 필요함"]
MAKER_OF = [p[7] for p in PARTS]
PRICE = np.array([p[6] for p in PARTS])
def usage_index(months, seed=0):
"""월별 훈련 강도(미리 알 수 있는 계획 값): 연 1회 정점을 갖는 계절 + 작은 잡음"""
r = np.random.default_rng(seed + 99)
t = np.arange(months)
return 1 + 0.25 * np.sin(2 * np.pi * (t - 3) / 12) + r.normal(0, 0.04, months)
def make_qdr(months=36, seed=1, scale=1.0, surge=True):
"""반환: QDR DataFrame(건당 1행), 월별 usage, 급증 이벤트 목록"""
rng = np.random.default_rng(seed)
use = usage_index(months, seed)
events = []
if surge:
for _ in range(max(2, months // 12 * 2)):
events.append((int(rng.integers(NP)), int(rng.integers(6, months))))
rows = []
wdef = [np.array([float(x.split(":")[1]) for x in p[8].split()]) for p in PARTS]
ddef = [[x.split(":")[0] for x in p[8].split()] for p in PARTS]
n = 0
for m in range(months):
for pi, p in enumerate(PARTS):
lam = p[2] * scale * (1 + 0.010 * m) * use[m] * (1 + p[3] * np.cos(2 * np.pi * (m % 12 - p[4]) / 12))
ev = (pi, m) in events
k = rng.poisson(lam * (2.6 if ev else 1.0))
for _ in range(k):
rows.append((n, m, pi, ev)); n += 1
N = len(rows)
A = np.array(rows)
month, part, ev = A[:, 1], A[:, 2], A[:, 3].astype(bool)
defect = np.array([rng.choice(ddef[i], p=wdef[i] / wdef[i].sum()) for i in part])
cr = np.array([PARTS[i][5][0] for i in part]); ma = np.array([PARTS[i][5][1] for i in part])
u = rng.random(N)
sev = np.where(u < cr, 2, np.where(u < cr + ma, 1, 0))
vendor = rng.choice(VENDORS, N, p=[0.25, 0.2, 0.2, 0.15, 0.2])
# 원인: 기본 비율에 부위(제조사)·업체·이벤트 효과를 곱한다
base = np.array([0.22, 0.20, 0.20, 0.16, 0.22])
P = np.tile(base, (N, 1))
P[vendor == "외주B", 1] *= 2.6
P[ev, 0] *= 5.0
design = np.isin(part, [2, 3, 7]); P[design, 3] *= 2.2
P[np.isin(part, [4, 5, 11]), 0] *= 2.0 # 제조사 MK3 납품 부위는 제조 불량 비중이 높다
P[defect == "마모", 4] *= 1.6; P[defect == "누유", 4] *= 1.3
P /= P.sum(axis=1, keepdims=True)
cause = np.array([CAUSES[rng.choice(5, p=P[i])] for i in range(N)])
since = np.where(cause == "정비 품질", rng.exponential(35, N), rng.exponential(160, N)).clip(1, 720).round().astype(int)
wait = (rng.lognormal(1.3, 0.5, N) * np.where(vendor == "외주B", 1.7, np.where(vendor == "부대정비", 0.6, 1.0)) * (0.7 + 0.5 * use[month])).round(1)
reins = rng.random(N) < np.where(cause == "정비 품질", 0.6, 0.08)
texts = []
for i in range(N):
pt = PARTS[part[i]]
cl = [f"{rng.choice(pt[1])}에서 {rng.choice(DEFECTS[defect[i]])}"]
if rng.random() < 0.7: cl.append(rng.choice(SEV_CUE[sev[i]]))
if rng.random() < 0.65: # 원인 단서: 8%는 실제 원인과 다른 단서가 적힌다(보고자의 추정)
cc = cause[i] if rng.random() > 0.08 else rng.choice(CAUSES)
cl.append(rng.choice(CAUSE_CUE[cc]))
cl.append(rng.choice(ACTIONS))
if rng.random() < 0.5: cl.insert(0, f"VU-{rng.integers(1, 31):02d} 소속")
texts.append(". ".join(cl) + ".")
df = pd.DataFrame({"qdr_id": [f"Q{seed}{i:06d}" for i in range(N)], "month": month, "unit": [f"VU-{x:02d}" for x in rng.integers(1, 31, N)],
"part": part, "defect": defect, "sev": sev, "cause": cause, "vendor": vendor, "maker": [MAKER_OF[i] for i in part],
"since_maint": since, "inspect_wait": wait, "reinspect": reins, "event": ev, "text": texts})
df["cost"] = PRICE[df["part"]] * np.array([1.0, 2.0, 4.0])[df["sev"]] # [교육용가상자료] 수리·조치 비용지수
return df, use, events
# 1) 업무 프로세스 맵: 결함 발생부터 원인 환류까지 (각 단계에서 생기는 데이터)
PROCESS = [("결함 발견", "발견 부대, 장비, 부위, 서술 텍스트"), ("QDR 작성", "QDR ID, 작성일, 치명도(Critical/Major/Minor)"),
("접수·검토", "텍스트 정제, 부위·결함 유형 라벨"), ("검사·판정", "검사 대기일, 재검사 여부, 외주정비 업체"),
("원인 분석", "원인 구분(제조·정비·운용·설계·노후), 제조사"), ("조치", "수리·교환·하자 구상 여부, 조치 비용"),
("실적 집계", "부위별·월별 건수, 취약성 지수"), ("다발 결함 식별", "파레토(ABC), 이상 신호"), ("사후검토(AAR)", "원인 오분류 사례, 룰 조정 이력")]
# 2) 스키마(데이터 딕셔너리): 그룹 / 필드 / 자료형 / 규칙 / 설명
PART_NAMES = [p[0] for p in PARTS]
S = {
"report": {
"qdr_id": (str, {"pattern": r"^Q\d{7,8}$"}, "QDR ID"),
"month": (int, {"min": 0, "max": 143}, "보고 월(모의 시작 기준 경과 월)"),
"unit": (str, {"pattern": r"^VU-\d{2}$"}, "보고 부대 코드(가상)"),
"equip_code": (str, {"pattern": r"^VX-\d{4}-\d{4}$"}, "장비 식별코드(국방 표준 코드 형식을 모사한 가상 코드)"),
"fsc_group": (str, {"pattern": r"^\d{4}$"}, "보급 분류군(가상 4자리)"),
"part": (str, {"enum": PART_NAMES}, "결함 부위(장비 계통)"),
"severity": (str, {"enum": SEV}, "결함 치명도"),
},
"text": {
"text": (str, {"min_len": 5}, "결함 서술 텍스트(비정형)"),
"defect": (str, {"enum": DNAMES + ["미분류"]}, "결함 유형 라벨"),
"n_tokens": (int, {"min": 1, "max": 200}, "정제 후 토큰 수"),
"part_label": (str, {"enum": PART_NAMES + ["미분류"]}, "서술에서 추출한 부위 라벨"),
},
"inspection": {
"vendor": (str, {"enum": VENDORS}, "직전 정비 수행처(외주 업체 또는 부대정비)"),
"since_maint": (int, {"min": 0, "max": 1500}, "직전 정비 후 경과일"),
"inspect_wait": (float, {"min": 0, "max": 60}, "검사 대기일(검사 병목 지표)"),
"reinspect": (bool, {}, "재검사 여부"),
},
"result": {
"cause": (str, {"enum": CAUSES}, "원인 구분 - 예측대상 1"),
"maker": (str, {"pattern": r"^MK\d$"}, "제조사 코드(가상)"),
"cost": (float, {"min": 0, "max": 100}, "조치 비용지수 - 예측대상 2"),
"monthly_count": (int, {"min": 0, "max": 500}, "부위별 월간 QDR 건수 - 예측대상 3"),
"vuln_index": (float, {"min": 0, "max": 1000}, "치명도 가중 취약성 지수 - 예측대상 4"),
"event_flag": (bool, {}, "제조 LOT 불량 등 급증 이벤트 월 여부"),
"hitl_status": (str, {"enum": ["PENDING", "APPROVED", "REJECTED", "NO_RESPONSE"]}, "품질관 검토 상태"),
},
}
def check(name, v, typ, rule):
if not isinstance(v, typ) or (typ in (int, float) and isinstance(v, bool)):
return [f"{name}: 자료형 오류({type(v).__name__})"]
e = []
if "enum" in rule and v not in rule["enum"]:
e.append(f"{name}: 허용되지 않은 값 '{v}'")
if "min" in rule and v < rule["min"]:
e.append(f"{name}: 최소값 {rule['min']} 미만")
if "max" in rule and v > rule["max"]:
e.append(f"{name}: 최대값 {rule['max']} 초과")
if "min_len" in rule and len(v) < rule["min_len"]:
e.append(f"{name}: 서술이 너무 짧음")
if "pattern" in rule and not re.match(rule["pattern"], v):
e.append(f"{name}: 형식 불일치 '{v}'")
return e
def validate(rec):
errs = []
for g, fields in S.items():
for k, (typ, rule, _) in fields.items():
if k not in rec.get(g, {}):
errs.append(f"{g}.{k}: 필수 필드 누락")
else:
errs += check(f"{g}.{k}", rec[g][k], typ, rule)
return errs
# 3) [교육용가상자료] 생성 데이터에서 20건을 레코드 형태로 만들어 검증
df, use, events = make_qdr(36, seed=1)
rng = np.random.default_rng(7)
mc = df.groupby(["part", "month"]).size()
recs = []
for r in df.sample(20, random_state=1).itertuples():
t = r.text
recs.append({
"report": {"qdr_id": r.qdr_id, "month": int(r.month), "unit": r.unit, "equip_code": f"VX-{rng.integers(1000, 9999)}-{rng.integers(1000, 9999)}",
"fsc_group": f"{rng.integers(1000, 9999)}", "part": PART_NAMES[r.part], "severity": SEV[r.sev]},
"text": {"text": t, "defect": r.defect, "n_tokens": max(len(t.split()), 1), "part_label": PART_NAMES[r.part]},
"inspection": {"vendor": r.vendor, "since_maint": int(r.since_maint), "inspect_wait": float(r.inspect_wait), "reinspect": bool(r.reinspect)},
"result": {"cause": r.cause, "maker": r.maker, "cost": float(r.cost), "monthly_count": int(mc[(r.part, r.month)]),
"vuln_index": float(sum(np.array([1, 3, 9])[df.loc[(df.part == r.part) & (df.month == r.month), "sev"]])), "event_flag": bool(r.event), "hitl_status": "PENDING"}})
ok = sum(not validate(r) for r in recs)
print(f"[교육용가상자료] 가상 QDR {len(df)}건 생성, 표본 20건 중 검증 통과 {ok}건, 오류 {len(recs) - ok}건")
print(f"전체 데이터 부위·치명도·원인 허용값 검증: {'통과' if df['part'].between(0, NP - 1).all() and df['sev'].isin([0, 1, 2]).all() and df['cause'].isin(CAUSES).all() else '실패'}")
# 4) 오류 주입 시험
bad = json.loads(json.dumps(recs[0]))
bad["report"]["qdr_id"] = "QDR-7" # ID 형식 오류
bad["report"]["severity"] = "Severe" # 허용되지 않은 치명도
bad["text"]["text"] = "고장" # 서술 너무 짧음
del bad["inspection"]["vendor"] # 필수 필드 누락
bad["inspection"]["inspect_wait"] = "3일" # 자료형 오류
bad["result"]["cost"] = 250.0 # 범위 초과
print("\n[오류 주입 결과]")
for e in validate(bad):
print(" -", e)
# 5) 프로세스 맵과 데이터 딕셔너리
print("\n[업무 프로세스 맵]")
print(" -> ".join(f"{i}.{p}" for i, (p, _) in enumerate(PROCESS, 1)))
for p, d in PROCESS:
print(f" {p:10s}: {d}")
rows = [(g, k, t.__name__, str(r)[:34] if r else "-", d) for g, f in S.items() for k, (t, r, d) in f.items()]
dd = pd.DataFrame(rows, columns=["그룹", "필드", "자료형", "규칙", "설명"])
print(f"\n[데이터 딕셔너리] 피처 {len(dd)}개 (목표 20개 이상: {'달성' if len(dd) >= 20 else '미달'})")
print(dd[["그룹", "필드", "자료형", "설명"]].to_string(index=False))
In [ ]:
"""표기 불일치·중복·전각·특수문자·약어·오타·불가능값·결측이 섞인 가상 QDR 원천 자료에 정제 규칙 10종(텍스트 정규화, 약어 확장, 오타 교정, KNN 결측 보정, 부위·결함 유형 키워드 라벨링 포함)을 적용하고, 긴급 보고는 보존하며 정합성 검증 통과율과 라벨링 정확도, 정제 전후 통계를 확인한다."""
import re
import unicodedata
import numpy as np
import pandas as pd
from sklearn.impute import KNNImputer
# ---------------------------------------------------------------------------
# [교육용가상자료] 가상 야전 장비 품질결함보고(QDR) 생성기 (실제 부대·장비·업체·정비 실적 없음)
# 가상 장비 부위 12종, 결함 유형 8종, 치명도(Critical/Major/Minor), 원인 5종, 가상 외주정비 업체 4곳 + 부대정비.
# QDR 1건마다 한국어 서술 텍스트를 만들고, 월별 건수는 계절·추세·훈련 강도(usage)·일부 제조 불량 이벤트를 따른다.
# ---------------------------------------------------------------------------
PARTS = [ # (이름, 별칭, 월 기준 건수, 계절 진폭, 정점 월, (Critical, Major 비율), 가상 단가지수, 제조사, 주요 결함 가중치)
("엔진 연료계통", ["연료계통", "연료 라인", "연료 펌프"], 26, 0.15, 7, (0.10, 0.35), 6.0, "MK1", "누유:4 마모:2 체결불량:2 오작동:2"),
("변속기", ["변속기", "T/M", "미션"], 22, 0.10, 8, (0.12, 0.38), 9.0, "MK1", "마모:4 누유:2 과열:3 오작동:1"),
("제동 장치", ["제동 장치", "브레이크", "제동 라인"], 20, 0.20, 12, (0.26, 0.40), 5.0, "MK2", "마모:4 균열:2 누유:2 체결불량:2"),
("조향 장치", ["조향 장치", "핸들 연동부", "조향 로드"], 14, 0.10, 6, (0.30, 0.40), 5.0, "MK2", "체결불량:4 균열:2 마모:3 오작동:1"),
("냉각 계통", ["냉각 계통", "라디에이터", "냉각수 라인"], 24, 0.30, 7, (0.05, 0.30), 3.0, "MK3", "누유:4 과열:4 부식:2"),
("전기·배터리", ["배터리", "전기 계통", "발전기"], 28, 0.35, 1, (0.04, 0.28), 2.5, "MK3", "접촉불량:4 부식:2 과열:2 오작동:2"),
("통신 모듈", ["통신 모듈", "무전기 연동부", "안테나 접속부"], 16, 0.05, 4, (0.03, 0.25), 4.0, "MK4", "접촉불량:5 오작동:4 부식:1"),
("유압 계통", ["유압 계통", "유압 호스", "유압 실린더"], 18, 0.10, 9, (0.10, 0.35), 4.5, "MK2", "누유:5 균열:2 마모:2 체결불량:1"),
("현가 장치", ["현가 장치", "서스펜션", "완충기"], 15, 0.08, 5, (0.06, 0.30), 3.5, "MK4", "마모:4 균열:3 체결불량:2 부식:1"),
("차체 용접부", ["차체 용접부", "프레임 용접부", "차체 이음부"], 10, 0.05, 10, (0.28, 0.40), 7.0, "MK1", "균열:6 부식:3 체결불량:1"),
("광학·조준 장비", ["광학 장비", "조준경", "관측 장비"], 8, 0.05, 3, (0.03, 0.22), 8.0, "MK4", "오작동:4 접촉불량:3 부식:2 체결불량:1"),
("공기 정화 필터", ["공기 필터", "흡기 필터", "정화 장치"], 12, 0.25, 4, (0.01, 0.12), 1.0, "MK3", "마모:5 부식:2 체결불량:3")]
NP = len(PARTS)
DEFECTS = { # 결함 유형: 증상 표현(동의어)
"균열": ["균열이 발견됨", "크랙이 확인됨", "갈라짐이 있음"], "누유": ["누유가 발생함", "오일이 새어 나옴", "누설 흔적이 있음"],
"마모": ["마모가 심함", "과도한 마모가 확인됨", "닳은 흔적이 있음"], "부식": ["부식이 진행됨", "녹이 발생함", "산화 흔적이 있음"],
"접촉불량": ["접촉 불량이 발생함", "커넥터 단선이 의심됨", "신호가 끊김"], "체결불량": ["볼트 풀림이 확인됨", "체결 상태가 불량함", "고정부가 헐거움"],
"과열": ["과열 경고가 발생함", "온도가 과도하게 상승함", "열이 심하게 남"], "오작동": ["오작동이 반복됨", "작동이 불안정함", "간헐적으로 멈춤"]}
DNAMES = list(DEFECTS)
SEV = ["Minor", "Major", "Critical"]
SEV_CUE = {0: ["경미한 수준임", "운용에는 지장이 없음", "육안 점검상 사소한 이상임", "기능에는 영향이 없음"],
1: ["성능 저하가 있음", "조속한 정비가 필요함", "출력이 떨어지는 상태임", "기능이 부분적으로 제한됨"],
2: ["즉시 운용 중지함", "안전에 위험이 있어 사용 불가", "작동 정지로 임무 수행이 어려움", "인명 안전 위협이 우려됨"]}
CAUSES = ["제조 불량", "정비 품질", "운용 부주의", "설계 취약", "자연 노후"]
CAUSE_CUE = {
"제조 불량": ["신품 인수 직후 발생함", "출고 상태에서 이미 이상이 있었음", "인수 후 얼마 지나지 않아 발생함"],
"정비 품질": ["외주 정비 후 얼마 되지 않아 재발함", "정비 후 재조립 상태가 불량함", "정비 직후 같은 부위에서 다시 발생함"],
"운용 부주의": ["험지 기동 중 충격 이후 발생함", "과부하 운용 후 발생함", "점검 없이 연속 운용한 뒤 발생함"],
"설계 취약": ["동일 증상이 다른 차량에서도 반복됨", "구조적으로 취약한 부위로 보임", "여러 부대에서 같은 형태로 보고됨"],
"자연 노후": ["사용 연한이 도래함", "장기간 사용으로 노후됨", "수명 주기 말기에 발생함"]}
VENDORS = ["외주A", "외주B", "외주C", "외주D", "부대정비"]
ACTIONS = ["정비소 입고를 요청함", "현장 임시 조치 후 운용 중임", "부품 교환을 요청함", "재검사를 요청함", "원인 확인이 필요함"]
MAKER_OF = [p[7] for p in PARTS]
PRICE = np.array([p[6] for p in PARTS])
def usage_index(months, seed=0):
"""월별 훈련 강도(미리 알 수 있는 계획 값): 연 1회 정점을 갖는 계절 + 작은 잡음"""
r = np.random.default_rng(seed + 99)
t = np.arange(months)
return 1 + 0.25 * np.sin(2 * np.pi * (t - 3) / 12) + r.normal(0, 0.04, months)
def make_qdr(months=36, seed=1, scale=1.0, surge=True):
"""반환: QDR DataFrame(건당 1행), 월별 usage, 급증 이벤트 목록"""
rng = np.random.default_rng(seed)
use = usage_index(months, seed)
events = []
if surge:
for _ in range(max(2, months // 12 * 2)):
events.append((int(rng.integers(NP)), int(rng.integers(6, months))))
rows = []
wdef = [np.array([float(x.split(":")[1]) for x in p[8].split()]) for p in PARTS]
ddef = [[x.split(":")[0] for x in p[8].split()] for p in PARTS]
n = 0
for m in range(months):
for pi, p in enumerate(PARTS):
lam = p[2] * scale * (1 + 0.010 * m) * use[m] * (1 + p[3] * np.cos(2 * np.pi * (m % 12 - p[4]) / 12))
ev = (pi, m) in events
k = rng.poisson(lam * (2.6 if ev else 1.0))
for _ in range(k):
rows.append((n, m, pi, ev)); n += 1
N = len(rows)
A = np.array(rows)
month, part, ev = A[:, 1], A[:, 2], A[:, 3].astype(bool)
defect = np.array([rng.choice(ddef[i], p=wdef[i] / wdef[i].sum()) for i in part])
cr = np.array([PARTS[i][5][0] for i in part]); ma = np.array([PARTS[i][5][1] for i in part])
u = rng.random(N)
sev = np.where(u < cr, 2, np.where(u < cr + ma, 1, 0))
vendor = rng.choice(VENDORS, N, p=[0.25, 0.2, 0.2, 0.15, 0.2])
# 원인: 기본 비율에 부위(제조사)·업체·이벤트 효과를 곱한다
base = np.array([0.22, 0.20, 0.20, 0.16, 0.22])
P = np.tile(base, (N, 1))
P[vendor == "외주B", 1] *= 2.6
P[ev, 0] *= 5.0
design = np.isin(part, [2, 3, 7]); P[design, 3] *= 2.2
P[np.isin(part, [4, 5, 11]), 0] *= 2.0 # 제조사 MK3 납품 부위는 제조 불량 비중이 높다
P[defect == "마모", 4] *= 1.6; P[defect == "누유", 4] *= 1.3
P /= P.sum(axis=1, keepdims=True)
cause = np.array([CAUSES[rng.choice(5, p=P[i])] for i in range(N)])
since = np.where(cause == "정비 품질", rng.exponential(35, N), rng.exponential(160, N)).clip(1, 720).round().astype(int)
wait = (rng.lognormal(1.3, 0.5, N) * np.where(vendor == "외주B", 1.7, np.where(vendor == "부대정비", 0.6, 1.0)) * (0.7 + 0.5 * use[month])).round(1)
reins = rng.random(N) < np.where(cause == "정비 품질", 0.6, 0.08)
texts = []
for i in range(N):
pt = PARTS[part[i]]
cl = [f"{rng.choice(pt[1])}에서 {rng.choice(DEFECTS[defect[i]])}"]
if rng.random() < 0.7: cl.append(rng.choice(SEV_CUE[sev[i]]))
if rng.random() < 0.65: # 원인 단서: 8%는 실제 원인과 다른 단서가 적힌다(보고자의 추정)
cc = cause[i] if rng.random() > 0.08 else rng.choice(CAUSES)
cl.append(rng.choice(CAUSE_CUE[cc]))
cl.append(rng.choice(ACTIONS))
if rng.random() < 0.5: cl.insert(0, f"VU-{rng.integers(1, 31):02d} 소속")
texts.append(". ".join(cl) + ".")
df = pd.DataFrame({"qdr_id": [f"Q{seed}{i:06d}" for i in range(N)], "month": month, "unit": [f"VU-{x:02d}" for x in rng.integers(1, 31, N)],
"part": part, "defect": defect, "sev": sev, "cause": cause, "vendor": vendor, "maker": [MAKER_OF[i] for i in part],
"since_maint": since, "inspect_wait": wait, "reinspect": reins, "event": ev, "text": texts})
df["cost"] = PRICE[df["part"]] * np.array([1.0, 2.0, 4.0])[df["sev"]] # [교육용가상자료] 수리·조치 비용지수
return df, use, events
# ---------------------------------------------------------------------------
# 공통 텍스트 조각: 정규화, 간이 토큰화(조사 제거), 부위·결함·치명도·원인 키워드 라벨링
# ---------------------------------------------------------------------------
TYPO = {"누윳": "누유", "균렬": "균열", "접축 불량": "접촉 불량", "부씩": "부식", "마묘": "마모", "과렬": "과열", "오작똥": "오작동", "재동": "제동"}
ABBR = {"T/M": "변속기", "BAT": "배터리"}
PART_KW = sorted([(a, i) for i, p in enumerate(PARTS) for a in p[1]], key=lambda x: -len(x[0]))
DEF_KW = {"균열": ["균열", "크랙", "갈라"], "누유": ["누유", "새어", "누설"], "마모": ["마모", "닳은"], "부식": ["부식", "녹이", "산화"],
"접촉불량": ["접촉 불량", "단선", "끊김"], "체결불량": ["볼트", "체결", "헐거"], "과열": ["과열", "온도", "열이"], "오작동": ["오작동", "불안정", "멈춤"]}
SEV_KW = [(2, ["즉시 운용 중지", "사용 불가", "위험"]), (1, ["성능 저하", "조속한"]), (0, ["경미", "지장이 없"])]
CAUSE_KW = {"제조 불량": ["신품", "출고", "인수"], "정비 품질": ["정비 후", "재조립", "정비 직후"], "운용 부주의": ["충격", "과부하", "점검 없이"],
"설계 취약": ["동일 증상", "구조적", "여러 부대"], "자연 노후": ["사용 연한", "노후", "수명"]}
PARTICLES = ("에서", "으로", "이", "가", "은", "는", "을", "를", "에", "의", "로", "와", "과", "도", "함", "음", "됨")
def norm(t):
"""정규화: 전각->반각, 특수문자·중복 공백 제거, 약어 확장, 오타 교정"""
t = unicodedata.normalize("NFKC", str(t))
t = re.sub(r"[!#~*@^]+", " ", t)
for a, b in ABBR.items():
t = t.replace(a, b)
for a, b in TYPO.items():
t = t.replace(a, b)
return re.sub(r"\s+", " ", t).strip()
def tok(t):
out = []
for w in re.split(r"[\s.,]+", norm(t)):
if not w or re.fullmatch(r"[\d\-A-Za-z]+", w):
continue
for p in PARTICLES:
if len(w) > len(p) + 1 and w.endswith(p):
w = w[:-len(p)]
break
out.append(w)
return out
def label_part(t):
for a, i in PART_KW:
if a in t:
return i
return -1
def label_defect(t):
for d, ks in DEF_KW.items():
if any(k in t for k in ks):
return d
return "미분류"
def label_sev(t):
for s, ks in SEV_KW:
if any(k in t for k in ks):
return s
return -1
def label_cause(t):
for c, ks in CAUSE_KW.items():
if any(k in t for k in ks):
return c
return "미상"
# 1) [교육용가상자료] 원천 자료: 생성 자료에 일부러 오류를 섞는다
df0, use, events = make_qdr(36, seed=1)
rng = np.random.default_rng(2)
raw = df0[["qdr_id", "month", "unit", "vendor", "since_maint", "inspect_wait", "text", "part", "defect"]].copy()
raw["date"] = [f"2024-{m % 12 + 1:02d}-{rng.integers(1, 28):02d}" for m in raw["month"]]
raw["urgent"] = False
n = len(raw)
urg = rng.choice(n, 40, replace=False)
raw.loc[urg, "inspect_wait"] = 0.0; raw.loc[urg, "urgent"] = True # 긴급 보고: 대기일 0은 정상 예외
raw.loc[urg, "text"] = "긴급 보고. " + raw.loc[urg, "text"]
def pick(k):
return rng.choice(n, k, replace=False)
i2 = pick(300)
raw.loc[i2, "unit"] = [u.lower() if j % 3 == 0 else u.replace("-", "") if j % 3 == 1 else "VU-" + str(int(u[-2:])) for j, u in enumerate(raw.loc[i2, "unit"])]
i3 = pick(400); raw.loc[i3, "date"] = [d.replace("-", ".") if j % 2 else d[2:].replace("-", "/") for j, d in enumerate(raw.loc[i3, "date"])]
i4 = pick(600); raw.loc[i4, "text"] = [t.replace(". ", ". ").replace("에서", "에서 ").replace("함.", "함!!") + " ##" if j % 2 else unicodedata.normalize("NFKC", t).replace(".", ".") for j, t in enumerate(raw.loc[i4, "text"])]
i5 = pick(300); raw.loc[i5, "text"] = [t.replace("변속기", "T/M").replace("배터리", "BAT") for t in raw.loc[i5, "text"]]
i6 = rng.choice(np.where(raw["text"].str.contains("누유|균열|부식|제동|과열"))[0], 400, replace=False); raw.loc[i6, "text"] = [t.replace("누유", "누윳").replace("균열", "균렬").replace("부식", "부씩").replace("제동", "재동").replace("과열", "과렬") for t in raw.loc[i6, "text"]]
i7 = pick(80); raw.loc[i7, "inspect_wait"] = -1.0
i8 = pick(400); raw.loc[i8, "since_maint"] = np.nan
dup = raw.sample(150, random_state=3); raw = pd.concat([raw, dup], ignore_index=True)
print(f"원천 자료 {len(raw)}행 (정상 {n}행 + 긴급 보고 {len(urg)}건 + 중복 {len(dup)}행, 오류 주입 {len(i2) + len(i3) + len(i4) + len(i5) + len(i6) + len(i7) + len(i8)}행)")
# 2) 정제 규칙 10종
log = []
def rule(name, cnt):
log.append((name, int(cnt)))
df = raw.copy()
before_len = len(df)
df = df.drop_duplicates("qdr_id").reset_index(drop=True); rule("R1 중복 QDR 제거", before_len - len(df))
fix = lambda u: "VU-%02d" % int(re.sub(r"\D", "", u))
bad = ~df["unit"].str.fullmatch(r"VU-\d{2}"); df["unit"] = df["unit"].map(fix); rule("R2 부대 코드 표기 통일", bad.sum())
bad = ~df["date"].str.fullmatch(r"\d{4}-\d{2}-\d{2}")
df["date"] = [pd.to_datetime(d.replace(".", "-"), format="%Y-%m-%d" if len(d) == 10 else "%y-%m-%d", errors="coerce") if "/" not in d else pd.to_datetime(d, format="%y/%m/%d") for d in df["date"]]
rule("R3 일자 형식 통일", bad.sum())
t0 = df["text"].copy()
df["text"] = df["text"].map(lambda t: re.sub(r"\s+", " ", re.sub(r"[!#~*@^]+", " ", unicodedata.normalize("NFKC", t))).strip()); rule("R4 텍스트 정규화(전각·특수문자·공백)", (df["text"] != t0).sum())
c = df["text"].str.contains("T/M|BAT"); df["text"] = df["text"].map(norm); rule("R5 약어 확장(T/M, BAT)", c.sum())
c = t0.map(lambda t: any(k in t for k in TYPO))
rule("R6 오타 교정(사전 기반)", c.sum())
bad = df["inspect_wait"] < 0; df.loc[bad, "inspect_wait"] = np.nan; rule("R7 불가능값(음수 대기일) 결측 처리", bad.sum())
miss = df["since_maint"].isna() | df["inspect_wait"].isna()
X = pd.DataFrame({"part": df["part"], "vendor": df["vendor"].astype("category").cat.codes * 5, "wait": df["inspect_wait"], "since": df["since_maint"], "urgent": df["urgent"] * 20})
df[["inspect_wait", "since_maint"]] = KNNImputer(n_neighbors=5).fit_transform(X)[:, 2:4]; rule("R8 대기일·정비후경과일 KNN 보정", miss.sum())
df["part_label"] = df["text"].map(label_part); df["defect_label"] = df["text"].map(label_defect); rule("R9 부위·결함 유형 키워드 라벨링", len(df))
flag = (df["inspect_wait"] > 40) | ((df["inspect_wait"] == 0) & ~df["urgent"])
df["flag"] = flag
rule("R10 긴급 보고 보존(일반 이상값은 표시만)", int(df["urgent"].sum()))
print("\n[적용 규칙과 처리 건수]")
for nm, c in log:
print(f" {nm:40s} {c:6d}건")
# 3) 정합성 검증 + 라벨링 정확도
truth = df.merge(df0[["qdr_id", "part", "defect"]], on="qdr_id", suffixes=("", "_t"))
rawlab = raw.drop_duplicates("qdr_id").assign(pl=lambda d: d["text"].map(label_part), dl=lambda d: d["text"].map(label_defect))
rawacc_p, rawacc_d = (rawlab["pl"] == rawlab["part"]).mean(), (rawlab["dl"] == rawlab["defect"]).mean()
acc_p, acc_d = (truth["part_label"] == truth["part_t"]).mean(), (truth["defect_label"] == truth["defect_t"]).mean()
checks = {
"R1 ID 유일": df["qdr_id"].is_unique, "R2 부대 코드 형식": df["unit"].str.fullmatch(r"VU-\d{2}").all(), "R3 일자 변환": df["date"].notna().all(),
"R4 특수문자·이중 공백 없음": not df["text"].str.contains(r"[!#]|\s\s|.").any(), "R5 약어 없음": not df["text"].str.contains("T/M|BAT").any(),
"R6 오타 없음": not df["text"].map(lambda t: any(k in t for k in TYPO)).any(), "R7 음수 대기일 없음": (df["inspect_wait"] >= 0).all(),
"R8 결측 없음": df[["inspect_wait", "since_maint"]].notna().all().all(), "R9 부위 라벨 부여율 100%": (df["part_label"] >= 0).all(),
"R10 긴급 보고 40건 보존": int(df["urgent"].sum()) == 40}
npass = sum(checks.values())
print(f"\n정합성 검증 {npass}/10 통과, 통과율 {npass / 10:.0%} (목표 100%: {'달성' if npass == 10 else '미달'})")
print(f"라벨링 정확도: 부위 {rawacc_p:.1%} -> {acc_p:.1%}, 결함 유형 {rawacc_d:.1%} -> {acc_d:.1%} (정제 전 -> 후)")
print(f"긴급 보고 {int(df['urgent'].sum())}건 보존, 일반 이상 표시 {int((df['flag'] & ~df['urgent']).sum())}건")
# 4) 정제 전후 기술통계
def stats(d, after):
return {"행 수": len(d), "부대 표기 종류 수": d["unit"].nunique(), "서술 평균 길이(자)": round(d["text"].str.len().mean(), 1),
"대기일 최소": round(d["inspect_wait"].min(), 1), "대기일 결측 수": int(d["inspect_wait"].isna().sum()), "정비후경과일 결측 수": int(d["since_maint"].isna().sum())}
print("\n[정제 전후 기술통계]")
print(pd.DataFrame({"정제 전": stats(raw, False), "정제 후": stats(df, True)}).to_string())
In [ ]:
"""빈도 분석 기반 1차 다발 결함 부위 도출 Baseline(학습 기간 건수 상위 5개 부위)의 적중률과 한계(치명도·비용 순위와의 차이)를 확인하고, 탐색적 분석(EDA 차트 8종, 월간 QDR 건수와 피처의 상관계수·p-value, 외주정비 업체별 검사 대기·원인, 건수·비용 파레토 ABC 분류)으로 핵심 관리 대상을 식별한다."""
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from scipy.stats import spearmanr
# ---------------------------------------------------------------------------
# [교육용가상자료] 가상 야전 장비 품질결함보고(QDR) 생성기 (실제 부대·장비·업체·정비 실적 없음)
# 가상 장비 부위 12종, 결함 유형 8종, 치명도(Critical/Major/Minor), 원인 5종, 가상 외주정비 업체 4곳 + 부대정비.
# QDR 1건마다 한국어 서술 텍스트를 만들고, 월별 건수는 계절·추세·훈련 강도(usage)·일부 제조 불량 이벤트를 따른다.
# ---------------------------------------------------------------------------
PARTS = [ # (이름, 별칭, 월 기준 건수, 계절 진폭, 정점 월, (Critical, Major 비율), 가상 단가지수, 제조사, 주요 결함 가중치)
("엔진 연료계통", ["연료계통", "연료 라인", "연료 펌프"], 26, 0.15, 7, (0.10, 0.35), 6.0, "MK1", "누유:4 마모:2 체결불량:2 오작동:2"),
("변속기", ["변속기", "T/M", "미션"], 22, 0.10, 8, (0.12, 0.38), 9.0, "MK1", "마모:4 누유:2 과열:3 오작동:1"),
("제동 장치", ["제동 장치", "브레이크", "제동 라인"], 20, 0.20, 12, (0.26, 0.40), 5.0, "MK2", "마모:4 균열:2 누유:2 체결불량:2"),
("조향 장치", ["조향 장치", "핸들 연동부", "조향 로드"], 14, 0.10, 6, (0.30, 0.40), 5.0, "MK2", "체결불량:4 균열:2 마모:3 오작동:1"),
("냉각 계통", ["냉각 계통", "라디에이터", "냉각수 라인"], 24, 0.30, 7, (0.05, 0.30), 3.0, "MK3", "누유:4 과열:4 부식:2"),
("전기·배터리", ["배터리", "전기 계통", "발전기"], 28, 0.35, 1, (0.04, 0.28), 2.5, "MK3", "접촉불량:4 부식:2 과열:2 오작동:2"),
("통신 모듈", ["통신 모듈", "무전기 연동부", "안테나 접속부"], 16, 0.05, 4, (0.03, 0.25), 4.0, "MK4", "접촉불량:5 오작동:4 부식:1"),
("유압 계통", ["유압 계통", "유압 호스", "유압 실린더"], 18, 0.10, 9, (0.10, 0.35), 4.5, "MK2", "누유:5 균열:2 마모:2 체결불량:1"),
("현가 장치", ["현가 장치", "서스펜션", "완충기"], 15, 0.08, 5, (0.06, 0.30), 3.5, "MK4", "마모:4 균열:3 체결불량:2 부식:1"),
("차체 용접부", ["차체 용접부", "프레임 용접부", "차체 이음부"], 10, 0.05, 10, (0.28, 0.40), 7.0, "MK1", "균열:6 부식:3 체결불량:1"),
("광학·조준 장비", ["광학 장비", "조준경", "관측 장비"], 8, 0.05, 3, (0.03, 0.22), 8.0, "MK4", "오작동:4 접촉불량:3 부식:2 체결불량:1"),
("공기 정화 필터", ["공기 필터", "흡기 필터", "정화 장치"], 12, 0.25, 4, (0.01, 0.12), 1.0, "MK3", "마모:5 부식:2 체결불량:3")]
NP = len(PARTS)
DEFECTS = { # 결함 유형: 증상 표현(동의어)
"균열": ["균열이 발견됨", "크랙이 확인됨", "갈라짐이 있음"], "누유": ["누유가 발생함", "오일이 새어 나옴", "누설 흔적이 있음"],
"마모": ["마모가 심함", "과도한 마모가 확인됨", "닳은 흔적이 있음"], "부식": ["부식이 진행됨", "녹이 발생함", "산화 흔적이 있음"],
"접촉불량": ["접촉 불량이 발생함", "커넥터 단선이 의심됨", "신호가 끊김"], "체결불량": ["볼트 풀림이 확인됨", "체결 상태가 불량함", "고정부가 헐거움"],
"과열": ["과열 경고가 발생함", "온도가 과도하게 상승함", "열이 심하게 남"], "오작동": ["오작동이 반복됨", "작동이 불안정함", "간헐적으로 멈춤"]}
DNAMES = list(DEFECTS)
SEV = ["Minor", "Major", "Critical"]
SEV_CUE = {0: ["경미한 수준임", "운용에는 지장이 없음", "육안 점검상 사소한 이상임", "기능에는 영향이 없음"],
1: ["성능 저하가 있음", "조속한 정비가 필요함", "출력이 떨어지는 상태임", "기능이 부분적으로 제한됨"],
2: ["즉시 운용 중지함", "안전에 위험이 있어 사용 불가", "작동 정지로 임무 수행이 어려움", "인명 안전 위협이 우려됨"]}
CAUSES = ["제조 불량", "정비 품질", "운용 부주의", "설계 취약", "자연 노후"]
CAUSE_CUE = {
"제조 불량": ["신품 인수 직후 발생함", "출고 상태에서 이미 이상이 있었음", "인수 후 얼마 지나지 않아 발생함"],
"정비 품질": ["외주 정비 후 얼마 되지 않아 재발함", "정비 후 재조립 상태가 불량함", "정비 직후 같은 부위에서 다시 발생함"],
"운용 부주의": ["험지 기동 중 충격 이후 발생함", "과부하 운용 후 발생함", "점검 없이 연속 운용한 뒤 발생함"],
"설계 취약": ["동일 증상이 다른 차량에서도 반복됨", "구조적으로 취약한 부위로 보임", "여러 부대에서 같은 형태로 보고됨"],
"자연 노후": ["사용 연한이 도래함", "장기간 사용으로 노후됨", "수명 주기 말기에 발생함"]}
VENDORS = ["외주A", "외주B", "외주C", "외주D", "부대정비"]
ACTIONS = ["정비소 입고를 요청함", "현장 임시 조치 후 운용 중임", "부품 교환을 요청함", "재검사를 요청함", "원인 확인이 필요함"]
MAKER_OF = [p[7] for p in PARTS]
PRICE = np.array([p[6] for p in PARTS])
def usage_index(months, seed=0):
"""월별 훈련 강도(미리 알 수 있는 계획 값): 연 1회 정점을 갖는 계절 + 작은 잡음"""
r = np.random.default_rng(seed + 99)
t = np.arange(months)
return 1 + 0.25 * np.sin(2 * np.pi * (t - 3) / 12) + r.normal(0, 0.04, months)
def make_qdr(months=36, seed=1, scale=1.0, surge=True):
"""반환: QDR DataFrame(건당 1행), 월별 usage, 급증 이벤트 목록"""
rng = np.random.default_rng(seed)
use = usage_index(months, seed)
events = []
if surge:
for _ in range(max(2, months // 12 * 2)):
events.append((int(rng.integers(NP)), int(rng.integers(6, months))))
rows = []
wdef = [np.array([float(x.split(":")[1]) for x in p[8].split()]) for p in PARTS]
ddef = [[x.split(":")[0] for x in p[8].split()] for p in PARTS]
n = 0
for m in range(months):
for pi, p in enumerate(PARTS):
lam = p[2] * scale * (1 + 0.010 * m) * use[m] * (1 + p[3] * np.cos(2 * np.pi * (m % 12 - p[4]) / 12))
ev = (pi, m) in events
k = rng.poisson(lam * (2.6 if ev else 1.0))
for _ in range(k):
rows.append((n, m, pi, ev)); n += 1
N = len(rows)
A = np.array(rows)
month, part, ev = A[:, 1], A[:, 2], A[:, 3].astype(bool)
defect = np.array([rng.choice(ddef[i], p=wdef[i] / wdef[i].sum()) for i in part])
cr = np.array([PARTS[i][5][0] for i in part]); ma = np.array([PARTS[i][5][1] for i in part])
u = rng.random(N)
sev = np.where(u < cr, 2, np.where(u < cr + ma, 1, 0))
vendor = rng.choice(VENDORS, N, p=[0.25, 0.2, 0.2, 0.15, 0.2])
# 원인: 기본 비율에 부위(제조사)·업체·이벤트 효과를 곱한다
base = np.array([0.22, 0.20, 0.20, 0.16, 0.22])
P = np.tile(base, (N, 1))
P[vendor == "외주B", 1] *= 2.6
P[ev, 0] *= 5.0
design = np.isin(part, [2, 3, 7]); P[design, 3] *= 2.2
P[np.isin(part, [4, 5, 11]), 0] *= 2.0 # 제조사 MK3 납품 부위는 제조 불량 비중이 높다
P[defect == "마모", 4] *= 1.6; P[defect == "누유", 4] *= 1.3
P /= P.sum(axis=1, keepdims=True)
cause = np.array([CAUSES[rng.choice(5, p=P[i])] for i in range(N)])
since = np.where(cause == "정비 품질", rng.exponential(35, N), rng.exponential(160, N)).clip(1, 720).round().astype(int)
wait = (rng.lognormal(1.3, 0.5, N) * np.where(vendor == "외주B", 1.7, np.where(vendor == "부대정비", 0.6, 1.0)) * (0.7 + 0.5 * use[month])).round(1)
reins = rng.random(N) < np.where(cause == "정비 품질", 0.6, 0.08)
texts = []
for i in range(N):
pt = PARTS[part[i]]
cl = [f"{rng.choice(pt[1])}에서 {rng.choice(DEFECTS[defect[i]])}"]
if rng.random() < 0.7: cl.append(rng.choice(SEV_CUE[sev[i]]))
if rng.random() < 0.65: # 원인 단서: 8%는 실제 원인과 다른 단서가 적힌다(보고자의 추정)
cc = cause[i] if rng.random() > 0.08 else rng.choice(CAUSES)
cl.append(rng.choice(CAUSE_CUE[cc]))
cl.append(rng.choice(ACTIONS))
if rng.random() < 0.5: cl.insert(0, f"VU-{rng.integers(1, 31):02d} 소속")
texts.append(". ".join(cl) + ".")
df = pd.DataFrame({"qdr_id": [f"Q{seed}{i:06d}" for i in range(N)], "month": month, "unit": [f"VU-{x:02d}" for x in rng.integers(1, 31, N)],
"part": part, "defect": defect, "sev": sev, "cause": cause, "vendor": vendor, "maker": [MAKER_OF[i] for i in part],
"since_maint": since, "inspect_wait": wait, "reinspect": reins, "event": ev, "text": texts})
df["cost"] = PRICE[df["part"]] * np.array([1.0, 2.0, 4.0])[df["sev"]] # [교육용가상자료] 수리·조치 비용지수
return df, use, events
df, use, events = make_qdr(36, seed=1)
names = [p[0] for p in PARTS]
tr, va = df[df["month"] < 24], df[df["month"] >= 24]
print(f"[교육용가상자료] QDR {len(df)}건, 부위 {NP}종, 36개월 (학습 24개월 {len(tr)}건, 검증 12개월 {len(va)}건)")
# 1) Baseline: 학습 기간 건수 상위 5개 부위를 다발 결함 부위로 지정
cnt_tr = tr.groupby("part").size().sort_values(ascending=False)
top_base = list(cnt_tr.index[:5])
cnt_va = va.groupby("part").size().sort_values(ascending=False)
vuln = lambda d: d.groupby("part")["sev"].apply(lambda s: np.array([1, 3, 9])[s].sum()).sort_values(ascending=False)
v_va = vuln(va)
monthly_hit = np.mean([len(set(top_base) & set(g.groupby("part").size().sort_values(ascending=False).index[:5])) / 5 for _, g in va.groupby("month")])
print(f"Baseline 상위 5 부위: {', '.join(names[i] for i in top_base)}")
print(f" 검증 기간 건수 상위 5와 일치 {len(set(top_base) & set(cnt_va.index[:5]))}/5, 월별 평균 적중률 {monthly_hit:.0%}")
print(f" 치명도 가중 취약성 지수 상위 5와 일치 {len(set(top_base) & set(v_va.index[:5]))}/5 -> 건수만 보면 놓치는 부위: " + ", ".join(names[i] for i in set(v_va.index[:5]) - set(top_base)))
crit = va[va["sev"] == 2]
miss = (~crit["part"].isin(top_base)).mean()
print(f" Critical 결함 {len(crit)}건 중 Baseline 대상 밖 부위 비중 {miss:.0%}")
# 2) 파레토(ABC): 건수와 조치 비용
par = pd.DataFrame({"부위": [names[i] for i in cnt_tr.index], "건수": cnt_tr.values}).set_index("부위")
par["비용지수"] = [df.loc[df["part"] == i, "cost"].sum() for i in cnt_tr.index]
pc = par.sort_values("비용지수", ascending=False)
pc["누적비율"] = pc["비용지수"].cumsum() / pc["비용지수"].sum()
pc["등급"] = np.where(pc["누적비율"] <= 0.7, "A", np.where(pc["누적비율"] <= 0.9, "B", "C")); pc.iloc[0, 3] = "A"
print("\n[파레토] 조치 비용 기준(가상 단가지수)")
print(pc.assign(비용지수=lambda d: d["비용지수"].round(0), 누적비율=lambda d: d["누적비율"].map("{:.0%}".format)).head(6).to_string())
top20 = pc.head(int(np.ceil(NP * 0.2)))
print(f"상위 20% 부위({', '.join(top20.index)})가 전체 조치 비용의 {top20['비용지수'].sum() / pc['비용지수'].sum():.0%}, A등급 {int((pc['등급'] == 'A').sum())}종")
# 3) 상관분석: 월간 QDR 건수와 월별 피처
mon = df.groupby("month").agg(n=("qdr_id", "size"), wait=("inspect_wait", "mean"), re=("reinspect", "mean"), crit=("sev", lambda s: (s == 2).mean()),
ven_b=("vendor", lambda s: (s == "외주B").mean()), short=("since_maint", lambda s: (s < 60).mean()),
mfg=("cause", lambda s: (s == "제조 불량").mean()), cost=("cost", "mean"), units=("unit", "nunique"),
short_n=("since_maint", lambda s: (s < 60).sum()))
mon["usage"] = use; mon["trend"] = np.arange(36); mon["season"] = np.cos(2 * np.pi * (mon["trend"] % 12 - 7) / 12); mon["events"] = [sum(1 for e in events if e[1] == m) for m in range(36)]
rows = []
for c, lab in [("usage", "훈련 강도"), ("trend", "경과 월(추세)"), ("season", "계절 요인"), ("wait", "평균 검사 대기일"), ("re", "재검사율"), ("short", "정비 후 60일 이내 비중"),
("mfg", "제조 불량 비중"), ("cost", "건당 평균 조치 비용"), ("ven_b", "외주B 비중"), ("crit", "Critical 비중"), ("units", "보고 부대 수"), ("short_n", "정비 후 60일 이내 건수")]:
r, p = spearmanr(mon[c], mon["n"]); rows.append((lab, r, p))
cor = pd.DataFrame(rows, columns=["피처", "rho", "p"]); cor["유의"] = (cor["p"] < 0.05) & (cor["rho"].abs() >= 0.3)
print("\n[상관분석] 피처 | 스피어만 rho | p-value | 유의(p<0.05, |rho|>=0.3)")
for x in cor.sort_values("rho", key=abs, ascending=False).head(7).itertuples():
print(f" {x.피처:16s} {x.rho:+.2f} {x.p:.4f} {'유의' if x.유의 else '-'}")
nsig = int(cor["유의"].sum())
# 4) 외주정비 업체별 검사 병목과 원인
ven = df.groupby("vendor").agg(건수=("qdr_id", "size"), 대기일=("inspect_wait", "mean"), 재검사율=("reinspect", "mean"), 정비품질원인=("cause", lambda s: (s == "정비 품질").mean()))
print("\n[업체별 검사 실적] 직전 정비 수행처 기준")
print(ven.round(2).to_string())
b, o = df[df["vendor"] == "외주B"], df[~df["vendor"].isin(["외주B"])]
print(f"외주B: 대기일 {b['inspect_wait'].mean():.1f}일(타 수행처 {o['inspect_wait'].mean():.1f}일), '정비 품질' 원인 {np.mean(b['cause'] == '정비 품질'):.0%}(타 {np.mean(o['cause'] == '정비 품질'):.0%}), 정비 후 60일 이내 재발 {np.mean(b['since_maint'] < 60):.0%}")
# 5) EDA 차트 8종
fig, ax = plt.subplots(2, 4, figsize=(16, 7))
H = df.pivot_table(index="part", columns="month", values="qdr_id", aggfunc="size", fill_value=0)
ax[0, 0].imshow(H.values, aspect="auto", cmap="Reds"); ax[0, 0].set_title("1 QDR heatmap (part x month)")
ax[0, 1].plot(mon["n"].values, label="QDR"); ax[0, 1].plot(mon["usage"].values * mon["n"].mean(), label="usage x mean"); ax[0, 1].legend(fontsize=6); ax[0, 1].set_title("2 Monthly QDR vs usage")
ax[0, 2].bar(range(NP), pc["비용지수"].values); ax[0, 2].plot(range(NP), pc["누적비율"].values * pc["비용지수"].max(), "r-o"); ax[0, 2].set_title("3 Pareto of repair cost")
sv = pd.crosstab(df["part"], df["sev"], normalize="index"); ax[0, 3].bar(range(NP), sv[0], label="Minor"); ax[0, 3].bar(range(NP), sv[1], bottom=sv[0], label="Major"); ax[0, 3].bar(range(NP), sv[2], bottom=sv[0] + sv[1], label="Critical"); ax[0, 3].legend(fontsize=6); ax[0, 3].set_title("4 Severity share by part")
ax[1, 0].boxplot([df.loc[df["vendor"] == v, "inspect_wait"] for v in VENDORS], labels=[v[-1] for v in VENDORS]); ax[1, 0].set_title("5 Inspection wait by vendor")
cv = pd.crosstab(df["vendor"], df["cause"], normalize="index"); ax[1, 1].imshow(cv.values, cmap="Blues"); ax[1, 1].set_title("6 Cause share by vendor")
ax[1, 2].hist([df.loc[df["cause"] == c, "since_maint"].clip(0, 400) for c in CAUSES[:2]], bins=20, label=["mfg", "maint"]); ax[1, 2].legend(fontsize=6); ax[1, 2].set_title("7 Days since maintenance")
ax[1, 3].scatter(mon["wait"], mon["n"], s=12); ax[1, 3].set_title("8 Wait vs monthly QDR")
plt.tight_layout(); plt.show()
print(f"\nEDA 차트 8종 생성(목표 8종 이상: 달성), 유의한 피처 {nsig}개 (목표 5개 이상: {'달성' if nsig >= 5 else '미달'})")
print("핵심 발견: 1) 건수 상위만 보면 조향 장치처럼 치명도 높은 부위를 놓침 2) 외주B는 검사 대기가 길고 정비 후 조기 재발 비중이 높음 3) 월별 QDR는 훈련 강도와 계절에 연동")
In [ ]:
"""QDR 서술 텍스트를 TF-IDF로 벡터화하고 NMF 토픽 모델링으로 장비 구조적 품질 취약점을 자동 군집화해 부위·결함 라벨과의 일치도(NMI)와 취약 군집(치명도·설계 취약 비중)을 확인하고, 토픽 비중 등 파생 피처 14개로 부위별 월간 QDR 건수 기준 모델을 만들어 시계열 교차검증 MAPE를 평가하고 순열 중요도로 피처를 줄인다."""
import re
import unicodedata
import numpy as np
import pandas as pd
from sklearn.decomposition import NMF
from sklearn.ensemble import GradientBoostingRegressor
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.inspection import permutation_importance
from sklearn.metrics import normalized_mutual_info_score
# ---------------------------------------------------------------------------
# [교육용가상자료] 가상 야전 장비 품질결함보고(QDR) 생성기 (실제 부대·장비·업체·정비 실적 없음)
# 가상 장비 부위 12종, 결함 유형 8종, 치명도(Critical/Major/Minor), 원인 5종, 가상 외주정비 업체 4곳 + 부대정비.
# QDR 1건마다 한국어 서술 텍스트를 만들고, 월별 건수는 계절·추세·훈련 강도(usage)·일부 제조 불량 이벤트를 따른다.
# ---------------------------------------------------------------------------
PARTS = [ # (이름, 별칭, 월 기준 건수, 계절 진폭, 정점 월, (Critical, Major 비율), 가상 단가지수, 제조사, 주요 결함 가중치)
("엔진 연료계통", ["연료계통", "연료 라인", "연료 펌프"], 26, 0.15, 7, (0.10, 0.35), 6.0, "MK1", "누유:4 마모:2 체결불량:2 오작동:2"),
("변속기", ["변속기", "T/M", "미션"], 22, 0.10, 8, (0.12, 0.38), 9.0, "MK1", "마모:4 누유:2 과열:3 오작동:1"),
("제동 장치", ["제동 장치", "브레이크", "제동 라인"], 20, 0.20, 12, (0.26, 0.40), 5.0, "MK2", "마모:4 균열:2 누유:2 체결불량:2"),
("조향 장치", ["조향 장치", "핸들 연동부", "조향 로드"], 14, 0.10, 6, (0.30, 0.40), 5.0, "MK2", "체결불량:4 균열:2 마모:3 오작동:1"),
("냉각 계통", ["냉각 계통", "라디에이터", "냉각수 라인"], 24, 0.30, 7, (0.05, 0.30), 3.0, "MK3", "누유:4 과열:4 부식:2"),
("전기·배터리", ["배터리", "전기 계통", "발전기"], 28, 0.35, 1, (0.04, 0.28), 2.5, "MK3", "접촉불량:4 부식:2 과열:2 오작동:2"),
("통신 모듈", ["통신 모듈", "무전기 연동부", "안테나 접속부"], 16, 0.05, 4, (0.03, 0.25), 4.0, "MK4", "접촉불량:5 오작동:4 부식:1"),
("유압 계통", ["유압 계통", "유압 호스", "유압 실린더"], 18, 0.10, 9, (0.10, 0.35), 4.5, "MK2", "누유:5 균열:2 마모:2 체결불량:1"),
("현가 장치", ["현가 장치", "서스펜션", "완충기"], 15, 0.08, 5, (0.06, 0.30), 3.5, "MK4", "마모:4 균열:3 체결불량:2 부식:1"),
("차체 용접부", ["차체 용접부", "프레임 용접부", "차체 이음부"], 10, 0.05, 10, (0.28, 0.40), 7.0, "MK1", "균열:6 부식:3 체결불량:1"),
("광학·조준 장비", ["광학 장비", "조준경", "관측 장비"], 8, 0.05, 3, (0.03, 0.22), 8.0, "MK4", "오작동:4 접촉불량:3 부식:2 체결불량:1"),
("공기 정화 필터", ["공기 필터", "흡기 필터", "정화 장치"], 12, 0.25, 4, (0.01, 0.12), 1.0, "MK3", "마모:5 부식:2 체결불량:3")]
NP = len(PARTS)
DEFECTS = { # 결함 유형: 증상 표현(동의어)
"균열": ["균열이 발견됨", "크랙이 확인됨", "갈라짐이 있음"], "누유": ["누유가 발생함", "오일이 새어 나옴", "누설 흔적이 있음"],
"마모": ["마모가 심함", "과도한 마모가 확인됨", "닳은 흔적이 있음"], "부식": ["부식이 진행됨", "녹이 발생함", "산화 흔적이 있음"],
"접촉불량": ["접촉 불량이 발생함", "커넥터 단선이 의심됨", "신호가 끊김"], "체결불량": ["볼트 풀림이 확인됨", "체결 상태가 불량함", "고정부가 헐거움"],
"과열": ["과열 경고가 발생함", "온도가 과도하게 상승함", "열이 심하게 남"], "오작동": ["오작동이 반복됨", "작동이 불안정함", "간헐적으로 멈춤"]}
DNAMES = list(DEFECTS)
SEV = ["Minor", "Major", "Critical"]
SEV_CUE = {0: ["경미한 수준임", "운용에는 지장이 없음", "육안 점검상 사소한 이상임", "기능에는 영향이 없음"],
1: ["성능 저하가 있음", "조속한 정비가 필요함", "출력이 떨어지는 상태임", "기능이 부분적으로 제한됨"],
2: ["즉시 운용 중지함", "안전에 위험이 있어 사용 불가", "작동 정지로 임무 수행이 어려움", "인명 안전 위협이 우려됨"]}
CAUSES = ["제조 불량", "정비 품질", "운용 부주의", "설계 취약", "자연 노후"]
CAUSE_CUE = {
"제조 불량": ["신품 인수 직후 발생함", "출고 상태에서 이미 이상이 있었음", "인수 후 얼마 지나지 않아 발생함"],
"정비 품질": ["외주 정비 후 얼마 되지 않아 재발함", "정비 후 재조립 상태가 불량함", "정비 직후 같은 부위에서 다시 발생함"],
"운용 부주의": ["험지 기동 중 충격 이후 발생함", "과부하 운용 후 발생함", "점검 없이 연속 운용한 뒤 발생함"],
"설계 취약": ["동일 증상이 다른 차량에서도 반복됨", "구조적으로 취약한 부위로 보임", "여러 부대에서 같은 형태로 보고됨"],
"자연 노후": ["사용 연한이 도래함", "장기간 사용으로 노후됨", "수명 주기 말기에 발생함"]}
VENDORS = ["외주A", "외주B", "외주C", "외주D", "부대정비"]
ACTIONS = ["정비소 입고를 요청함", "현장 임시 조치 후 운용 중임", "부품 교환을 요청함", "재검사를 요청함", "원인 확인이 필요함"]
MAKER_OF = [p[7] for p in PARTS]
PRICE = np.array([p[6] for p in PARTS])
def usage_index(months, seed=0):
"""월별 훈련 강도(미리 알 수 있는 계획 값): 연 1회 정점을 갖는 계절 + 작은 잡음"""
r = np.random.default_rng(seed + 99)
t = np.arange(months)
return 1 + 0.25 * np.sin(2 * np.pi * (t - 3) / 12) + r.normal(0, 0.04, months)
def make_qdr(months=36, seed=1, scale=1.0, surge=True):
"""반환: QDR DataFrame(건당 1행), 월별 usage, 급증 이벤트 목록"""
rng = np.random.default_rng(seed)
use = usage_index(months, seed)
events = []
if surge:
for _ in range(max(2, months // 12 * 2)):
events.append((int(rng.integers(NP)), int(rng.integers(6, months))))
rows = []
wdef = [np.array([float(x.split(":")[1]) for x in p[8].split()]) for p in PARTS]
ddef = [[x.split(":")[0] for x in p[8].split()] for p in PARTS]
n = 0
for m in range(months):
for pi, p in enumerate(PARTS):
lam = p[2] * scale * (1 + 0.010 * m) * use[m] * (1 + p[3] * np.cos(2 * np.pi * (m % 12 - p[4]) / 12))
ev = (pi, m) in events
k = rng.poisson(lam * (2.6 if ev else 1.0))
for _ in range(k):
rows.append((n, m, pi, ev)); n += 1
N = len(rows)
A = np.array(rows)
month, part, ev = A[:, 1], A[:, 2], A[:, 3].astype(bool)
defect = np.array([rng.choice(ddef[i], p=wdef[i] / wdef[i].sum()) for i in part])
cr = np.array([PARTS[i][5][0] for i in part]); ma = np.array([PARTS[i][5][1] for i in part])
u = rng.random(N)
sev = np.where(u < cr, 2, np.where(u < cr + ma, 1, 0))
vendor = rng.choice(VENDORS, N, p=[0.25, 0.2, 0.2, 0.15, 0.2])
# 원인: 기본 비율에 부위(제조사)·업체·이벤트 효과를 곱한다
base = np.array([0.22, 0.20, 0.20, 0.16, 0.22])
P = np.tile(base, (N, 1))
P[vendor == "외주B", 1] *= 2.6
P[ev, 0] *= 5.0
design = np.isin(part, [2, 3, 7]); P[design, 3] *= 2.2
P[np.isin(part, [4, 5, 11]), 0] *= 2.0 # 제조사 MK3 납품 부위는 제조 불량 비중이 높다
P[defect == "마모", 4] *= 1.6; P[defect == "누유", 4] *= 1.3
P /= P.sum(axis=1, keepdims=True)
cause = np.array([CAUSES[rng.choice(5, p=P[i])] for i in range(N)])
since = np.where(cause == "정비 품질", rng.exponential(35, N), rng.exponential(160, N)).clip(1, 720).round().astype(int)
wait = (rng.lognormal(1.3, 0.5, N) * np.where(vendor == "외주B", 1.7, np.where(vendor == "부대정비", 0.6, 1.0)) * (0.7 + 0.5 * use[month])).round(1)
reins = rng.random(N) < np.where(cause == "정비 품질", 0.6, 0.08)
texts = []
for i in range(N):
pt = PARTS[part[i]]
cl = [f"{rng.choice(pt[1])}에서 {rng.choice(DEFECTS[defect[i]])}"]
if rng.random() < 0.7: cl.append(rng.choice(SEV_CUE[sev[i]]))
if rng.random() < 0.65: # 원인 단서: 8%는 실제 원인과 다른 단서가 적힌다(보고자의 추정)
cc = cause[i] if rng.random() > 0.08 else rng.choice(CAUSES)
cl.append(rng.choice(CAUSE_CUE[cc]))
cl.append(rng.choice(ACTIONS))
if rng.random() < 0.5: cl.insert(0, f"VU-{rng.integers(1, 31):02d} 소속")
texts.append(". ".join(cl) + ".")
df = pd.DataFrame({"qdr_id": [f"Q{seed}{i:06d}" for i in range(N)], "month": month, "unit": [f"VU-{x:02d}" for x in rng.integers(1, 31, N)],
"part": part, "defect": defect, "sev": sev, "cause": cause, "vendor": vendor, "maker": [MAKER_OF[i] for i in part],
"since_maint": since, "inspect_wait": wait, "reinspect": reins, "event": ev, "text": texts})
df["cost"] = PRICE[df["part"]] * np.array([1.0, 2.0, 4.0])[df["sev"]] # [교육용가상자료] 수리·조치 비용지수
return df, use, events
# ---------------------------------------------------------------------------
# 공통 텍스트 조각: 정규화, 간이 토큰화(조사 제거), 부위·결함·치명도·원인 키워드 라벨링
# ---------------------------------------------------------------------------
TYPO = {"누윳": "누유", "균렬": "균열", "접축 불량": "접촉 불량", "부씩": "부식", "마묘": "마모", "과렬": "과열", "오작똥": "오작동", "재동": "제동"}
ABBR = {"T/M": "변속기", "BAT": "배터리"}
PART_KW = sorted([(a, i) for i, p in enumerate(PARTS) for a in p[1]], key=lambda x: -len(x[0]))
DEF_KW = {"균열": ["균열", "크랙", "갈라"], "누유": ["누유", "새어", "누설"], "마모": ["마모", "닳은"], "부식": ["부식", "녹이", "산화"],
"접촉불량": ["접촉 불량", "단선", "끊김"], "체결불량": ["볼트", "체결", "헐거"], "과열": ["과열", "온도", "열이"], "오작동": ["오작동", "불안정", "멈춤"]}
SEV_KW = [(2, ["즉시 운용 중지", "사용 불가", "위험"]), (1, ["성능 저하", "조속한"]), (0, ["경미", "지장이 없"])]
CAUSE_KW = {"제조 불량": ["신품", "출고", "인수"], "정비 품질": ["정비 후", "재조립", "정비 직후"], "운용 부주의": ["충격", "과부하", "점검 없이"],
"설계 취약": ["동일 증상", "구조적", "여러 부대"], "자연 노후": ["사용 연한", "노후", "수명"]}
PARTICLES = ("에서", "으로", "이", "가", "은", "는", "을", "를", "에", "의", "로", "와", "과", "도", "함", "음", "됨")
def norm(t):
"""정규화: 전각->반각, 특수문자·중복 공백 제거, 약어 확장, 오타 교정"""
t = unicodedata.normalize("NFKC", str(t))
t = re.sub(r"[!#~*@^]+", " ", t)
for a, b in ABBR.items():
t = t.replace(a, b)
for a, b in TYPO.items():
t = t.replace(a, b)
return re.sub(r"\s+", " ", t).strip()
def tok(t):
out = []
for w in re.split(r"[\s.,]+", norm(t)):
if not w or re.fullmatch(r"[\d\-A-Za-z]+", w):
continue
for p in PARTICLES:
if len(w) > len(p) + 1 and w.endswith(p):
w = w[:-len(p)]
break
out.append(w)
return out
def label_part(t):
for a, i in PART_KW:
if a in t:
return i
return -1
def label_defect(t):
for d, ks in DEF_KW.items():
if any(k in t for k in ks):
return d
return "미분류"
def label_sev(t):
for s, ks in SEV_KW:
if any(k in t for k in ks):
return s
return -1
def label_cause(t):
for c, ks in CAUSE_KW.items():
if any(k in t for k in ks):
return c
return "미상"
df, use, events = make_qdr(36, seed=1, scale=3.0)
names = [p[0] for p in PARTS]
print(f"[교육용가상자료] QDR {len(df)}건, 36개월")
print("(토픽 모델은 결함 서술 핵심 문장 '부위에서 증상'에 적용하고, 치명도·원인은 군집 해석에 사용)")
# 1) 텍스트 임베딩(TF-IDF) + NMF 토픽 모델링
STOP = set(w for a in ACTIONS for w in tok(a)) | {"소속"}
vec = TfidfVectorizer(tokenizer=lambda s: [w for w in tok(s) if w not in STOP], lowercase=False, token_pattern=None, min_df=5)
df["core"] = df["text"].map(lambda s: [x for x in s.split(". ") if "에서" in x][0]) # 결함 서술 핵심 문장(부위+증상)
X = vec.fit_transform(df["core"])
K = 36
nmf = NMF(n_components=K, init="nndsvd", random_state=0, max_iter=400).fit(X)
Wd = nmf.transform(X)
df["topic"] = Wd.argmax(axis=1)
vocab = np.array(vec.get_feature_names_out())
nmi_p = normalized_mutual_info_score(df["part"], df["topic"]); nmi_d = normalized_mutual_info_score(df["defect"], df["topic"]); nmi_pd = normalized_mutual_info_score(df["part"].astype(str) + df["defect"], df["topic"])
print(f"TF-IDF 어휘 {X.shape[1]}개, 토픽 {K}개: 군집-라벨 일치도(NMI) 부위 {nmi_p:.2f}, 결함 유형 {nmi_d:.2f}, 부위+결함 {nmi_pd:.2f}")
base_acc = np.mean([label_part(t) == p for t, p in zip(df["text"], df["part"])])
print(f" 참고: 키워드 규칙 부위 라벨 정확도 {base_acc:.1%}")
g = df.groupby("topic").agg(건수=("qdr_id", "size"), critical=("sev", lambda s: (s == 2).mean()), 설계취약=("cause", lambda s: (s == "설계 취약").mean()),
주부위=("part", lambda s: names[s.mode()[0]]), 주결함=("defect", lambda s: s.mode()[0]))
g["핵심어"] = [", ".join(vocab[np.argsort(-nmf.components_[k])[:4]]) for k in g.index]
g["취약지수"] = g["건수"] * (g["critical"] * 9 + (1 - g["critical"]) * 1.5)
print("\n[취약 군집 상위 5] 토픽 | 건수 | Critical 비중 | 주 부위 | 주 결함 | 핵심어")
for k, r in g.sort_values("취약지수", ascending=False).head(5).iterrows():
print(f" T{k:02d} {r['건수']:4d} {r['critical']:.0%} {r['주부위']} / {r['주결함']} [{r['핵심어']}]")
struct = list(g["설계취약"].sort_values(ascending=False).index[:8])
print(f"설계 취약 비중 상위 토픽 {len(struct)}개(구조적 취약 후보): " + ", ".join(f"T{k:02d}" for k in struct) + f" -> 해당 토픽 QDR의 설계 취약 비중 {df.loc[df['topic'].isin(struct), 'cause'].eq('설계 취약').mean():.0%} (전체 {df['cause'].eq('설계 취약').mean():.0%})")
# 2) 부위별 월간 QDR 건수 기준 모델: 파생 피처 13개
top8 = list(df.groupby("part").size().sort_values(ascending=False).index[:8])
mon = df.groupby(["part", "month"]).agg(n=("qdr_id", "size"), crit=("sev", lambda s: (s == 2).mean()), wait=("inspect_wait", "mean"), vb=("vendor", lambda s: (s == "외주B").mean()),
re=("reinspect", "mean"), st=("topic", lambda s: s.isin(struct).mean())).reset_index()
rows = []
for p in top8:
d = mon[mon["part"] == p].set_index("month").reindex(range(36)).fillna(0)
f = pd.DataFrame({"part": p, "month": range(36), "n": d["n"].values})
f["lag1"], f["lag2"] = f["n"].shift(1), f["n"].shift(2)
f["roll3"] = f["n"].shift(1).rolling(3).mean()
f["exp_count"] = (f["n"] / use).shift(1).rolling(6, min_periods=3).mean() * use # 최근 6개월 평균 발생률 x 이번 달 훈련 강도
f["usage"], f["usage_lag1"] = use, np.r_[use[0], use[:-1]]
f["season_c"], f["season_s"] = np.cos(2 * np.pi * f["month"] / 12), np.sin(2 * np.pi * f["month"] / 12)
f["trend"] = f["month"]
for c in ("crit", "wait", "vb", "re", "st"):
f[c + "_lag1"] = d[c].shift(1).values
rows.append(f)
F = pd.concat(rows, ignore_index=True).dropna()
DER = ["lag1", "lag2", "roll3", "exp_count", "usage", "usage_lag1", "season_c", "season_s", "trend", "crit_lag1", "wait_lag1", "vb_lag1", "re_lag1", "st_lag1"]
print(f"\n월간 건수 예측: 파생 피처 {len(DER)}개 (목표 10개 이상: {'달성' if len(DER) >= 10 else '미달'}), 대상 {len(top8)}개 부위 x {F['month'].nunique()}개월")
def fit(d, cols):
return GradientBoostingRegressor(n_estimators=120, max_depth=3, learning_rate=0.08, subsample=0.8, random_state=0).fit(d[cols], d["n"])
mape = lambda y, p: float(np.mean(np.abs(y - p) / y))
folds, naive = [], []
for c in (17, 23, 29): # 확장 윈도: 과거로만 학습, 뒤 6개월 검증
trn, val = F[F["month"] <= c], F[(F["month"] > c) & (F["month"] <= c + 6)]
folds.append(mape(val["n"], fit(trn, DER).predict(val[DER]))); naive.append(mape(val["n"], val["lag1"]))
print(f"시계열 교차검증 폴드별 MAPE: {', '.join(f'{x:.1%}' for x in folds)} -> 평균 {np.mean(folds):.1%} (목표 25% 이하: {'달성' if np.mean(folds) <= 0.25 else '미달'}), 전월값 기준 평균 {np.mean(naive):.1%}")
# 3) 피처 중요도로 차원 축소
trn, val = F[F["month"] <= 29], F[F["month"] > 29]
full = fit(trn, DER)
pi = permutation_importance(full, val[DER], val["n"], n_repeats=5, random_state=0, scoring="neg_mean_absolute_error")
imp = pd.Series(pi.importances_mean, DER).sort_values(ascending=False)
print("피처 중요도 상위 5: " + ", ".join(f"{k}({v:.2f})" for k, v in imp.head(5).items()))
top = list(imp.index[:7])
print(f"차원 축소 {len(DER)}개 -> {len(top)}개: 검증 MAPE {mape(val['n'], full.predict(val[DER])):.1%} -> {mape(val['n'], fit(trn, top).predict(val[top])):.1%}")
In [ ]:
"""QDR 서술·부위·결함 정보로 개별 결함의 치명도(Critical/Major/Minor)를 분류하는 모델(키워드 규칙 기준 대비)과 신뢰도 보정을 확인하고, 치명도 가중 부위별 월간 종합 품질 취약성 지수를 앙상블(릿지에 그래디언트 부스팅·랜덤 포레스트 잔차 보정을 더한 모델)로 예측해 학습 기간 평균 기준 모델과 MAE·RMSE·R2를 비교하는 취약성 지수 산출 엔진을 구현한다."""
import re
import unicodedata
import numpy as np
import pandas as pd
import scipy.sparse as sp
from sklearn.ensemble import GradientBoostingRegressor, RandomForestRegressor
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.linear_model import LogisticRegression, Ridge
from sklearn.metrics import mean_absolute_error, mean_squared_error, r2_score
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
# ---------------------------------------------------------------------------
# [교육용가상자료] 가상 야전 장비 품질결함보고(QDR) 생성기 (실제 부대·장비·업체·정비 실적 없음)
# 가상 장비 부위 12종, 결함 유형 8종, 치명도(Critical/Major/Minor), 원인 5종, 가상 외주정비 업체 4곳 + 부대정비.
# QDR 1건마다 한국어 서술 텍스트를 만들고, 월별 건수는 계절·추세·훈련 강도(usage)·일부 제조 불량 이벤트를 따른다.
# ---------------------------------------------------------------------------
PARTS = [ # (이름, 별칭, 월 기준 건수, 계절 진폭, 정점 월, (Critical, Major 비율), 가상 단가지수, 제조사, 주요 결함 가중치)
("엔진 연료계통", ["연료계통", "연료 라인", "연료 펌프"], 26, 0.15, 7, (0.10, 0.35), 6.0, "MK1", "누유:4 마모:2 체결불량:2 오작동:2"),
("변속기", ["변속기", "T/M", "미션"], 22, 0.10, 8, (0.12, 0.38), 9.0, "MK1", "마모:4 누유:2 과열:3 오작동:1"),
("제동 장치", ["제동 장치", "브레이크", "제동 라인"], 20, 0.20, 12, (0.26, 0.40), 5.0, "MK2", "마모:4 균열:2 누유:2 체결불량:2"),
("조향 장치", ["조향 장치", "핸들 연동부", "조향 로드"], 14, 0.10, 6, (0.30, 0.40), 5.0, "MK2", "체결불량:4 균열:2 마모:3 오작동:1"),
("냉각 계통", ["냉각 계통", "라디에이터", "냉각수 라인"], 24, 0.30, 7, (0.05, 0.30), 3.0, "MK3", "누유:4 과열:4 부식:2"),
("전기·배터리", ["배터리", "전기 계통", "발전기"], 28, 0.35, 1, (0.04, 0.28), 2.5, "MK3", "접촉불량:4 부식:2 과열:2 오작동:2"),
("통신 모듈", ["통신 모듈", "무전기 연동부", "안테나 접속부"], 16, 0.05, 4, (0.03, 0.25), 4.0, "MK4", "접촉불량:5 오작동:4 부식:1"),
("유압 계통", ["유압 계통", "유압 호스", "유압 실린더"], 18, 0.10, 9, (0.10, 0.35), 4.5, "MK2", "누유:5 균열:2 마모:2 체결불량:1"),
("현가 장치", ["현가 장치", "서스펜션", "완충기"], 15, 0.08, 5, (0.06, 0.30), 3.5, "MK4", "마모:4 균열:3 체결불량:2 부식:1"),
("차체 용접부", ["차체 용접부", "프레임 용접부", "차체 이음부"], 10, 0.05, 10, (0.28, 0.40), 7.0, "MK1", "균열:6 부식:3 체결불량:1"),
("광학·조준 장비", ["광학 장비", "조준경", "관측 장비"], 8, 0.05, 3, (0.03, 0.22), 8.0, "MK4", "오작동:4 접촉불량:3 부식:2 체결불량:1"),
("공기 정화 필터", ["공기 필터", "흡기 필터", "정화 장치"], 12, 0.25, 4, (0.01, 0.12), 1.0, "MK3", "마모:5 부식:2 체결불량:3")]
NP = len(PARTS)
DEFECTS = { # 결함 유형: 증상 표현(동의어)
"균열": ["균열이 발견됨", "크랙이 확인됨", "갈라짐이 있음"], "누유": ["누유가 발생함", "오일이 새어 나옴", "누설 흔적이 있음"],
"마모": ["마모가 심함", "과도한 마모가 확인됨", "닳은 흔적이 있음"], "부식": ["부식이 진행됨", "녹이 발생함", "산화 흔적이 있음"],
"접촉불량": ["접촉 불량이 발생함", "커넥터 단선이 의심됨", "신호가 끊김"], "체결불량": ["볼트 풀림이 확인됨", "체결 상태가 불량함", "고정부가 헐거움"],
"과열": ["과열 경고가 발생함", "온도가 과도하게 상승함", "열이 심하게 남"], "오작동": ["오작동이 반복됨", "작동이 불안정함", "간헐적으로 멈춤"]}
DNAMES = list(DEFECTS)
SEV = ["Minor", "Major", "Critical"]
SEV_CUE = {0: ["경미한 수준임", "운용에는 지장이 없음", "육안 점검상 사소한 이상임", "기능에는 영향이 없음"],
1: ["성능 저하가 있음", "조속한 정비가 필요함", "출력이 떨어지는 상태임", "기능이 부분적으로 제한됨"],
2: ["즉시 운용 중지함", "안전에 위험이 있어 사용 불가", "작동 정지로 임무 수행이 어려움", "인명 안전 위협이 우려됨"]}
CAUSES = ["제조 불량", "정비 품질", "운용 부주의", "설계 취약", "자연 노후"]
CAUSE_CUE = {
"제조 불량": ["신품 인수 직후 발생함", "출고 상태에서 이미 이상이 있었음", "인수 후 얼마 지나지 않아 발생함"],
"정비 품질": ["외주 정비 후 얼마 되지 않아 재발함", "정비 후 재조립 상태가 불량함", "정비 직후 같은 부위에서 다시 발생함"],
"운용 부주의": ["험지 기동 중 충격 이후 발생함", "과부하 운용 후 발생함", "점검 없이 연속 운용한 뒤 발생함"],
"설계 취약": ["동일 증상이 다른 차량에서도 반복됨", "구조적으로 취약한 부위로 보임", "여러 부대에서 같은 형태로 보고됨"],
"자연 노후": ["사용 연한이 도래함", "장기간 사용으로 노후됨", "수명 주기 말기에 발생함"]}
VENDORS = ["외주A", "외주B", "외주C", "외주D", "부대정비"]
ACTIONS = ["정비소 입고를 요청함", "현장 임시 조치 후 운용 중임", "부품 교환을 요청함", "재검사를 요청함", "원인 확인이 필요함"]
MAKER_OF = [p[7] for p in PARTS]
PRICE = np.array([p[6] for p in PARTS])
def usage_index(months, seed=0):
"""월별 훈련 강도(미리 알 수 있는 계획 값): 연 1회 정점을 갖는 계절 + 작은 잡음"""
r = np.random.default_rng(seed + 99)
t = np.arange(months)
return 1 + 0.25 * np.sin(2 * np.pi * (t - 3) / 12) + r.normal(0, 0.04, months)
def make_qdr(months=36, seed=1, scale=1.0, surge=True):
"""반환: QDR DataFrame(건당 1행), 월별 usage, 급증 이벤트 목록"""
rng = np.random.default_rng(seed)
use = usage_index(months, seed)
events = []
if surge:
for _ in range(max(2, months // 12 * 2)):
events.append((int(rng.integers(NP)), int(rng.integers(6, months))))
rows = []
wdef = [np.array([float(x.split(":")[1]) for x in p[8].split()]) for p in PARTS]
ddef = [[x.split(":")[0] for x in p[8].split()] for p in PARTS]
n = 0
for m in range(months):
for pi, p in enumerate(PARTS):
lam = p[2] * scale * (1 + 0.010 * m) * use[m] * (1 + p[3] * np.cos(2 * np.pi * (m % 12 - p[4]) / 12))
ev = (pi, m) in events
k = rng.poisson(lam * (2.6 if ev else 1.0))
for _ in range(k):
rows.append((n, m, pi, ev)); n += 1
N = len(rows)
A = np.array(rows)
month, part, ev = A[:, 1], A[:, 2], A[:, 3].astype(bool)
defect = np.array([rng.choice(ddef[i], p=wdef[i] / wdef[i].sum()) for i in part])
cr = np.array([PARTS[i][5][0] for i in part]); ma = np.array([PARTS[i][5][1] for i in part])
u = rng.random(N)
sev = np.where(u < cr, 2, np.where(u < cr + ma, 1, 0))
vendor = rng.choice(VENDORS, N, p=[0.25, 0.2, 0.2, 0.15, 0.2])
# 원인: 기본 비율에 부위(제조사)·업체·이벤트 효과를 곱한다
base = np.array([0.22, 0.20, 0.20, 0.16, 0.22])
P = np.tile(base, (N, 1))
P[vendor == "외주B", 1] *= 2.6
P[ev, 0] *= 5.0
design = np.isin(part, [2, 3, 7]); P[design, 3] *= 2.2
P[np.isin(part, [4, 5, 11]), 0] *= 2.0 # 제조사 MK3 납품 부위는 제조 불량 비중이 높다
P[defect == "마모", 4] *= 1.6; P[defect == "누유", 4] *= 1.3
P /= P.sum(axis=1, keepdims=True)
cause = np.array([CAUSES[rng.choice(5, p=P[i])] for i in range(N)])
since = np.where(cause == "정비 품질", rng.exponential(35, N), rng.exponential(160, N)).clip(1, 720).round().astype(int)
wait = (rng.lognormal(1.3, 0.5, N) * np.where(vendor == "외주B", 1.7, np.where(vendor == "부대정비", 0.6, 1.0)) * (0.7 + 0.5 * use[month])).round(1)
reins = rng.random(N) < np.where(cause == "정비 품질", 0.6, 0.08)
texts = []
for i in range(N):
pt = PARTS[part[i]]
cl = [f"{rng.choice(pt[1])}에서 {rng.choice(DEFECTS[defect[i]])}"]
if rng.random() < 0.7: cl.append(rng.choice(SEV_CUE[sev[i]]))
if rng.random() < 0.65: # 원인 단서: 8%는 실제 원인과 다른 단서가 적힌다(보고자의 추정)
cc = cause[i] if rng.random() > 0.08 else rng.choice(CAUSES)
cl.append(rng.choice(CAUSE_CUE[cc]))
cl.append(rng.choice(ACTIONS))
if rng.random() < 0.5: cl.insert(0, f"VU-{rng.integers(1, 31):02d} 소속")
texts.append(". ".join(cl) + ".")
df = pd.DataFrame({"qdr_id": [f"Q{seed}{i:06d}" for i in range(N)], "month": month, "unit": [f"VU-{x:02d}" for x in rng.integers(1, 31, N)],
"part": part, "defect": defect, "sev": sev, "cause": cause, "vendor": vendor, "maker": [MAKER_OF[i] for i in part],
"since_maint": since, "inspect_wait": wait, "reinspect": reins, "event": ev, "text": texts})
df["cost"] = PRICE[df["part"]] * np.array([1.0, 2.0, 4.0])[df["sev"]] # [교육용가상자료] 수리·조치 비용지수
return df, use, events
# ---------------------------------------------------------------------------
# 공통 텍스트 조각: 정규화, 간이 토큰화(조사 제거), 부위·결함·치명도·원인 키워드 라벨링
# ---------------------------------------------------------------------------
TYPO = {"누윳": "누유", "균렬": "균열", "접축 불량": "접촉 불량", "부씩": "부식", "마묘": "마모", "과렬": "과열", "오작똥": "오작동", "재동": "제동"}
ABBR = {"T/M": "변속기", "BAT": "배터리"}
PART_KW = sorted([(a, i) for i, p in enumerate(PARTS) for a in p[1]], key=lambda x: -len(x[0]))
DEF_KW = {"균열": ["균열", "크랙", "갈라"], "누유": ["누유", "새어", "누설"], "마모": ["마모", "닳은"], "부식": ["부식", "녹이", "산화"],
"접촉불량": ["접촉 불량", "단선", "끊김"], "체결불량": ["볼트", "체결", "헐거"], "과열": ["과열", "온도", "열이"], "오작동": ["오작동", "불안정", "멈춤"]}
SEV_KW = [(2, ["즉시 운용 중지", "사용 불가", "위험"]), (1, ["성능 저하", "조속한"]), (0, ["경미", "지장이 없"])]
CAUSE_KW = {"제조 불량": ["신품", "출고", "인수"], "정비 품질": ["정비 후", "재조립", "정비 직후"], "운용 부주의": ["충격", "과부하", "점검 없이"],
"설계 취약": ["동일 증상", "구조적", "여러 부대"], "자연 노후": ["사용 연한", "노후", "수명"]}
PARTICLES = ("에서", "으로", "이", "가", "은", "는", "을", "를", "에", "의", "로", "와", "과", "도", "함", "음", "됨")
def norm(t):
"""정규화: 전각->반각, 특수문자·중복 공백 제거, 약어 확장, 오타 교정"""
t = unicodedata.normalize("NFKC", str(t))
t = re.sub(r"[!#~*@^]+", " ", t)
for a, b in ABBR.items():
t = t.replace(a, b)
for a, b in TYPO.items():
t = t.replace(a, b)
return re.sub(r"\s+", " ", t).strip()
def tok(t):
out = []
for w in re.split(r"[\s.,]+", norm(t)):
if not w or re.fullmatch(r"[\d\-A-Za-z]+", w):
continue
for p in PARTICLES:
if len(w) > len(p) + 1 and w.endswith(p):
w = w[:-len(p)]
break
out.append(w)
return out
def label_part(t):
for a, i in PART_KW:
if a in t:
return i
return -1
def label_defect(t):
for d, ks in DEF_KW.items():
if any(k in t for k in ks):
return d
return "미분류"
def label_sev(t):
for s, ks in SEV_KW:
if any(k in t for k in ks):
return s
return -1
def label_cause(t):
for c, ks in CAUSE_KW.items():
if any(k in t for k in ks):
return c
return "미상"
df, use, events = make_qdr(48, seed=1, scale=4.0)
names = [p[0] for p in PARTS]
W_SEV = np.array([1, 3, 9]) # 취약성 지수 가중치(Minor 1, Major 3, Critical 9)
tr, va = df[df["month"] < 36], df[df["month"] >= 36]
print(f"[교육용가상자료] QDR {len(df)}건 (학습 36개월 {len(tr)}건, 검증 12개월 {len(va)}건)")
# 1) 개별 QDR 치명도 분류: 키워드 규칙 vs 텍스트+부위·결함 로지스틱 회귀
rule = va["text"].map(label_sev).replace(-1, 0).values # 치명도 단서가 없으면 Minor로 간주
vec = TfidfVectorizer(tokenizer=tok, lowercase=False, token_pattern=None, min_df=5)
Xtr, Xva = vec.fit_transform(tr["text"]), vec.transform(va["text"])
oh = lambda d: sp.csr_matrix(np.hstack([np.eye(NP)[d["part"]], pd.get_dummies(d["defect"]).reindex(columns=DNAMES, fill_value=0).values.astype(float)]))
clf = LogisticRegression(C=5, max_iter=500).fit(sp.hstack([Xtr, oh(tr)]), tr["sev"])
P = clf.predict_proba(sp.hstack([Xva, oh(va)]))
pred = P.argmax(axis=1)
acc_r, acc_m = (rule == va["sev"].values).mean(), (pred == va["sev"].values).mean()
rec = lambda p: ((p == 2) & (va["sev"].values == 2)).sum() / (va["sev"].values == 2).sum()
print(f"치명도 분류 정확도: 키워드 규칙 {acc_r:.1%} -> 텍스트 모델 {acc_m:.1%}, Critical 재현율 {rec(rule):.1%} -> {rec(pred):.1%}")
conf = P.max(axis=1)
print("[신뢰도 보정] 구간 | 건수 | 평균 신뢰도 | 실제 정확도")
for a, b in [(0, 0.6), (0.6, 0.8), (0.8, 0.95), (0.95, 1.01)]:
m = (conf >= a) & (conf < b)
print(f" {a:.2f}~{min(b, 1):.2f} {m.sum():5d} {conf[m].mean():.2f} {(pred[m] == va['sev'].values[m]).mean():.2f}")
# 2) 월간 부위별 취약성 지수 예측
g = df.assign(v=W_SEV[df["sev"]]).groupby(["part", "month"]).agg(n=("v", "size"), v=("v", "sum")).reset_index()
rows = []
for p in range(NP):
d = g[g["part"] == p].set_index("month").reindex(range(48)).fillna(0)
f = pd.DataFrame({"part": p, "month": range(48), "y": d["v"].values, "n": d["n"].values})
f["lag1"], f["lag2"], f["lag12"] = f["y"].shift(1), f["y"].shift(2), (f["y"] / use).shift(12) * use # lag12: 작년 같은 달 발생률 x 이번 달 훈련 강도
f["exp_v"] = (f["y"] / use).shift(1).rolling(6, min_periods=3).mean() * use # 최근 6개월 발생률 x 이번 달 훈련 강도
f["usage"], f["season_c"], f["season_s"], f["trend"] = use, np.cos(2 * np.pi * f["month"] / 12), np.sin(2 * np.pi * f["month"] / 12), f["month"]
f["price"], f["crit_rate"] = PRICE[p], PARTS[p][5][0]
rows.append(f)
F = pd.concat(rows, ignore_index=True).dropna()
FE = ["lag1", "lag2", "lag12", "exp_v", "usage", "season_c", "season_s", "trend", "price", "crit_rate"]
Ftr, Fva = F[F["month"] < 42], F[F["month"] >= 42]
base = Ftr.groupby("part")["y"].mean()
pb = Fva["part"].map(base).values
def members(d_tr, depth, alpha):
"""릿지(선형 추세·계절)로 먼저 맞추고, 그래디언트 부스팅·랜덤 포레스트는 릿지 잔차를 보정하도록 학습한다."""
rd = make_pipeline(StandardScaler(), Ridge(alpha=alpha)).fit(d_tr[FE], d_tr["y"])
res = d_tr["y"] - rd.predict(d_tr[FE])
gb = GradientBoostingRegressor(n_estimators=100, max_depth=depth, learning_rate=0.05, subsample=0.8, random_state=0).fit(d_tr[FE], res)
rf = RandomForestRegressor(n_estimators=40, max_depth=6, min_samples_leaf=5, n_jobs=-1, random_state=0).fit(d_tr[FE], res)
return [rd, gb, rf]
def blend(ms, d, w):
return ms[0].predict(d[FE]) + w[0] * ms[1].predict(d[FE]) + w[1] * ms[2].predict(d[FE])
GRID_W = [(a / 2, b / 2) for a in range(3) for b in range(3)] # 잔차 보정 가중치 격자(GBR, RF) 0, 0.5, 1
best, solo = (1e9,), {}
folds = [(Ftr[Ftr["month"] < c], Ftr[(Ftr["month"] >= c) & (Ftr["month"] < c + 6)]) for c in (24, 30, 36)] # 확장 윈도 내부 검증 3폴드
for dp in (2, 3):
for al in (1, 10):
fitted = [(members(a_, dp, al), v) for a_, v in folds]
for w in GRID_W:
e_ = np.mean([mean_absolute_error(v["y"], blend(ms, v, w)) for ms, v in fitted])
solo[(dp, al, w)] = e_
if e_ < best[0]:
best = (e_, dp, al, w)
_, dp, al, wt = best
if solo[(dp, al, (0.0, 0.0))] * 0.98 < best[0]: # 잔차 보정의 내부 검증 개선이 2% 미만이면 단순한 모델(릿지)을 채택(과적합 방지)
wt = (0.0, 0.0)
models = members(Ftr, dp, al)
pe = blend(models, Fva, wt)
ev = np.array([(int(a), int(b)) in set(events) for a, b in zip(Fva["part"], Fva["month"])]) # 제조 LOT 불량 등 급증 이벤트 월(별도 경보 대상)
keep = ~ev
y = Fva["y"].values
met = lambda p, m=keep: (mean_absolute_error(y[m], p[m]), mean_squared_error(y[m], p[m]) ** 0.5, r2_score(y[m], p[m]))
mb, me = met(pb), met(pe)
print(f"\n[취약성 지수 예측] 학습 월 12~41, Hold-out 월 42~47 ({len(Fva)}건 중 급증 이벤트 월 {int(ev.sum())}건 제외 평가), 선택 GBR depth={dp}, ridge alpha={al}, 잔차 보정 가중치(GBR, RF)={wt}")
print("모델 | MAE | RMSE | R2")
print(f" 기준(학습 기간 부위별 평균) {mb[0]:7.1f} {mb[1]:7.1f} {mb[2]:.3f}")
print(f" 전월값(참고) {met(Fva['lag1'].values)[0]:7.1f} {met(Fva['lag1'].values)[1]:7.1f} {met(Fva['lag1'].values)[2]:.3f}")
x = met(models[0].predict(Fva[FE]))
print(f" 릿지 단독 {x[0]:7.1f} {x[1]:7.1f} {x[2]:.3f}")
print(f" 고도화(선택 모델) {me[0]:7.1f} {me[1]:7.1f} {me[2]:.3f}")
imp_mae, imp_rmse = 1 - me[0] / mb[0], 1 - me[1] / mb[1]
print(f" (참고) 이벤트 월 포함 전체: 기준 MAE {met(pb, np.ones(len(y), bool))[0]:.1f} -> 앙상블 {met(pe, np.ones(len(y), bool))[0]:.1f}, R2 {met(pe, np.ones(len(y), bool))[2]:.3f}")
print(f"개선율 MAE {imp_mae:.1%}, RMSE {imp_rmse:.1%} (목표 20% 이상: {'달성' if min(imp_mae, imp_rmse) >= 0.2 else '미달'}), R2 {me[2]:.3f} (목표 0.85 이상: {'달성' if me[2] >= 0.85 else '미달'})")
# 3) 종합 품질 취약성 지수 순위(다음 달 예측 상위 5 부위) 카드
last = Fva[Fva["month"] == 47].assign(pred=pe[Fva["month"].values == 47]).sort_values("pred", ascending=False)
print("\n[취약성 지수 카드] 마지막 달 예측 상위 5 부위 | 예측 | 실제")
for r in last.head(5).itertuples():
print(f" {names[r.part]:10s} 예측 {r.pred:6.0f} 실제 {r.y:6.0f} {'(우선 점검 권고)' if r.pred >= last['pred'].median() * 1.5 else ''}")
print(f"예측 상위 5와 실제 상위 5 일치 {len(set(last.head(5)['part']) & set(last.sort_values('y', ascending=False).head(5)['part']))}/5")
In [ ]:
"""야전 QDR 발생 현황을 부위별·월별 히트맵과 집중도 경보로 구성하고, 치명도 분류 모델의 예측마다 기여도 상위 3개 인자(서술 단어·부위·결함 유형)를 자동 산출하며(근거 유효성 검증), 정비·품질 담당자가 읽을 수 있는 자연어 요약 문장을 생성하는 설명가능성(XAI) 모듈을 구현한다."""
import re
import unicodedata
import numpy as np
import pandas as pd
import scipy.sparse as sp
import matplotlib.pyplot as plt
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.linear_model import LogisticRegression
# ---------------------------------------------------------------------------
# [교육용가상자료] 가상 야전 장비 품질결함보고(QDR) 생성기 (실제 부대·장비·업체·정비 실적 없음)
# 가상 장비 부위 12종, 결함 유형 8종, 치명도(Critical/Major/Minor), 원인 5종, 가상 외주정비 업체 4곳 + 부대정비.
# QDR 1건마다 한국어 서술 텍스트를 만들고, 월별 건수는 계절·추세·훈련 강도(usage)·일부 제조 불량 이벤트를 따른다.
# ---------------------------------------------------------------------------
PARTS = [ # (이름, 별칭, 월 기준 건수, 계절 진폭, 정점 월, (Critical, Major 비율), 가상 단가지수, 제조사, 주요 결함 가중치)
("엔진 연료계통", ["연료계통", "연료 라인", "연료 펌프"], 26, 0.15, 7, (0.10, 0.35), 6.0, "MK1", "누유:4 마모:2 체결불량:2 오작동:2"),
("변속기", ["변속기", "T/M", "미션"], 22, 0.10, 8, (0.12, 0.38), 9.0, "MK1", "마모:4 누유:2 과열:3 오작동:1"),
("제동 장치", ["제동 장치", "브레이크", "제동 라인"], 20, 0.20, 12, (0.26, 0.40), 5.0, "MK2", "마모:4 균열:2 누유:2 체결불량:2"),
("조향 장치", ["조향 장치", "핸들 연동부", "조향 로드"], 14, 0.10, 6, (0.30, 0.40), 5.0, "MK2", "체결불량:4 균열:2 마모:3 오작동:1"),
("냉각 계통", ["냉각 계통", "라디에이터", "냉각수 라인"], 24, 0.30, 7, (0.05, 0.30), 3.0, "MK3", "누유:4 과열:4 부식:2"),
("전기·배터리", ["배터리", "전기 계통", "발전기"], 28, 0.35, 1, (0.04, 0.28), 2.5, "MK3", "접촉불량:4 부식:2 과열:2 오작동:2"),
("통신 모듈", ["통신 모듈", "무전기 연동부", "안테나 접속부"], 16, 0.05, 4, (0.03, 0.25), 4.0, "MK4", "접촉불량:5 오작동:4 부식:1"),
("유압 계통", ["유압 계통", "유압 호스", "유압 실린더"], 18, 0.10, 9, (0.10, 0.35), 4.5, "MK2", "누유:5 균열:2 마모:2 체결불량:1"),
("현가 장치", ["현가 장치", "서스펜션", "완충기"], 15, 0.08, 5, (0.06, 0.30), 3.5, "MK4", "마모:4 균열:3 체결불량:2 부식:1"),
("차체 용접부", ["차체 용접부", "프레임 용접부", "차체 이음부"], 10, 0.05, 10, (0.28, 0.40), 7.0, "MK1", "균열:6 부식:3 체결불량:1"),
("광학·조준 장비", ["광학 장비", "조준경", "관측 장비"], 8, 0.05, 3, (0.03, 0.22), 8.0, "MK4", "오작동:4 접촉불량:3 부식:2 체결불량:1"),
("공기 정화 필터", ["공기 필터", "흡기 필터", "정화 장치"], 12, 0.25, 4, (0.01, 0.12), 1.0, "MK3", "마모:5 부식:2 체결불량:3")]
NP = len(PARTS)
DEFECTS = { # 결함 유형: 증상 표현(동의어)
"균열": ["균열이 발견됨", "크랙이 확인됨", "갈라짐이 있음"], "누유": ["누유가 발생함", "오일이 새어 나옴", "누설 흔적이 있음"],
"마모": ["마모가 심함", "과도한 마모가 확인됨", "닳은 흔적이 있음"], "부식": ["부식이 진행됨", "녹이 발생함", "산화 흔적이 있음"],
"접촉불량": ["접촉 불량이 발생함", "커넥터 단선이 의심됨", "신호가 끊김"], "체결불량": ["볼트 풀림이 확인됨", "체결 상태가 불량함", "고정부가 헐거움"],
"과열": ["과열 경고가 발생함", "온도가 과도하게 상승함", "열이 심하게 남"], "오작동": ["오작동이 반복됨", "작동이 불안정함", "간헐적으로 멈춤"]}
DNAMES = list(DEFECTS)
SEV = ["Minor", "Major", "Critical"]
SEV_CUE = {0: ["경미한 수준임", "운용에는 지장이 없음", "육안 점검상 사소한 이상임", "기능에는 영향이 없음"],
1: ["성능 저하가 있음", "조속한 정비가 필요함", "출력이 떨어지는 상태임", "기능이 부분적으로 제한됨"],
2: ["즉시 운용 중지함", "안전에 위험이 있어 사용 불가", "작동 정지로 임무 수행이 어려움", "인명 안전 위협이 우려됨"]}
CAUSES = ["제조 불량", "정비 품질", "운용 부주의", "설계 취약", "자연 노후"]
CAUSE_CUE = {
"제조 불량": ["신품 인수 직후 발생함", "출고 상태에서 이미 이상이 있었음", "인수 후 얼마 지나지 않아 발생함"],
"정비 품질": ["외주 정비 후 얼마 되지 않아 재발함", "정비 후 재조립 상태가 불량함", "정비 직후 같은 부위에서 다시 발생함"],
"운용 부주의": ["험지 기동 중 충격 이후 발생함", "과부하 운용 후 발생함", "점검 없이 연속 운용한 뒤 발생함"],
"설계 취약": ["동일 증상이 다른 차량에서도 반복됨", "구조적으로 취약한 부위로 보임", "여러 부대에서 같은 형태로 보고됨"],
"자연 노후": ["사용 연한이 도래함", "장기간 사용으로 노후됨", "수명 주기 말기에 발생함"]}
VENDORS = ["외주A", "외주B", "외주C", "외주D", "부대정비"]
ACTIONS = ["정비소 입고를 요청함", "현장 임시 조치 후 운용 중임", "부품 교환을 요청함", "재검사를 요청함", "원인 확인이 필요함"]
MAKER_OF = [p[7] for p in PARTS]
PRICE = np.array([p[6] for p in PARTS])
def usage_index(months, seed=0):
"""월별 훈련 강도(미리 알 수 있는 계획 값): 연 1회 정점을 갖는 계절 + 작은 잡음"""
r = np.random.default_rng(seed + 99)
t = np.arange(months)
return 1 + 0.25 * np.sin(2 * np.pi * (t - 3) / 12) + r.normal(0, 0.04, months)
def make_qdr(months=36, seed=1, scale=1.0, surge=True):
"""반환: QDR DataFrame(건당 1행), 월별 usage, 급증 이벤트 목록"""
rng = np.random.default_rng(seed)
use = usage_index(months, seed)
events = []
if surge:
for _ in range(max(2, months // 12 * 2)):
events.append((int(rng.integers(NP)), int(rng.integers(6, months))))
rows = []
wdef = [np.array([float(x.split(":")[1]) for x in p[8].split()]) for p in PARTS]
ddef = [[x.split(":")[0] for x in p[8].split()] for p in PARTS]
n = 0
for m in range(months):
for pi, p in enumerate(PARTS):
lam = p[2] * scale * (1 + 0.010 * m) * use[m] * (1 + p[3] * np.cos(2 * np.pi * (m % 12 - p[4]) / 12))
ev = (pi, m) in events
k = rng.poisson(lam * (2.6 if ev else 1.0))
for _ in range(k):
rows.append((n, m, pi, ev)); n += 1
N = len(rows)
A = np.array(rows)
month, part, ev = A[:, 1], A[:, 2], A[:, 3].astype(bool)
defect = np.array([rng.choice(ddef[i], p=wdef[i] / wdef[i].sum()) for i in part])
cr = np.array([PARTS[i][5][0] for i in part]); ma = np.array([PARTS[i][5][1] for i in part])
u = rng.random(N)
sev = np.where(u < cr, 2, np.where(u < cr + ma, 1, 0))
vendor = rng.choice(VENDORS, N, p=[0.25, 0.2, 0.2, 0.15, 0.2])
# 원인: 기본 비율에 부위(제조사)·업체·이벤트 효과를 곱한다
base = np.array([0.22, 0.20, 0.20, 0.16, 0.22])
P = np.tile(base, (N, 1))
P[vendor == "외주B", 1] *= 2.6
P[ev, 0] *= 5.0
design = np.isin(part, [2, 3, 7]); P[design, 3] *= 2.2
P[np.isin(part, [4, 5, 11]), 0] *= 2.0 # 제조사 MK3 납품 부위는 제조 불량 비중이 높다
P[defect == "마모", 4] *= 1.6; P[defect == "누유", 4] *= 1.3
P /= P.sum(axis=1, keepdims=True)
cause = np.array([CAUSES[rng.choice(5, p=P[i])] for i in range(N)])
since = np.where(cause == "정비 품질", rng.exponential(35, N), rng.exponential(160, N)).clip(1, 720).round().astype(int)
wait = (rng.lognormal(1.3, 0.5, N) * np.where(vendor == "외주B", 1.7, np.where(vendor == "부대정비", 0.6, 1.0)) * (0.7 + 0.5 * use[month])).round(1)
reins = rng.random(N) < np.where(cause == "정비 품질", 0.6, 0.08)
texts = []
for i in range(N):
pt = PARTS[part[i]]
cl = [f"{rng.choice(pt[1])}에서 {rng.choice(DEFECTS[defect[i]])}"]
if rng.random() < 0.7: cl.append(rng.choice(SEV_CUE[sev[i]]))
if rng.random() < 0.65: # 원인 단서: 8%는 실제 원인과 다른 단서가 적힌다(보고자의 추정)
cc = cause[i] if rng.random() > 0.08 else rng.choice(CAUSES)
cl.append(rng.choice(CAUSE_CUE[cc]))
cl.append(rng.choice(ACTIONS))
if rng.random() < 0.5: cl.insert(0, f"VU-{rng.integers(1, 31):02d} 소속")
texts.append(". ".join(cl) + ".")
df = pd.DataFrame({"qdr_id": [f"Q{seed}{i:06d}" for i in range(N)], "month": month, "unit": [f"VU-{x:02d}" for x in rng.integers(1, 31, N)],
"part": part, "defect": defect, "sev": sev, "cause": cause, "vendor": vendor, "maker": [MAKER_OF[i] for i in part],
"since_maint": since, "inspect_wait": wait, "reinspect": reins, "event": ev, "text": texts})
df["cost"] = PRICE[df["part"]] * np.array([1.0, 2.0, 4.0])[df["sev"]] # [교육용가상자료] 수리·조치 비용지수
return df, use, events
# ---------------------------------------------------------------------------
# 공통 텍스트 조각: 정규화, 간이 토큰화(조사 제거), 부위·결함·치명도·원인 키워드 라벨링
# ---------------------------------------------------------------------------
TYPO = {"누윳": "누유", "균렬": "균열", "접축 불량": "접촉 불량", "부씩": "부식", "마묘": "마모", "과렬": "과열", "오작똥": "오작동", "재동": "제동"}
ABBR = {"T/M": "변속기", "BAT": "배터리"}
PART_KW = sorted([(a, i) for i, p in enumerate(PARTS) for a in p[1]], key=lambda x: -len(x[0]))
DEF_KW = {"균열": ["균열", "크랙", "갈라"], "누유": ["누유", "새어", "누설"], "마모": ["마모", "닳은"], "부식": ["부식", "녹이", "산화"],
"접촉불량": ["접촉 불량", "단선", "끊김"], "체결불량": ["볼트", "체결", "헐거"], "과열": ["과열", "온도", "열이"], "오작동": ["오작동", "불안정", "멈춤"]}
SEV_KW = [(2, ["즉시 운용 중지", "사용 불가", "위험"]), (1, ["성능 저하", "조속한"]), (0, ["경미", "지장이 없"])]
CAUSE_KW = {"제조 불량": ["신품", "출고", "인수"], "정비 품질": ["정비 후", "재조립", "정비 직후"], "운용 부주의": ["충격", "과부하", "점검 없이"],
"설계 취약": ["동일 증상", "구조적", "여러 부대"], "자연 노후": ["사용 연한", "노후", "수명"]}
PARTICLES = ("에서", "으로", "이", "가", "은", "는", "을", "를", "에", "의", "로", "와", "과", "도", "함", "음", "됨")
def norm(t):
"""정규화: 전각->반각, 특수문자·중복 공백 제거, 약어 확장, 오타 교정"""
t = unicodedata.normalize("NFKC", str(t))
t = re.sub(r"[!#~*@^]+", " ", t)
for a, b in ABBR.items():
t = t.replace(a, b)
for a, b in TYPO.items():
t = t.replace(a, b)
return re.sub(r"\s+", " ", t).strip()
def tok(t):
out = []
for w in re.split(r"[\s.,]+", norm(t)):
if not w or re.fullmatch(r"[\d\-A-Za-z]+", w):
continue
for p in PARTICLES:
if len(w) > len(p) + 1 and w.endswith(p):
w = w[:-len(p)]
break
out.append(w)
return out
def label_part(t):
for a, i in PART_KW:
if a in t:
return i
return -1
def label_defect(t):
for d, ks in DEF_KW.items():
if any(k in t for k in ks):
return d
return "미분류"
def label_sev(t):
for s, ks in SEV_KW:
if any(k in t for k in ks):
return s
return -1
def label_cause(t):
for c, ks in CAUSE_KW.items():
if any(k in t for k in ks):
return c
return "미상"
df, use, events = make_qdr(36, seed=1, scale=2.0)
names = [p[0] for p in PARTS]
tr, va = df[df["month"] < 24].reset_index(drop=True), df[df["month"] >= 24].reset_index(drop=True)
vec = TfidfVectorizer(tokenizer=tok, lowercase=False, token_pattern=None, min_df=5)
oh = lambda d: sp.csr_matrix(np.hstack([np.eye(NP)[d["part"]], pd.get_dummies(d["defect"]).reindex(columns=DNAMES, fill_value=0).values.astype(float)]))
Xtr, Xva = sp.hstack([vec.fit_transform(tr["text"]), oh(tr)]).tocsr(), sp.hstack([vec.transform(va["text"]), oh(va)]).tocsr()
fnames = list(vec.get_feature_names_out()) + [f"부위:{n}" for n in names] + [f"결함:{d}" for d in DNAMES]
clf = LogisticRegression(C=5, max_iter=500).fit(Xtr, tr["sev"])
P = clf.predict_proba(Xva); pred = P.argmax(axis=1)
print(f"[교육용가상자료] QDR {len(df)}건, 검증 {len(va)}건 치명도 분류 정확도 {(pred == va['sev'].values).mean():.1%}")
# 1) 건마다 기여도 상위 3개 인자 (기여도 = 특성값 x 예측 클래스 계수)
co = clf.coef_
top3 = []
for i in range(Xva.shape[0]):
row = Xva.getrow(i)
contrib = row.data * co[pred[i], row.indices]
order = np.argsort(-contrib)[:3]
top3.append([(row.indices[j], contrib[j]) for j in order])
ok = sum(len(t) == 3 for t in top3)
print(f"예측 {len(top3)}건 중 기여도 상위 3개 인자 산출 {ok}건 (산출률 {ok / len(top3):.0%}, 목표 100%: {'달성' if ok == len(top3) else '미달'})")
# 2) 근거 유효성: 1순위 인자를 제거하면 예측 클래스 확률이 낮아지는가 (표본 2,000건)
idx = np.random.default_rng(0).choice(len(top3), 2000, replace=False)
X2 = Xva[idx].tolil()
for r, i in enumerate(idx):
X2[r, top3[i][0][0]] = 0
P2 = clf.predict_proba(X2.tocsr())
drop = P2[np.arange(len(idx)), pred[idx]] < P[idx, pred[idx]]
print(f"근거 유효성: 1순위 인자를 제거하면 예측 확률이 낮아지는 비율 {drop.mean():.1%} (평균 {P[idx, pred[idx]].mean():.2f} -> {P2[np.arange(len(idx)), pred[idx]].mean():.2f})")
def summarize(i):
d = va.iloc[i]
f = [fnames[j] for j, _ in top3[i]]
return f"{names[d['part']]}의 {d['defect']} 결함, 치명도 {SEV[pred[i]]} 가능성 {P[i, pred[i]]:.0%}. 주요 근거: " + ", ".join(f"'{x}'" for x in f) + "."
# 3) 점검 카드 (치명도별 1건)
print("\n[점검 카드] 치명도 | 실제 | 설명")
for s in (2, 1, 0):
i = int(np.where((pred == s) & (va["sev"].values == s) & (P.max(axis=1) > 0.9))[0][0])
print(f" 예측 {SEV[s]:8s} (실제 {SEV[va.iloc[i]['sev']]})")
print(" " + summarize(i))
print(" 기여도: " + " / ".join(f"{fnames[j]}({c:+.1f})" for j, c in top3[i]))
# 4) 발생 현황 히트맵(최근 6개월 x 부위)과 집중도 경보: 월 건수 / 학습 기간 월평균 > 1.4 이고 Critical 건이 있으면 경보
H = df[df["month"] >= 30].pivot_table(index="part", columns="month", values="qdr_id", aggfunc="size", fill_value=0)
base = tr.groupby("part").size() / 24
ratio = H.div(base, axis=0)
alerts = [(int(p), int(m), ratio.loc[p, m]) for p in H.index for m in H.columns if ratio.loc[p, m] > 1.4 and ((df["part"] == p) & (df["month"] == m) & (df["sev"] == 2)).any()]
print("\n[QDR 발생 히트맵] 부위 | 월 30~35 건수 | 학습 월평균 대비 최대 배수")
for p in H.sum(axis=1).sort_values(ascending=False).index[:6]:
print(f" {names[p]:10s} " + " ".join(f"{v:4d}" for v in H.loc[p]) + f" x{ratio.loc[p].max():.2f}")
print(f"집중도 경보 {len(alerts)}건: " + ", ".join(f"{names[p]}({m}월, x{r:.1f})" for p, m, r in sorted(alerts, key=lambda a: -a[2])[:4]))
fig, ax = plt.subplots(1, 2, figsize=(11, 4))
im = ax[0].imshow(ratio.values, aspect="auto", cmap="Reds"); ax[0].set_yticks(range(NP)); ax[0].set_yticklabels(range(NP)); ax[0].set_title("QDR concentration (x baseline)"); ax[0].set_xlabel("month"); plt.colorbar(im, ax=ax[0])
ax[1].hist(P.max(axis=1), bins=20); ax[1].set_title("Prediction confidence")
plt.tight_layout(); plt.show()
In [ ]:
"""최근 12개월 QDR 근거로 부위별 제조사 하자 구상(제조 불량 우세)과 설계 변경 소요 제기(설계 취약 우세) 후보를 제안하되 자동 확정하지 않고 품질관 검토 대기열에 올려 승인·반려·무응답(조치 보류)을 사유와 함께 해시 체인 감사 로그에 남기는 HITL 모듈을 시뮬레이션하고, 개별 사후 수리 대비 비용 절감률과 승인 지연·처리 용량·전시 전환 민감도를 분석한다."""
import hashlib
import json
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
# ---------------------------------------------------------------------------
# [교육용가상자료] 가상 야전 장비 품질결함보고(QDR) 생성기 (실제 부대·장비·업체·정비 실적 없음)
# 가상 장비 부위 12종, 결함 유형 8종, 치명도(Critical/Major/Minor), 원인 5종, 가상 외주정비 업체 4곳 + 부대정비.
# QDR 1건마다 한국어 서술 텍스트를 만들고, 월별 건수는 계절·추세·훈련 강도(usage)·일부 제조 불량 이벤트를 따른다.
# ---------------------------------------------------------------------------
PARTS = [ # (이름, 별칭, 월 기준 건수, 계절 진폭, 정점 월, (Critical, Major 비율), 가상 단가지수, 제조사, 주요 결함 가중치)
("엔진 연료계통", ["연료계통", "연료 라인", "연료 펌프"], 26, 0.15, 7, (0.10, 0.35), 6.0, "MK1", "누유:4 마모:2 체결불량:2 오작동:2"),
("변속기", ["변속기", "T/M", "미션"], 22, 0.10, 8, (0.12, 0.38), 9.0, "MK1", "마모:4 누유:2 과열:3 오작동:1"),
("제동 장치", ["제동 장치", "브레이크", "제동 라인"], 20, 0.20, 12, (0.26, 0.40), 5.0, "MK2", "마모:4 균열:2 누유:2 체결불량:2"),
("조향 장치", ["조향 장치", "핸들 연동부", "조향 로드"], 14, 0.10, 6, (0.30, 0.40), 5.0, "MK2", "체결불량:4 균열:2 마모:3 오작동:1"),
("냉각 계통", ["냉각 계통", "라디에이터", "냉각수 라인"], 24, 0.30, 7, (0.05, 0.30), 3.0, "MK3", "누유:4 과열:4 부식:2"),
("전기·배터리", ["배터리", "전기 계통", "발전기"], 28, 0.35, 1, (0.04, 0.28), 2.5, "MK3", "접촉불량:4 부식:2 과열:2 오작동:2"),
("통신 모듈", ["통신 모듈", "무전기 연동부", "안테나 접속부"], 16, 0.05, 4, (0.03, 0.25), 4.0, "MK4", "접촉불량:5 오작동:4 부식:1"),
("유압 계통", ["유압 계통", "유압 호스", "유압 실린더"], 18, 0.10, 9, (0.10, 0.35), 4.5, "MK2", "누유:5 균열:2 마모:2 체결불량:1"),
("현가 장치", ["현가 장치", "서스펜션", "완충기"], 15, 0.08, 5, (0.06, 0.30), 3.5, "MK4", "마모:4 균열:3 체결불량:2 부식:1"),
("차체 용접부", ["차체 용접부", "프레임 용접부", "차체 이음부"], 10, 0.05, 10, (0.28, 0.40), 7.0, "MK1", "균열:6 부식:3 체결불량:1"),
("광학·조준 장비", ["광학 장비", "조준경", "관측 장비"], 8, 0.05, 3, (0.03, 0.22), 8.0, "MK4", "오작동:4 접촉불량:3 부식:2 체결불량:1"),
("공기 정화 필터", ["공기 필터", "흡기 필터", "정화 장치"], 12, 0.25, 4, (0.01, 0.12), 1.0, "MK3", "마모:5 부식:2 체결불량:3")]
NP = len(PARTS)
DEFECTS = { # 결함 유형: 증상 표현(동의어)
"균열": ["균열이 발견됨", "크랙이 확인됨", "갈라짐이 있음"], "누유": ["누유가 발생함", "오일이 새어 나옴", "누설 흔적이 있음"],
"마모": ["마모가 심함", "과도한 마모가 확인됨", "닳은 흔적이 있음"], "부식": ["부식이 진행됨", "녹이 발생함", "산화 흔적이 있음"],
"접촉불량": ["접촉 불량이 발생함", "커넥터 단선이 의심됨", "신호가 끊김"], "체결불량": ["볼트 풀림이 확인됨", "체결 상태가 불량함", "고정부가 헐거움"],
"과열": ["과열 경고가 발생함", "온도가 과도하게 상승함", "열이 심하게 남"], "오작동": ["오작동이 반복됨", "작동이 불안정함", "간헐적으로 멈춤"]}
DNAMES = list(DEFECTS)
SEV = ["Minor", "Major", "Critical"]
SEV_CUE = {0: ["경미한 수준임", "운용에는 지장이 없음", "육안 점검상 사소한 이상임", "기능에는 영향이 없음"],
1: ["성능 저하가 있음", "조속한 정비가 필요함", "출력이 떨어지는 상태임", "기능이 부분적으로 제한됨"],
2: ["즉시 운용 중지함", "안전에 위험이 있어 사용 불가", "작동 정지로 임무 수행이 어려움", "인명 안전 위협이 우려됨"]}
CAUSES = ["제조 불량", "정비 품질", "운용 부주의", "설계 취약", "자연 노후"]
CAUSE_CUE = {
"제조 불량": ["신품 인수 직후 발생함", "출고 상태에서 이미 이상이 있었음", "인수 후 얼마 지나지 않아 발생함"],
"정비 품질": ["외주 정비 후 얼마 되지 않아 재발함", "정비 후 재조립 상태가 불량함", "정비 직후 같은 부위에서 다시 발생함"],
"운용 부주의": ["험지 기동 중 충격 이후 발생함", "과부하 운용 후 발생함", "점검 없이 연속 운용한 뒤 발생함"],
"설계 취약": ["동일 증상이 다른 차량에서도 반복됨", "구조적으로 취약한 부위로 보임", "여러 부대에서 같은 형태로 보고됨"],
"자연 노후": ["사용 연한이 도래함", "장기간 사용으로 노후됨", "수명 주기 말기에 발생함"]}
VENDORS = ["외주A", "외주B", "외주C", "외주D", "부대정비"]
ACTIONS = ["정비소 입고를 요청함", "현장 임시 조치 후 운용 중임", "부품 교환을 요청함", "재검사를 요청함", "원인 확인이 필요함"]
MAKER_OF = [p[7] for p in PARTS]
PRICE = np.array([p[6] for p in PARTS])
def usage_index(months, seed=0):
"""월별 훈련 강도(미리 알 수 있는 계획 값): 연 1회 정점을 갖는 계절 + 작은 잡음"""
r = np.random.default_rng(seed + 99)
t = np.arange(months)
return 1 + 0.25 * np.sin(2 * np.pi * (t - 3) / 12) + r.normal(0, 0.04, months)
def make_qdr(months=36, seed=1, scale=1.0, surge=True):
"""반환: QDR DataFrame(건당 1행), 월별 usage, 급증 이벤트 목록"""
rng = np.random.default_rng(seed)
use = usage_index(months, seed)
events = []
if surge:
for _ in range(max(2, months // 12 * 2)):
events.append((int(rng.integers(NP)), int(rng.integers(6, months))))
rows = []
wdef = [np.array([float(x.split(":")[1]) for x in p[8].split()]) for p in PARTS]
ddef = [[x.split(":")[0] for x in p[8].split()] for p in PARTS]
n = 0
for m in range(months):
for pi, p in enumerate(PARTS):
lam = p[2] * scale * (1 + 0.010 * m) * use[m] * (1 + p[3] * np.cos(2 * np.pi * (m % 12 - p[4]) / 12))
ev = (pi, m) in events
k = rng.poisson(lam * (2.6 if ev else 1.0))
for _ in range(k):
rows.append((n, m, pi, ev)); n += 1
N = len(rows)
A = np.array(rows)
month, part, ev = A[:, 1], A[:, 2], A[:, 3].astype(bool)
defect = np.array([rng.choice(ddef[i], p=wdef[i] / wdef[i].sum()) for i in part])
cr = np.array([PARTS[i][5][0] for i in part]); ma = np.array([PARTS[i][5][1] for i in part])
u = rng.random(N)
sev = np.where(u < cr, 2, np.where(u < cr + ma, 1, 0))
vendor = rng.choice(VENDORS, N, p=[0.25, 0.2, 0.2, 0.15, 0.2])
# 원인: 기본 비율에 부위(제조사)·업체·이벤트 효과를 곱한다
base = np.array([0.22, 0.20, 0.20, 0.16, 0.22])
P = np.tile(base, (N, 1))
P[vendor == "외주B", 1] *= 2.6
P[ev, 0] *= 5.0
design = np.isin(part, [2, 3, 7]); P[design, 3] *= 2.2
P[np.isin(part, [4, 5, 11]), 0] *= 2.0 # 제조사 MK3 납품 부위는 제조 불량 비중이 높다
P[defect == "마모", 4] *= 1.6; P[defect == "누유", 4] *= 1.3
P /= P.sum(axis=1, keepdims=True)
cause = np.array([CAUSES[rng.choice(5, p=P[i])] for i in range(N)])
since = np.where(cause == "정비 품질", rng.exponential(35, N), rng.exponential(160, N)).clip(1, 720).round().astype(int)
wait = (rng.lognormal(1.3, 0.5, N) * np.where(vendor == "외주B", 1.7, np.where(vendor == "부대정비", 0.6, 1.0)) * (0.7 + 0.5 * use[month])).round(1)
reins = rng.random(N) < np.where(cause == "정비 품질", 0.6, 0.08)
texts = []
for i in range(N):
pt = PARTS[part[i]]
cl = [f"{rng.choice(pt[1])}에서 {rng.choice(DEFECTS[defect[i]])}"]
if rng.random() < 0.7: cl.append(rng.choice(SEV_CUE[sev[i]]))
if rng.random() < 0.65: # 원인 단서: 8%는 실제 원인과 다른 단서가 적힌다(보고자의 추정)
cc = cause[i] if rng.random() > 0.08 else rng.choice(CAUSES)
cl.append(rng.choice(CAUSE_CUE[cc]))
cl.append(rng.choice(ACTIONS))
if rng.random() < 0.5: cl.insert(0, f"VU-{rng.integers(1, 31):02d} 소속")
texts.append(". ".join(cl) + ".")
df = pd.DataFrame({"qdr_id": [f"Q{seed}{i:06d}" for i in range(N)], "month": month, "unit": [f"VU-{x:02d}" for x in rng.integers(1, 31, N)],
"part": part, "defect": defect, "sev": sev, "cause": cause, "vendor": vendor, "maker": [MAKER_OF[i] for i in part],
"since_maint": since, "inspect_wait": wait, "reinspect": reins, "event": ev, "text": texts})
df["cost"] = PRICE[df["part"]] * np.array([1.0, 2.0, 4.0])[df["sev"]] # [교육용가상자료] 수리·조치 비용지수
return df, use, events
df, use, events = make_qdr(48, seed=1, scale=1.5)
names = [p[0] for p in PARTS]
D0 = 24 # 검토 기준 월: 직전 12개월(12~23) 근거로 제안, 이후 24개월(24~47) 효과 평가
CLAIM_FIXED, CHANGE_FIXED, FIX_VENDOR = 15.0, 100.0, 50.0 # [교육용가상자료] 하자 구상 청구 처리비, 설계 변경 소요 제기·검토 비용지수
RECOVER, REDUCE, LAG, W_WAIT, REINSPECT = 0.8, 0.8, 3, 0.8, 6.0 # 하자 구상 회수율, 설계 변경 후 해당 원인 QDR 감소율, 설계 변경 반영 지연(개월)
class Audit:
def __init__(self):
self.log = []
def add(self, **body):
prev = self.log[-1]["hash"] if self.log else "GENESIS"
body = {"seq": len(self.log), **body}
self.log.append({**body, "prev": prev, "hash": hashlib.sha256((prev + json.dumps(body, sort_keys=True, ensure_ascii=False)).encode()).hexdigest()})
def verify(self):
prev = "GENESIS"
for e in self.log:
b = {k: v for k, v in e.items() if k not in ("prev", "hash")}
if e["prev"] != prev or hashlib.sha256((prev + json.dumps(b, sort_keys=True, ensure_ascii=False)).encode()).hexdigest() != e["hash"]:
return e["seq"]
prev = e["hash"]
return None
def proposals(d):
"""부위별로 근거(건수·치명도·원인 비중)를 집계해 제안 유형을 정한다."""
ev = d[(d["month"] >= D0 - 12) & (d["month"] < D0)]
out = []
for p, g in ev.groupby("part"):
df_, n = g["defect"].mode()[0], len(g)
mfg, dsg = (g["cause"] == "제조 불량").mean(), (g["cause"] == "설계 취약").mean()
noise = ((g["cause"] == "운용 부주의") | (g["cause"] == "자연 노후")).mean()
if n >= 60 and mfg >= 0.22:
out.append({"part": p, "defect": df_, "kind": "하자 구상", "n": n, "share": mfg, "crit": (g["sev"] == 2).mean(), "noise": noise})
if n >= 60 and dsg >= 0.20:
out.append({"part": p, "defect": df_, "kind": "설계 변경", "n": n, "share": dsg, "crit": (g["sev"] == 2).mean(), "noise": noise})
for v, g in ev.groupby("vendor"):
sh = (g["cause"] == "정비 품질").mean()
if v != "부대정비" and len(g) >= 100 and sh >= 0.28:
out.append({"part": -1, "vendor": v, "defect": "-", "kind": "외주정비 시정", "n": len(g), "share": sh, "crit": (g["sev"] == 2).mean(), "noise": ((g["cause"] == "운용 부주의") | (g["cause"] == "자연 노후")).mean()})
return out
def reviewer(pr, rng, second=False):
"""[모의 품질관] 근거가 약하거나 운용·노후 원인이 우세하면 반려, 10%는 기한 내 무응답(재상정 시에는 응답)."""
if not second and rng.random() < 0.10:
return "무응답", "기한 내 응답 없음 -> 조치 보류(기본 계획 유지)"
if pr["noise"] > 0.46:
return "반려", f"운용·노후 원인 {pr['noise']:.0%}로 제조사 책임 근거 부족"
if pr["share"] < 0.235 and pr["kind"] != "외주정비 시정":
return "반려", f"원인 비중 {pr['share']:.0%}로 평균 수준, 추가 관찰"
if pr["n"] < 120 and pr["crit"] < 0.10:
return "반려", f"근거 {pr['n']}건, Critical {pr['crit']:.0%}로 추가 관찰"
return "승인", f"{pr['kind']} 근거 {pr['n']}건, 원인 비중 {pr['share']:.0%}, Critical {pr['crit']:.0%}"
def effect(d, pr, delay, surge):
"""승인된 제안의 이후 12개월 효과(비용지수 이득): 하자 구상은 제조 불량 QDR 비용의 일부 회수, 설계 변경은 설계 취약 QDR 감소"""
f = d[(d["month"] >= D0) & (d["part"] == pr["part"])] if pr["part"] >= 0 else d[(d["month"] >= D0) & (d["vendor"] == pr["vendor"])]
start = D0 + delay
if pr["kind"] == "외주정비 시정":
g = f[(f["cause"] == "정비 품질") & (f["month"] >= start + 2)]
return 0.7 * (g["cost"] + W_WAIT * g["inspect_wait"] + REINSPECT * g["reinspect"]).sum() * surge - FIX_VENDOR
if pr["kind"] == "하자 구상":
c = f[(f["cause"] == "제조 불량") & (f["month"] >= start)]["cost"].sum() * surge
return RECOVER * c - CLAIM_FIXED
c = f[(f["cause"] == "설계 취약") & (f["month"] >= start + LAG)]["cost"].sum() * surge
return REDUCE * c - CHANGE_FIXED
def run(mode, delay=1, surge=1.0, cap=99, seed=0, audit=None, resubmit=True):
rng = np.random.default_rng(seed)
props = sorted(proposals(df), key=lambda x: -x["n"] * (1 + 3 * x["crit"]))
extra = 0
gain, dec, n_ok = 0.0, {"승인": 0, "반려": 0, "무응답": 0}, 0
for pr in props:
if mode == "auto":
d_, why = "승인", "비교용 전부 적용"
else:
audit.add(event="PROPOSE", target=(names[pr["part"]] if pr["part"] >= 0 else pr["vendor"]) + "/" + pr["kind"], status="PENDING")
d_, why = reviewer(pr, rng)
if d_ == "승인" and n_ok >= cap:
d_, why = "무응답", "월 처리 용량 초과 -> 보류"
tg = (names[pr["part"]] if pr["part"] >= 0 else pr["vendor"]) + "/" + pr["kind"]
audit.add(event="DECIDE", target=tg, decision=d_, reason=why, by="품질관(모의)")
extra = 0
if d_ == "무응답" and resubmit: # 무응답은 기본 계획 유지(조치 보류), 다음 분기 검토 회의에 재상정
audit.add(event="RESUBMIT", target=tg, status="PENDING", note="분기 검토 회의 재상정(자동 승인 아님)")
d_, why = reviewer(pr, rng, second=True)
audit.add(event="DECIDE", target=tg, decision=d_, reason=why + "(재상정)", by="품질관(모의)")
extra = 3
dec[d_] += 1
if d_ == "승인":
n_ok += 1; gain += effect(df, pr, (delay + (extra if mode != "auto" else 0)) if mode != "auto" else 0, surge)
e = df[df["month"] >= D0]
base = (e["cost"].sum() + W_WAIT * e["inspect_wait"].sum() + REINSPECT * e["reinspect"].sum()) * surge
return gain, base, dec, len(props)
audit = Audit()
g_h, base, dec, n_prop = run("hitl", seed=3, audit=audit)
g_a, _, _, _ = run("auto")
print(f"[교육용가상자료] QDR {len(df)}건, 검토 기준 월 {D0}, 후보 제안 {n_prop}건(하자 구상·설계 변경·외주정비 시정)")
print("\n[비용 비교] 방식 | 이후 24개월 비용지수 | 절감액")
for nm, g in [("개별 사후 수리(기본 계획)", 0), ("제안 + 품질관 승인", g_h), ("제안 전부 적용(비교용)", g_a)]:
print(f" {nm:22s} {base - g:9.0f} {g:8.0f}")
cut = g_h / base
print(f"HITL 적용 비용 절감률 {cut:.1%} (목표 15% 이상: {'달성' if cut >= 0.15 else '미달'}), 비교용 전부 적용 {g_a / base:.1%}")
print(f"최종 결정 {sum(dec.values())}건: " + ", ".join(f"{k} {v}건" for k, v in dec.items()))
tgt = {e["target"] for e in audit.log if e["event"] == "PROPOSE"}; dec_t = {e["target"] for e in audit.log if e["event"] == "DECIDE"}
n_re = sum(e["event"] == "RESUBMIT" for e in audit.log)
print(f"제안 {len(tgt)}건 중 결정 기록 {len(tgt & dec_t)}건(로깅률 {len(tgt & dec_t) / len(tgt):.0%}), 자동 승인 0건, 무응답 {n_re}건은 조치 보류 후 분기 회의 재상정")
rej = [e["reason"] for e in audit.log if e.get("decision") == "반려"]
print("반려 사유 예:", rej[0] if rej else "-")
print(f"감사 로그 무결성: {'정상' if audit.verify() is None else '이상'}", end=", ")
audit.log[3]["reason"] = "사후 수정"
print(f"사후 변조 시 seq {audit.verify()}에서 탐지")
print("\n[민감도 1] 승인까지 지연(개월) -> 절감률")
for dl in (0, 1, 3, 6, 9):
g, b, _, _ = run("hitl", delay=dl, seed=3, audit=Audit())
print(f" {dl}개월: {g / b:.1%}")
print("[민감도 2] 품질관 처리 용량(승인 가능 건수) -> 절감률")
for cp in (2, 5, 8, 99):
g, b, _, _ = run("hitl", cap=cp, seed=3, audit=Audit())
print(f" 최대 {cp if cp < 99 else '무제한'}건: {g / b:.1%}")
print("[민감도 3] 전시 전환 가정(QDR 비용 1.5배 급증) -> 절감률")
for sg in (1.0, 1.5, 2.0):
g, b, _, _ = run("hitl", surge=sg, seed=3, audit=Audit())
print(f" x{sg}: {g / b:.1%}")
plt.figure(figsize=(6, 4))
plt.bar(["baseline", "HITL", "auto(ref)"], [base, base - g_h, base - g_a]); plt.ylabel("cost index (24 months)"); plt.title("Quality review HITL simulator")
plt.tight_layout(); plt.show()
In [ ]:
"""10년(120개월)치 대용량 야전 QDR 텍스트를 한꺼번에 주입해 정규화·부위/결함/치명도 라벨링·월별 집계·집중도 경보를 일괄 분석하고, 규모별 처리 시간, 불량 레코드(빈 서술·인코딩 깨짐·과대 길이) 격리와 유실 0건, 대시보드 화면 구성·필터 응답 시간, 급증 이벤트에 대한 경보 정확도를 확인하는 스트레스 시험을 수행한다."""
import re
import time
import unicodedata
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
# ---------------------------------------------------------------------------
# [교육용가상자료] 가상 야전 장비 품질결함보고(QDR) 생성기 (실제 부대·장비·업체·정비 실적 없음)
# 가상 장비 부위 12종, 결함 유형 8종, 치명도(Critical/Major/Minor), 원인 5종, 가상 외주정비 업체 4곳 + 부대정비.
# QDR 1건마다 한국어 서술 텍스트를 만들고, 월별 건수는 계절·추세·훈련 강도(usage)·일부 제조 불량 이벤트를 따른다.
# ---------------------------------------------------------------------------
PARTS = [ # (이름, 별칭, 월 기준 건수, 계절 진폭, 정점 월, (Critical, Major 비율), 가상 단가지수, 제조사, 주요 결함 가중치)
("엔진 연료계통", ["연료계통", "연료 라인", "연료 펌프"], 26, 0.15, 7, (0.10, 0.35), 6.0, "MK1", "누유:4 마모:2 체결불량:2 오작동:2"),
("변속기", ["변속기", "T/M", "미션"], 22, 0.10, 8, (0.12, 0.38), 9.0, "MK1", "마모:4 누유:2 과열:3 오작동:1"),
("제동 장치", ["제동 장치", "브레이크", "제동 라인"], 20, 0.20, 12, (0.26, 0.40), 5.0, "MK2", "마모:4 균열:2 누유:2 체결불량:2"),
("조향 장치", ["조향 장치", "핸들 연동부", "조향 로드"], 14, 0.10, 6, (0.30, 0.40), 5.0, "MK2", "체결불량:4 균열:2 마모:3 오작동:1"),
("냉각 계통", ["냉각 계통", "라디에이터", "냉각수 라인"], 24, 0.30, 7, (0.05, 0.30), 3.0, "MK3", "누유:4 과열:4 부식:2"),
("전기·배터리", ["배터리", "전기 계통", "발전기"], 28, 0.35, 1, (0.04, 0.28), 2.5, "MK3", "접촉불량:4 부식:2 과열:2 오작동:2"),
("통신 모듈", ["통신 모듈", "무전기 연동부", "안테나 접속부"], 16, 0.05, 4, (0.03, 0.25), 4.0, "MK4", "접촉불량:5 오작동:4 부식:1"),
("유압 계통", ["유압 계통", "유압 호스", "유압 실린더"], 18, 0.10, 9, (0.10, 0.35), 4.5, "MK2", "누유:5 균열:2 마모:2 체결불량:1"),
("현가 장치", ["현가 장치", "서스펜션", "완충기"], 15, 0.08, 5, (0.06, 0.30), 3.5, "MK4", "마모:4 균열:3 체결불량:2 부식:1"),
("차체 용접부", ["차체 용접부", "프레임 용접부", "차체 이음부"], 10, 0.05, 10, (0.28, 0.40), 7.0, "MK1", "균열:6 부식:3 체결불량:1"),
("광학·조준 장비", ["광학 장비", "조준경", "관측 장비"], 8, 0.05, 3, (0.03, 0.22), 8.0, "MK4", "오작동:4 접촉불량:3 부식:2 체결불량:1"),
("공기 정화 필터", ["공기 필터", "흡기 필터", "정화 장치"], 12, 0.25, 4, (0.01, 0.12), 1.0, "MK3", "마모:5 부식:2 체결불량:3")]
NP = len(PARTS)
DEFECTS = { # 결함 유형: 증상 표현(동의어)
"균열": ["균열이 발견됨", "크랙이 확인됨", "갈라짐이 있음"], "누유": ["누유가 발생함", "오일이 새어 나옴", "누설 흔적이 있음"],
"마모": ["마모가 심함", "과도한 마모가 확인됨", "닳은 흔적이 있음"], "부식": ["부식이 진행됨", "녹이 발생함", "산화 흔적이 있음"],
"접촉불량": ["접촉 불량이 발생함", "커넥터 단선이 의심됨", "신호가 끊김"], "체결불량": ["볼트 풀림이 확인됨", "체결 상태가 불량함", "고정부가 헐거움"],
"과열": ["과열 경고가 발생함", "온도가 과도하게 상승함", "열이 심하게 남"], "오작동": ["오작동이 반복됨", "작동이 불안정함", "간헐적으로 멈춤"]}
DNAMES = list(DEFECTS)
SEV = ["Minor", "Major", "Critical"]
SEV_CUE = {0: ["경미한 수준임", "운용에는 지장이 없음", "육안 점검상 사소한 이상임", "기능에는 영향이 없음"],
1: ["성능 저하가 있음", "조속한 정비가 필요함", "출력이 떨어지는 상태임", "기능이 부분적으로 제한됨"],
2: ["즉시 운용 중지함", "안전에 위험이 있어 사용 불가", "작동 정지로 임무 수행이 어려움", "인명 안전 위협이 우려됨"]}
CAUSES = ["제조 불량", "정비 품질", "운용 부주의", "설계 취약", "자연 노후"]
CAUSE_CUE = {
"제조 불량": ["신품 인수 직후 발생함", "출고 상태에서 이미 이상이 있었음", "인수 후 얼마 지나지 않아 발생함"],
"정비 품질": ["외주 정비 후 얼마 되지 않아 재발함", "정비 후 재조립 상태가 불량함", "정비 직후 같은 부위에서 다시 발생함"],
"운용 부주의": ["험지 기동 중 충격 이후 발생함", "과부하 운용 후 발생함", "점검 없이 연속 운용한 뒤 발생함"],
"설계 취약": ["동일 증상이 다른 차량에서도 반복됨", "구조적으로 취약한 부위로 보임", "여러 부대에서 같은 형태로 보고됨"],
"자연 노후": ["사용 연한이 도래함", "장기간 사용으로 노후됨", "수명 주기 말기에 발생함"]}
VENDORS = ["외주A", "외주B", "외주C", "외주D", "부대정비"]
ACTIONS = ["정비소 입고를 요청함", "현장 임시 조치 후 운용 중임", "부품 교환을 요청함", "재검사를 요청함", "원인 확인이 필요함"]
MAKER_OF = [p[7] for p in PARTS]
PRICE = np.array([p[6] for p in PARTS])
def usage_index(months, seed=0):
"""월별 훈련 강도(미리 알 수 있는 계획 값): 연 1회 정점을 갖는 계절 + 작은 잡음"""
r = np.random.default_rng(seed + 99)
t = np.arange(months)
return 1 + 0.25 * np.sin(2 * np.pi * (t - 3) / 12) + r.normal(0, 0.04, months)
def make_qdr(months=36, seed=1, scale=1.0, surge=True):
"""반환: QDR DataFrame(건당 1행), 월별 usage, 급증 이벤트 목록"""
rng = np.random.default_rng(seed)
use = usage_index(months, seed)
events = []
if surge:
for _ in range(max(2, months // 12 * 2)):
events.append((int(rng.integers(NP)), int(rng.integers(6, months))))
rows = []
wdef = [np.array([float(x.split(":")[1]) for x in p[8].split()]) for p in PARTS]
ddef = [[x.split(":")[0] for x in p[8].split()] for p in PARTS]
n = 0
for m in range(months):
for pi, p in enumerate(PARTS):
lam = p[2] * scale * (1 + 0.010 * m) * use[m] * (1 + p[3] * np.cos(2 * np.pi * (m % 12 - p[4]) / 12))
ev = (pi, m) in events
k = rng.poisson(lam * (2.6 if ev else 1.0))
for _ in range(k):
rows.append((n, m, pi, ev)); n += 1
N = len(rows)
A = np.array(rows)
month, part, ev = A[:, 1], A[:, 2], A[:, 3].astype(bool)
defect = np.array([rng.choice(ddef[i], p=wdef[i] / wdef[i].sum()) for i in part])
cr = np.array([PARTS[i][5][0] for i in part]); ma = np.array([PARTS[i][5][1] for i in part])
u = rng.random(N)
sev = np.where(u < cr, 2, np.where(u < cr + ma, 1, 0))
vendor = rng.choice(VENDORS, N, p=[0.25, 0.2, 0.2, 0.15, 0.2])
# 원인: 기본 비율에 부위(제조사)·업체·이벤트 효과를 곱한다
base = np.array([0.22, 0.20, 0.20, 0.16, 0.22])
P = np.tile(base, (N, 1))
P[vendor == "외주B", 1] *= 2.6
P[ev, 0] *= 5.0
design = np.isin(part, [2, 3, 7]); P[design, 3] *= 2.2
P[np.isin(part, [4, 5, 11]), 0] *= 2.0 # 제조사 MK3 납품 부위는 제조 불량 비중이 높다
P[defect == "마모", 4] *= 1.6; P[defect == "누유", 4] *= 1.3
P /= P.sum(axis=1, keepdims=True)
cause = np.array([CAUSES[rng.choice(5, p=P[i])] for i in range(N)])
since = np.where(cause == "정비 품질", rng.exponential(35, N), rng.exponential(160, N)).clip(1, 720).round().astype(int)
wait = (rng.lognormal(1.3, 0.5, N) * np.where(vendor == "외주B", 1.7, np.where(vendor == "부대정비", 0.6, 1.0)) * (0.7 + 0.5 * use[month])).round(1)
reins = rng.random(N) < np.where(cause == "정비 품질", 0.6, 0.08)
texts = []
for i in range(N):
pt = PARTS[part[i]]
cl = [f"{rng.choice(pt[1])}에서 {rng.choice(DEFECTS[defect[i]])}"]
if rng.random() < 0.7: cl.append(rng.choice(SEV_CUE[sev[i]]))
if rng.random() < 0.65: # 원인 단서: 8%는 실제 원인과 다른 단서가 적힌다(보고자의 추정)
cc = cause[i] if rng.random() > 0.08 else rng.choice(CAUSES)
cl.append(rng.choice(CAUSE_CUE[cc]))
cl.append(rng.choice(ACTIONS))
if rng.random() < 0.5: cl.insert(0, f"VU-{rng.integers(1, 31):02d} 소속")
texts.append(". ".join(cl) + ".")
df = pd.DataFrame({"qdr_id": [f"Q{seed}{i:06d}" for i in range(N)], "month": month, "unit": [f"VU-{x:02d}" for x in rng.integers(1, 31, N)],
"part": part, "defect": defect, "sev": sev, "cause": cause, "vendor": vendor, "maker": [MAKER_OF[i] for i in part],
"since_maint": since, "inspect_wait": wait, "reinspect": reins, "event": ev, "text": texts})
df["cost"] = PRICE[df["part"]] * np.array([1.0, 2.0, 4.0])[df["sev"]] # [교육용가상자료] 수리·조치 비용지수
return df, use, events
# ---------------------------------------------------------------------------
# 공통 텍스트 조각: 정규화, 간이 토큰화(조사 제거), 부위·결함·치명도·원인 키워드 라벨링
# ---------------------------------------------------------------------------
TYPO = {"누윳": "누유", "균렬": "균열", "접축 불량": "접촉 불량", "부씩": "부식", "마묘": "마모", "과렬": "과열", "오작똥": "오작동", "재동": "제동"}
ABBR = {"T/M": "변속기", "BAT": "배터리"}
PART_KW = sorted([(a, i) for i, p in enumerate(PARTS) for a in p[1]], key=lambda x: -len(x[0]))
DEF_KW = {"균열": ["균열", "크랙", "갈라"], "누유": ["누유", "새어", "누설"], "마모": ["마모", "닳은"], "부식": ["부식", "녹이", "산화"],
"접촉불량": ["접촉 불량", "단선", "끊김"], "체결불량": ["볼트", "체결", "헐거"], "과열": ["과열", "온도", "열이"], "오작동": ["오작동", "불안정", "멈춤"]}
SEV_KW = [(2, ["즉시 운용 중지", "사용 불가", "위험"]), (1, ["성능 저하", "조속한"]), (0, ["경미", "지장이 없"])]
CAUSE_KW = {"제조 불량": ["신품", "출고", "인수"], "정비 품질": ["정비 후", "재조립", "정비 직후"], "운용 부주의": ["충격", "과부하", "점검 없이"],
"설계 취약": ["동일 증상", "구조적", "여러 부대"], "자연 노후": ["사용 연한", "노후", "수명"]}
PARTICLES = ("에서", "으로", "이", "가", "은", "는", "을", "를", "에", "의", "로", "와", "과", "도", "함", "음", "됨")
def norm(t):
"""정규화: 전각->반각, 특수문자·중복 공백 제거, 약어 확장, 오타 교정"""
t = unicodedata.normalize("NFKC", str(t))
t = re.sub(r"[!#~*@^]+", " ", t)
for a, b in ABBR.items():
t = t.replace(a, b)
for a, b in TYPO.items():
t = t.replace(a, b)
return re.sub(r"\s+", " ", t).strip()
def tok(t):
out = []
for w in re.split(r"[\s.,]+", norm(t)):
if not w or re.fullmatch(r"[\d\-A-Za-z]+", w):
continue
for p in PARTICLES:
if len(w) > len(p) + 1 and w.endswith(p):
w = w[:-len(p)]
break
out.append(w)
return out
def label_part(t):
for a, i in PART_KW:
if a in t:
return i
return -1
def label_defect(t):
for d, ks in DEF_KW.items():
if any(k in t for k in ks):
return d
return "미분류"
def label_sev(t):
for s, ks in SEV_KW:
if any(k in t for k in ks):
return s
return -1
def label_cause(t):
for c, ks in CAUSE_KW.items():
if any(k in t for k in ks):
return c
return "미상"
names = [p[0] for p in PARTS]
t0 = time.perf_counter()
full, use, events = make_qdr(120, seed=3, scale=1.5)
print(f"[교육용가상자료] 10년(120개월) QDR {len(full)}건 생성({time.perf_counter() - t0:.1f}초, 분석 시간에는 포함하지 않음), 부위 {NP}종, 급증 이벤트 {len(events)}건")
rng = np.random.default_rng(0)
RE_SPEC, RE_SPACE = re.compile(r"[!#~*@^]+"), re.compile(r"\s+")
PART_RE = [(re.compile("|".join(map(re.escape, p[1]))), i) for i, p in enumerate(PARTS)]
DEF_RE = [(d, re.compile("|".join(ks))) for d, ks in DEF_KW.items()]
def analyze(d):
"""일괄 분석: 검증(격리) -> 정규화 -> 부위·결함·치명도 라벨링. 반환: (정상 결과, 격리 건수)"""
ok = d["text"].str.len().between(5, 400) & ~d["text"].str.contains("�", regex=False)
g = d[ok].copy()
g["t"] = g["text"].str.replace(RE_SPEC, " ", regex=True).str.replace(RE_SPACE, " ", regex=True).str.strip()
g["p_lab"] = -1
for rx, i in PART_RE:
g.loc[(g["p_lab"] == -1) & g["t"].str.contains(rx), "p_lab"] = i
g["d_lab"] = "미분류"
for dn, rx in DEF_RE:
g.loc[(g["d_lab"] == "미분류") & g["t"].str.contains(rx), "d_lab"] = dn
g["s_lab"] = np.where(g["t"].str.contains("즉시 운용 중지|사용 불가|위험|임무 수행이 어려움|위협"), 2, np.where(g["t"].str.contains("성능 저하|조속한|출력이|부분적으로"), 1, 0))
return g, int((~ok).sum())
def alarms(g, months):
"""월·부위 집계 후 직전 12개월 발생률(훈련 강도 보정) 대비 급증 경보"""
M = g.pivot_table(index="p_lab", columns="month", values="qdr_id", aggfunc="size", fill_value=0).reindex(index=range(NP), columns=range(months), fill_value=0)
rate = (M / use[:months]).T.rolling(12, min_periods=6).mean().shift(1).T
exp = rate * use[:months]
z = (M - exp) / np.sqrt(exp.clip(lower=1))
return M, (M / exp.clip(lower=1) > 1.9) & (z > 4)
def make_input(n_months, bad=0.01):
d = full[full["month"] < n_months].copy()
k = int(len(d) * bad)
idx = rng.choice(len(d), k, replace=False)
d.iloc[idx[: k // 3], d.columns.get_loc("text")] = ""
d.iloc[idx[k // 3: 2 * k // 3], d.columns.get_loc("text")] = "��� 오류"
d.iloc[idx[2 * k // 3:], d.columns.get_loc("text")] = "가" * 900
return d, k
print("\n[기간별 일괄 분석] 기간 | 입력 | 정상 | 격리 | 유실 | 분석 시간 | 처리량")
res = {}
for months in (12, 36, 72, 120):
d, k = make_input(months)
t = time.perf_counter()
g, q = analyze(d)
M, A = alarms(g, months)
sec = time.perf_counter() - t
res[months] = (g, M, A, sec)
print(f" {months // 12:2d}년 {len(d):7d} {len(g):7d} {q:5d} {len(d) - len(g) - q:3d} {sec:6.2f}초 {len(d) / sec:8.0f}건/초")
g, M, A, sec = res[120]
# 라벨링 품질과 경보 정확도
tr_ = full.set_index("qdr_id").loc[g["qdr_id"]]
print(f"\n라벨링 정확도(10년치): 부위 {(g['p_lab'].values == tr_['part'].values).mean():.1%}, 결함 유형 {(g['d_lab'].values == tr_['defect'].values).mean():.1%}, 치명도 {(g['s_lab'].values == tr_['sev'].values).mean():.1%} (단서 없는 건은 Minor 처리)")
truth = np.zeros((NP, 120), bool)
for p, m in events:
truth[p, m] = True
valid = ~np.isnan(A.values.astype(float)) & (np.arange(120) >= 12)[None, :]
al = A.values & valid
tp, fp, fn = (al & truth).sum(), (al & ~truth).sum(), (~al & truth & valid).sum()
acc = ((al == truth) & valid).sum() / valid.sum()
print(f"급증 경보(12개월 이후 {int(valid.sum())}개 부위-월 셀): 경보 정확도 {acc:.1%} (목표 95% 이상: {'달성' if acc >= 0.95 else '미달'}), 재현율 {tp / max(tp + fn, 1):.0%}, 정밀도 {tp / max(tp + fp, 1):.0%}, 실제 이벤트 {int((truth & valid).sum())}건")
# 대시보드 화면 구성: 10년치 집계표에서 히트맵·상위 5 부위·경보 목록을 만드는 시간, 필터(부위·기간) 응답 시간
t = time.perf_counter()
heat = M.values.reshape(NP, 10, 12).sum(axis=2)
top5 = M.sum(axis=1).sort_values(ascending=False).head(5)
alert_list = [(names[p], m) for p, m in zip(*np.where(al))]
load = time.perf_counter() - t
t = time.perf_counter()
for p in range(NP):
sub = g[(g["p_lab"] == p) & (g["month"] >= 84)]
_ = sub.groupby("d_lab").size()
flt = (time.perf_counter() - t) / NP
print(f"화면 구성(연도별 히트맵·상위 5·경보 {len(alert_list)}건) {load * 1000:.0f}ms, 필터 1회 응답 {flt * 1000:.0f}ms (목표 2초 이내: {'달성' if max(load, flt) <= 2 else '미달'})")
print("상위 5 부위: " + ", ".join(f"{names[p]} {v}" for p, v in top5.items()))
print("운용 기준: 일괄 분석은 야간 배치로 집계표를 갱신하고 화면은 집계표만 읽는다. 처리가 느려지면 최근 36개월만 재계산하고 격리율이 5%를 넘으면 서술 입력 체계를 점검한다.")
plt.figure(figsize=(6, 4))
ms = sorted(res)
plt.plot([m // 12 for m in ms], [res[m][3] for m in ms], marker="o"); plt.xlabel("years of QDR"); plt.ylabel("analysis seconds"); plt.title("Bulk analysis time")
plt.tight_layout(); plt.show()
In [ ]:
"""AAR 기간(12~23개월) QDR의 결함 원인 분류 오류를 원인별로 분석하고, 키워드 기반 텍스트 마이닝 룰셋에 외주 정비 후 경과일·업체·결함 유형 등 보조 규칙(결정 트리로 도출)을 더해 재튜닝한 뒤, 검증 기간(24~35개월)에서 정확도와 오분류 비용 변화를 확인하고 3대 정량 효과 지표(비용 절감, 확인 시간 단축, 제조·정비 책임 건 식별 증가)와 도입 로드맵을 산출한다."""
import re
import unicodedata
import numpy as np
import pandas as pd
from sklearn.tree import DecisionTreeClassifier, export_text
# ---------------------------------------------------------------------------
# [교육용가상자료] 가상 야전 장비 품질결함보고(QDR) 생성기 (실제 부대·장비·업체·정비 실적 없음)
# 가상 장비 부위 12종, 결함 유형 8종, 치명도(Critical/Major/Minor), 원인 5종, 가상 외주정비 업체 4곳 + 부대정비.
# QDR 1건마다 한국어 서술 텍스트를 만들고, 월별 건수는 계절·추세·훈련 강도(usage)·일부 제조 불량 이벤트를 따른다.
# ---------------------------------------------------------------------------
PARTS = [ # (이름, 별칭, 월 기준 건수, 계절 진폭, 정점 월, (Critical, Major 비율), 가상 단가지수, 제조사, 주요 결함 가중치)
("엔진 연료계통", ["연료계통", "연료 라인", "연료 펌프"], 26, 0.15, 7, (0.10, 0.35), 6.0, "MK1", "누유:4 마모:2 체결불량:2 오작동:2"),
("변속기", ["변속기", "T/M", "미션"], 22, 0.10, 8, (0.12, 0.38), 9.0, "MK1", "마모:4 누유:2 과열:3 오작동:1"),
("제동 장치", ["제동 장치", "브레이크", "제동 라인"], 20, 0.20, 12, (0.26, 0.40), 5.0, "MK2", "마모:4 균열:2 누유:2 체결불량:2"),
("조향 장치", ["조향 장치", "핸들 연동부", "조향 로드"], 14, 0.10, 6, (0.30, 0.40), 5.0, "MK2", "체결불량:4 균열:2 마모:3 오작동:1"),
("냉각 계통", ["냉각 계통", "라디에이터", "냉각수 라인"], 24, 0.30, 7, (0.05, 0.30), 3.0, "MK3", "누유:4 과열:4 부식:2"),
("전기·배터리", ["배터리", "전기 계통", "발전기"], 28, 0.35, 1, (0.04, 0.28), 2.5, "MK3", "접촉불량:4 부식:2 과열:2 오작동:2"),
("통신 모듈", ["통신 모듈", "무전기 연동부", "안테나 접속부"], 16, 0.05, 4, (0.03, 0.25), 4.0, "MK4", "접촉불량:5 오작동:4 부식:1"),
("유압 계통", ["유압 계통", "유압 호스", "유압 실린더"], 18, 0.10, 9, (0.10, 0.35), 4.5, "MK2", "누유:5 균열:2 마모:2 체결불량:1"),
("현가 장치", ["현가 장치", "서스펜션", "완충기"], 15, 0.08, 5, (0.06, 0.30), 3.5, "MK4", "마모:4 균열:3 체결불량:2 부식:1"),
("차체 용접부", ["차체 용접부", "프레임 용접부", "차체 이음부"], 10, 0.05, 10, (0.28, 0.40), 7.0, "MK1", "균열:6 부식:3 체결불량:1"),
("광학·조준 장비", ["광학 장비", "조준경", "관측 장비"], 8, 0.05, 3, (0.03, 0.22), 8.0, "MK4", "오작동:4 접촉불량:3 부식:2 체결불량:1"),
("공기 정화 필터", ["공기 필터", "흡기 필터", "정화 장치"], 12, 0.25, 4, (0.01, 0.12), 1.0, "MK3", "마모:5 부식:2 체결불량:3")]
NP = len(PARTS)
DEFECTS = { # 결함 유형: 증상 표현(동의어)
"균열": ["균열이 발견됨", "크랙이 확인됨", "갈라짐이 있음"], "누유": ["누유가 발생함", "오일이 새어 나옴", "누설 흔적이 있음"],
"마모": ["마모가 심함", "과도한 마모가 확인됨", "닳은 흔적이 있음"], "부식": ["부식이 진행됨", "녹이 발생함", "산화 흔적이 있음"],
"접촉불량": ["접촉 불량이 발생함", "커넥터 단선이 의심됨", "신호가 끊김"], "체결불량": ["볼트 풀림이 확인됨", "체결 상태가 불량함", "고정부가 헐거움"],
"과열": ["과열 경고가 발생함", "온도가 과도하게 상승함", "열이 심하게 남"], "오작동": ["오작동이 반복됨", "작동이 불안정함", "간헐적으로 멈춤"]}
DNAMES = list(DEFECTS)
SEV = ["Minor", "Major", "Critical"]
SEV_CUE = {0: ["경미한 수준임", "운용에는 지장이 없음", "육안 점검상 사소한 이상임", "기능에는 영향이 없음"],
1: ["성능 저하가 있음", "조속한 정비가 필요함", "출력이 떨어지는 상태임", "기능이 부분적으로 제한됨"],
2: ["즉시 운용 중지함", "안전에 위험이 있어 사용 불가", "작동 정지로 임무 수행이 어려움", "인명 안전 위협이 우려됨"]}
CAUSES = ["제조 불량", "정비 품질", "운용 부주의", "설계 취약", "자연 노후"]
CAUSE_CUE = {
"제조 불량": ["신품 인수 직후 발생함", "출고 상태에서 이미 이상이 있었음", "인수 후 얼마 지나지 않아 발생함"],
"정비 품질": ["외주 정비 후 얼마 되지 않아 재발함", "정비 후 재조립 상태가 불량함", "정비 직후 같은 부위에서 다시 발생함"],
"운용 부주의": ["험지 기동 중 충격 이후 발생함", "과부하 운용 후 발생함", "점검 없이 연속 운용한 뒤 발생함"],
"설계 취약": ["동일 증상이 다른 차량에서도 반복됨", "구조적으로 취약한 부위로 보임", "여러 부대에서 같은 형태로 보고됨"],
"자연 노후": ["사용 연한이 도래함", "장기간 사용으로 노후됨", "수명 주기 말기에 발생함"]}
VENDORS = ["외주A", "외주B", "외주C", "외주D", "부대정비"]
ACTIONS = ["정비소 입고를 요청함", "현장 임시 조치 후 운용 중임", "부품 교환을 요청함", "재검사를 요청함", "원인 확인이 필요함"]
MAKER_OF = [p[7] for p in PARTS]
PRICE = np.array([p[6] for p in PARTS])
def usage_index(months, seed=0):
"""월별 훈련 강도(미리 알 수 있는 계획 값): 연 1회 정점을 갖는 계절 + 작은 잡음"""
r = np.random.default_rng(seed + 99)
t = np.arange(months)
return 1 + 0.25 * np.sin(2 * np.pi * (t - 3) / 12) + r.normal(0, 0.04, months)
def make_qdr(months=36, seed=1, scale=1.0, surge=True):
"""반환: QDR DataFrame(건당 1행), 월별 usage, 급증 이벤트 목록"""
rng = np.random.default_rng(seed)
use = usage_index(months, seed)
events = []
if surge:
for _ in range(max(2, months // 12 * 2)):
events.append((int(rng.integers(NP)), int(rng.integers(6, months))))
rows = []
wdef = [np.array([float(x.split(":")[1]) for x in p[8].split()]) for p in PARTS]
ddef = [[x.split(":")[0] for x in p[8].split()] for p in PARTS]
n = 0
for m in range(months):
for pi, p in enumerate(PARTS):
lam = p[2] * scale * (1 + 0.010 * m) * use[m] * (1 + p[3] * np.cos(2 * np.pi * (m % 12 - p[4]) / 12))
ev = (pi, m) in events
k = rng.poisson(lam * (2.6 if ev else 1.0))
for _ in range(k):
rows.append((n, m, pi, ev)); n += 1
N = len(rows)
A = np.array(rows)
month, part, ev = A[:, 1], A[:, 2], A[:, 3].astype(bool)
defect = np.array([rng.choice(ddef[i], p=wdef[i] / wdef[i].sum()) for i in part])
cr = np.array([PARTS[i][5][0] for i in part]); ma = np.array([PARTS[i][5][1] for i in part])
u = rng.random(N)
sev = np.where(u < cr, 2, np.where(u < cr + ma, 1, 0))
vendor = rng.choice(VENDORS, N, p=[0.25, 0.2, 0.2, 0.15, 0.2])
# 원인: 기본 비율에 부위(제조사)·업체·이벤트 효과를 곱한다
base = np.array([0.22, 0.20, 0.20, 0.16, 0.22])
P = np.tile(base, (N, 1))
P[vendor == "외주B", 1] *= 2.6
P[ev, 0] *= 5.0
design = np.isin(part, [2, 3, 7]); P[design, 3] *= 2.2
P[np.isin(part, [4, 5, 11]), 0] *= 2.0 # 제조사 MK3 납품 부위는 제조 불량 비중이 높다
P[defect == "마모", 4] *= 1.6; P[defect == "누유", 4] *= 1.3
P /= P.sum(axis=1, keepdims=True)
cause = np.array([CAUSES[rng.choice(5, p=P[i])] for i in range(N)])
since = np.where(cause == "정비 품질", rng.exponential(35, N), rng.exponential(160, N)).clip(1, 720).round().astype(int)
wait = (rng.lognormal(1.3, 0.5, N) * np.where(vendor == "외주B", 1.7, np.where(vendor == "부대정비", 0.6, 1.0)) * (0.7 + 0.5 * use[month])).round(1)
reins = rng.random(N) < np.where(cause == "정비 품질", 0.6, 0.08)
texts = []
for i in range(N):
pt = PARTS[part[i]]
cl = [f"{rng.choice(pt[1])}에서 {rng.choice(DEFECTS[defect[i]])}"]
if rng.random() < 0.7: cl.append(rng.choice(SEV_CUE[sev[i]]))
if rng.random() < 0.65: # 원인 단서: 8%는 실제 원인과 다른 단서가 적힌다(보고자의 추정)
cc = cause[i] if rng.random() > 0.08 else rng.choice(CAUSES)
cl.append(rng.choice(CAUSE_CUE[cc]))
cl.append(rng.choice(ACTIONS))
if rng.random() < 0.5: cl.insert(0, f"VU-{rng.integers(1, 31):02d} 소속")
texts.append(". ".join(cl) + ".")
df = pd.DataFrame({"qdr_id": [f"Q{seed}{i:06d}" for i in range(N)], "month": month, "unit": [f"VU-{x:02d}" for x in rng.integers(1, 31, N)],
"part": part, "defect": defect, "sev": sev, "cause": cause, "vendor": vendor, "maker": [MAKER_OF[i] for i in part],
"since_maint": since, "inspect_wait": wait, "reinspect": reins, "event": ev, "text": texts})
df["cost"] = PRICE[df["part"]] * np.array([1.0, 2.0, 4.0])[df["sev"]] # [교육용가상자료] 수리·조치 비용지수
return df, use, events
# ---------------------------------------------------------------------------
# 공통 텍스트 조각: 정규화, 간이 토큰화(조사 제거), 부위·결함·치명도·원인 키워드 라벨링
# ---------------------------------------------------------------------------
TYPO = {"누윳": "누유", "균렬": "균열", "접축 불량": "접촉 불량", "부씩": "부식", "마묘": "마모", "과렬": "과열", "오작똥": "오작동", "재동": "제동"}
ABBR = {"T/M": "변속기", "BAT": "배터리"}
PART_KW = sorted([(a, i) for i, p in enumerate(PARTS) for a in p[1]], key=lambda x: -len(x[0]))
DEF_KW = {"균열": ["균열", "크랙", "갈라"], "누유": ["누유", "새어", "누설"], "마모": ["마모", "닳은"], "부식": ["부식", "녹이", "산화"],
"접촉불량": ["접촉 불량", "단선", "끊김"], "체결불량": ["볼트", "체결", "헐거"], "과열": ["과열", "온도", "열이"], "오작동": ["오작동", "불안정", "멈춤"]}
SEV_KW = [(2, ["즉시 운용 중지", "사용 불가", "위험"]), (1, ["성능 저하", "조속한"]), (0, ["경미", "지장이 없"])]
CAUSE_KW = {"제조 불량": ["신품", "출고", "인수"], "정비 품질": ["정비 후", "재조립", "정비 직후"], "운용 부주의": ["충격", "과부하", "점검 없이"],
"설계 취약": ["동일 증상", "구조적", "여러 부대"], "자연 노후": ["사용 연한", "노후", "수명"]}
PARTICLES = ("에서", "으로", "이", "가", "은", "는", "을", "를", "에", "의", "로", "와", "과", "도", "함", "음", "됨")
def norm(t):
"""정규화: 전각->반각, 특수문자·중복 공백 제거, 약어 확장, 오타 교정"""
t = unicodedata.normalize("NFKC", str(t))
t = re.sub(r"[!#~*@^]+", " ", t)
for a, b in ABBR.items():
t = t.replace(a, b)
for a, b in TYPO.items():
t = t.replace(a, b)
return re.sub(r"\s+", " ", t).strip()
def tok(t):
out = []
for w in re.split(r"[\s.,]+", norm(t)):
if not w or re.fullmatch(r"[\d\-A-Za-z]+", w):
continue
for p in PARTICLES:
if len(w) > len(p) + 1 and w.endswith(p):
w = w[:-len(p)]
break
out.append(w)
return out
def label_part(t):
for a, i in PART_KW:
if a in t:
return i
return -1
def label_defect(t):
for d, ks in DEF_KW.items():
if any(k in t for k in ks):
return d
return "미분류"
def label_sev(t):
for s, ks in SEV_KW:
if any(k in t for k in ks):
return s
return -1
def label_cause(t):
for c, ks in CAUSE_KW.items():
if any(k in t for k in ks):
return c
return "미상"
df, use, events = make_qdr(36, seed=1, scale=1.5)
df["t"] = df["text"].map(norm)
aar, val = df[(df["month"] >= 12) & (df["month"] < 24)].copy(), df[df["month"] >= 24].copy()
print(f"[교육용가상자료] AAR 기간 {len(aar)}건, 검증 기간 {len(val)}건")
# 1) 기존 룰셋: 서술 속 원인 단서 키워드(첫 일치)
for d in (aar, val):
d["rule0"] = d["t"].map(label_cause)
wrong = aar[aar["rule0"] != aar["cause"]]
no_cue = wrong[wrong["rule0"] == "미상"]
conflict = wrong[wrong["rule0"] != "미상"]
print(f"기존 룰셋 정확도: AAR {1 - len(wrong) / len(aar):.1%}, 검증 {(val['rule0'] == val['cause']).mean():.1%}")
print(f"AAR 오분류 {len(wrong)}건 ({len(wrong) / len(aar):.1%}): 원인 단서 없음(미상) {len(no_cue)}건 ({len(no_cue) / len(wrong):.0%}), 단서와 실제 원인 불일치 {len(conflict)}건 ({len(conflict) / len(wrong):.0%})")
print(" 미상 건의 실제 원인: " + ", ".join(f"{k} {v}" for k, v in no_cue["cause"].value_counts().items()))
c0 = conflict.groupby(["rule0", "cause"]).size().sort_values(ascending=False).head(2)
print(" 불일치 상위: " + ", ".join(f"룰 '{a}' -> 실제 '{b}' {v}건" for (a, b), v in c0.items()))
ex = no_cue[no_cue["cause"] == "정비 품질"].iloc[0]
print(f" 사례: '{ex['text']}' (업체 {ex['vendor']}, 정비 후 {ex['since_maint']}일) -> 룰 미상, 실제 정비 품질")
# 2) 재튜닝: 미상 건에 보조 규칙 적용(AAR 기간 미상 건만으로 결정 트리 학습, 깊이 3)
def meta(d):
X = pd.DataFrame({"since_maint": d["since_maint"], "ext_vendor": (d["vendor"] != "부대정비").astype(int), "vendor_B": (d["vendor"] == "외주B").astype(int),
"wait": d["inspect_wait"], "reinspect": d["reinspect"].astype(int), "sev": d["sev"], "wear": d["defect"].isin(["마모", "부식"]).astype(int),
"leak": (d["defect"] == "누유").astype(int)})
for p_ in (2, 3, 7):
X[f"part{p_}"] = (d["part"] == p_).astype(int)
for p_ in (4, 5, 11):
X[f"part{p_}"] = (d["part"] == p_).astype(int)
return X
tree = DecisionTreeClassifier(max_depth=3, min_samples_leaf=40, random_state=0).fit(meta(no_cue.assign(cause=no_cue["cause"])), no_cue["cause"])
print("\n[재튜닝 보조 규칙(결정 트리, 미상 건 대상)]")
for line in export_text(tree, feature_names=list(meta(no_cue).columns), max_depth=3).splitlines()[:9]:
print(" " + line)
CONF = 0.45
def rule1(d):
out = d["rule0"].copy()
unk = out == "미상"
if unk.any():
pr = tree.predict_proba(meta(d[unk]))
out[unk] = np.where(pr.max(axis=1) >= CONF, tree.classes_[pr.argmax(axis=1)], "미상") # 확신이 낮으면 미상으로 남겨 사람이 확인
return out
for d in (aar, val):
d["rule1"] = rule1(d)
acc = lambda d, c: (d[c] == d["cause"]).mean()
print(f"\n정확도 변화: AAR(튜닝) {acc(aar, 'rule0'):.1%} -> {acc(aar, 'rule1'):.1%}, 검증(미사용) {acc(val, 'rule0'):.1%} -> {acc(val, 'rule1'):.1%}")
for c in ("제조 불량", "정비 품질"):
m = val["cause"] == c
print(f" {c} 재현율(검증): {(val.loc[m, 'rule0'] == c).mean():.0%} -> {(val.loc[m, 'rule1'] == c).mean():.0%}")
# 3) 3대 정량 효과 지표 (검증 12개월, 가정 단가)
REWORK, HOURS = 0.5, 0.8 # [교육용가상자료] 오분류 건당 재작업 비용 = 조치 비용 x 0.5, 미상 건 수작업 원인 확인 0.8시간
cost0, cost1 = (val.loc[val["rule0"] != val["cause"], "cost"].sum() * REWORK), (val.loc[val["rule1"] != val["cause"], "cost"].sum() * REWORK)
h0, h1 = (val["rule0"] == "미상").sum() * HOURS, (val["rule1"] == "미상").sum() * HOURS
ext0, ext1 = ((val["rule0"].isin(["제조 불량", "정비 품질"])) & (val["cause"].isin(["제조 불량", "정비 품질"])) & (val["rule0"] == val["cause"])).sum(), \
((val["rule1"].isin(["제조 불량", "정비 품질"])) & (val["cause"].isin(["제조 불량", "정비 품질"])) & (val["rule1"] == val["cause"])).sum()
print("\n[3대 정량 효과 지표] (검증 12개월, 가정 단가)")
print(f" 1) 비용 절감: 오분류 재작업 비용지수 {cost0:.0f} -> {cost1:.0f} ({1 - cost1 / cost0:.1%} 절감), 연 환산 약 {cost0 - cost1:.0f}")
print(f" 2) 시간 단축: 원인 수작업 확인 {h0:.0f}h -> {h1:.0f}h ({1 - h1 / h0:.1%} 단축, 확신 {CONF:.0%} 미만은 미상으로 남겨 사람이 확인)")
print(f" 3) 제조·정비 책임 건 정확 식별: {ext0}건 -> {ext1}건 (+{ext1 / ext0 - 1:.0%}), 하자 구상·시정 요구 근거 확보 증가")
print("[도입 로드맵] 단기(1~3월): 보조 규칙 적용 + 품질관 확인 병행 / 중기(3~12월): 분기 AAR로 규칙 재학습, 단서 용어 사전 확장 / 장기(1년~): 군수 정보체계 정비 이력과 원인 라벨 환류")
In [ ]:
"""QDR 서술 검증·정제·라벨링, 치명도 분류 모델의 자동 재학습(챔피언·도전자 비교 후 교체), 부위별 취약성 지수와 급증 경보, 재튜닝 규칙의 원인 분류, 하자 구상·설계 변경·외주정비 시정 후보의 품질관 승인 대기 목록까지 야전 QDR 품질 취약점 식별 도구를 한 파일로 통합 실행하고, 자체 점검·운영 문서 완비 점검·품질 담당 장교 브리핑 요약을 출력한다."""
import re
import time
import unicodedata
import numpy as np
import pandas as pd
import scipy.sparse as sp
import matplotlib.pyplot as plt
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.linear_model import LogisticRegression
# ---------------------------------------------------------------------------
# [교육용가상자료] 가상 야전 장비 품질결함보고(QDR) 생성기 (실제 부대·장비·업체·정비 실적 없음)
# 가상 장비 부위 12종, 결함 유형 8종, 치명도(Critical/Major/Minor), 원인 5종, 가상 외주정비 업체 4곳 + 부대정비.
# QDR 1건마다 한국어 서술 텍스트를 만들고, 월별 건수는 계절·추세·훈련 강도(usage)·일부 제조 불량 이벤트를 따른다.
# ---------------------------------------------------------------------------
PARTS = [ # (이름, 별칭, 월 기준 건수, 계절 진폭, 정점 월, (Critical, Major 비율), 가상 단가지수, 제조사, 주요 결함 가중치)
("엔진 연료계통", ["연료계통", "연료 라인", "연료 펌프"], 26, 0.15, 7, (0.10, 0.35), 6.0, "MK1", "누유:4 마모:2 체결불량:2 오작동:2"),
("변속기", ["변속기", "T/M", "미션"], 22, 0.10, 8, (0.12, 0.38), 9.0, "MK1", "마모:4 누유:2 과열:3 오작동:1"),
("제동 장치", ["제동 장치", "브레이크", "제동 라인"], 20, 0.20, 12, (0.26, 0.40), 5.0, "MK2", "마모:4 균열:2 누유:2 체결불량:2"),
("조향 장치", ["조향 장치", "핸들 연동부", "조향 로드"], 14, 0.10, 6, (0.30, 0.40), 5.0, "MK2", "체결불량:4 균열:2 마모:3 오작동:1"),
("냉각 계통", ["냉각 계통", "라디에이터", "냉각수 라인"], 24, 0.30, 7, (0.05, 0.30), 3.0, "MK3", "누유:4 과열:4 부식:2"),
("전기·배터리", ["배터리", "전기 계통", "발전기"], 28, 0.35, 1, (0.04, 0.28), 2.5, "MK3", "접촉불량:4 부식:2 과열:2 오작동:2"),
("통신 모듈", ["통신 모듈", "무전기 연동부", "안테나 접속부"], 16, 0.05, 4, (0.03, 0.25), 4.0, "MK4", "접촉불량:5 오작동:4 부식:1"),
("유압 계통", ["유압 계통", "유압 호스", "유압 실린더"], 18, 0.10, 9, (0.10, 0.35), 4.5, "MK2", "누유:5 균열:2 마모:2 체결불량:1"),
("현가 장치", ["현가 장치", "서스펜션", "완충기"], 15, 0.08, 5, (0.06, 0.30), 3.5, "MK4", "마모:4 균열:3 체결불량:2 부식:1"),
("차체 용접부", ["차체 용접부", "프레임 용접부", "차체 이음부"], 10, 0.05, 10, (0.28, 0.40), 7.0, "MK1", "균열:6 부식:3 체결불량:1"),
("광학·조준 장비", ["광학 장비", "조준경", "관측 장비"], 8, 0.05, 3, (0.03, 0.22), 8.0, "MK4", "오작동:4 접촉불량:3 부식:2 체결불량:1"),
("공기 정화 필터", ["공기 필터", "흡기 필터", "정화 장치"], 12, 0.25, 4, (0.01, 0.12), 1.0, "MK3", "마모:5 부식:2 체결불량:3")]
NP = len(PARTS)
DEFECTS = { # 결함 유형: 증상 표현(동의어)
"균열": ["균열이 발견됨", "크랙이 확인됨", "갈라짐이 있음"], "누유": ["누유가 발생함", "오일이 새어 나옴", "누설 흔적이 있음"],
"마모": ["마모가 심함", "과도한 마모가 확인됨", "닳은 흔적이 있음"], "부식": ["부식이 진행됨", "녹이 발생함", "산화 흔적이 있음"],
"접촉불량": ["접촉 불량이 발생함", "커넥터 단선이 의심됨", "신호가 끊김"], "체결불량": ["볼트 풀림이 확인됨", "체결 상태가 불량함", "고정부가 헐거움"],
"과열": ["과열 경고가 발생함", "온도가 과도하게 상승함", "열이 심하게 남"], "오작동": ["오작동이 반복됨", "작동이 불안정함", "간헐적으로 멈춤"]}
DNAMES = list(DEFECTS)
SEV = ["Minor", "Major", "Critical"]
SEV_CUE = {0: ["경미한 수준임", "운용에는 지장이 없음", "육안 점검상 사소한 이상임", "기능에는 영향이 없음"],
1: ["성능 저하가 있음", "조속한 정비가 필요함", "출력이 떨어지는 상태임", "기능이 부분적으로 제한됨"],
2: ["즉시 운용 중지함", "안전에 위험이 있어 사용 불가", "작동 정지로 임무 수행이 어려움", "인명 안전 위협이 우려됨"]}
CAUSES = ["제조 불량", "정비 품질", "운용 부주의", "설계 취약", "자연 노후"]
CAUSE_CUE = {
"제조 불량": ["신품 인수 직후 발생함", "출고 상태에서 이미 이상이 있었음", "인수 후 얼마 지나지 않아 발생함"],
"정비 품질": ["외주 정비 후 얼마 되지 않아 재발함", "정비 후 재조립 상태가 불량함", "정비 직후 같은 부위에서 다시 발생함"],
"운용 부주의": ["험지 기동 중 충격 이후 발생함", "과부하 운용 후 발생함", "점검 없이 연속 운용한 뒤 발생함"],
"설계 취약": ["동일 증상이 다른 차량에서도 반복됨", "구조적으로 취약한 부위로 보임", "여러 부대에서 같은 형태로 보고됨"],
"자연 노후": ["사용 연한이 도래함", "장기간 사용으로 노후됨", "수명 주기 말기에 발생함"]}
VENDORS = ["외주A", "외주B", "외주C", "외주D", "부대정비"]
ACTIONS = ["정비소 입고를 요청함", "현장 임시 조치 후 운용 중임", "부품 교환을 요청함", "재검사를 요청함", "원인 확인이 필요함"]
MAKER_OF = [p[7] for p in PARTS]
PRICE = np.array([p[6] for p in PARTS])
def usage_index(months, seed=0):
"""월별 훈련 강도(미리 알 수 있는 계획 값): 연 1회 정점을 갖는 계절 + 작은 잡음"""
r = np.random.default_rng(seed + 99)
t = np.arange(months)
return 1 + 0.25 * np.sin(2 * np.pi * (t - 3) / 12) + r.normal(0, 0.04, months)
def make_qdr(months=36, seed=1, scale=1.0, surge=True):
"""반환: QDR DataFrame(건당 1행), 월별 usage, 급증 이벤트 목록"""
rng = np.random.default_rng(seed)
use = usage_index(months, seed)
events = []
if surge:
for _ in range(max(2, months // 12 * 2)):
events.append((int(rng.integers(NP)), int(rng.integers(6, months))))
rows = []
wdef = [np.array([float(x.split(":")[1]) for x in p[8].split()]) for p in PARTS]
ddef = [[x.split(":")[0] for x in p[8].split()] for p in PARTS]
n = 0
for m in range(months):
for pi, p in enumerate(PARTS):
lam = p[2] * scale * (1 + 0.010 * m) * use[m] * (1 + p[3] * np.cos(2 * np.pi * (m % 12 - p[4]) / 12))
ev = (pi, m) in events
k = rng.poisson(lam * (2.6 if ev else 1.0))
for _ in range(k):
rows.append((n, m, pi, ev)); n += 1
N = len(rows)
A = np.array(rows)
month, part, ev = A[:, 1], A[:, 2], A[:, 3].astype(bool)
defect = np.array([rng.choice(ddef[i], p=wdef[i] / wdef[i].sum()) for i in part])
cr = np.array([PARTS[i][5][0] for i in part]); ma = np.array([PARTS[i][5][1] for i in part])
u = rng.random(N)
sev = np.where(u < cr, 2, np.where(u < cr + ma, 1, 0))
vendor = rng.choice(VENDORS, N, p=[0.25, 0.2, 0.2, 0.15, 0.2])
# 원인: 기본 비율에 부위(제조사)·업체·이벤트 효과를 곱한다
base = np.array([0.22, 0.20, 0.20, 0.16, 0.22])
P = np.tile(base, (N, 1))
P[vendor == "외주B", 1] *= 2.6
P[ev, 0] *= 5.0
design = np.isin(part, [2, 3, 7]); P[design, 3] *= 2.2
P[np.isin(part, [4, 5, 11]), 0] *= 2.0 # 제조사 MK3 납품 부위는 제조 불량 비중이 높다
P[defect == "마모", 4] *= 1.6; P[defect == "누유", 4] *= 1.3
P /= P.sum(axis=1, keepdims=True)
cause = np.array([CAUSES[rng.choice(5, p=P[i])] for i in range(N)])
since = np.where(cause == "정비 품질", rng.exponential(35, N), rng.exponential(160, N)).clip(1, 720).round().astype(int)
wait = (rng.lognormal(1.3, 0.5, N) * np.where(vendor == "외주B", 1.7, np.where(vendor == "부대정비", 0.6, 1.0)) * (0.7 + 0.5 * use[month])).round(1)
reins = rng.random(N) < np.where(cause == "정비 품질", 0.6, 0.08)
texts = []
for i in range(N):
pt = PARTS[part[i]]
cl = [f"{rng.choice(pt[1])}에서 {rng.choice(DEFECTS[defect[i]])}"]
if rng.random() < 0.7: cl.append(rng.choice(SEV_CUE[sev[i]]))
if rng.random() < 0.65: # 원인 단서: 8%는 실제 원인과 다른 단서가 적힌다(보고자의 추정)
cc = cause[i] if rng.random() > 0.08 else rng.choice(CAUSES)
cl.append(rng.choice(CAUSE_CUE[cc]))
cl.append(rng.choice(ACTIONS))
if rng.random() < 0.5: cl.insert(0, f"VU-{rng.integers(1, 31):02d} 소속")
texts.append(". ".join(cl) + ".")
df = pd.DataFrame({"qdr_id": [f"Q{seed}{i:06d}" for i in range(N)], "month": month, "unit": [f"VU-{x:02d}" for x in rng.integers(1, 31, N)],
"part": part, "defect": defect, "sev": sev, "cause": cause, "vendor": vendor, "maker": [MAKER_OF[i] for i in part],
"since_maint": since, "inspect_wait": wait, "reinspect": reins, "event": ev, "text": texts})
df["cost"] = PRICE[df["part"]] * np.array([1.0, 2.0, 4.0])[df["sev"]] # [교육용가상자료] 수리·조치 비용지수
return df, use, events
# ---------------------------------------------------------------------------
# 공통 텍스트 조각: 정규화, 간이 토큰화(조사 제거), 부위·결함·치명도·원인 키워드 라벨링
# ---------------------------------------------------------------------------
TYPO = {"누윳": "누유", "균렬": "균열", "접축 불량": "접촉 불량", "부씩": "부식", "마묘": "마모", "과렬": "과열", "오작똥": "오작동", "재동": "제동"}
ABBR = {"T/M": "변속기", "BAT": "배터리"}
PART_KW = sorted([(a, i) for i, p in enumerate(PARTS) for a in p[1]], key=lambda x: -len(x[0]))
DEF_KW = {"균열": ["균열", "크랙", "갈라"], "누유": ["누유", "새어", "누설"], "마모": ["마모", "닳은"], "부식": ["부식", "녹이", "산화"],
"접촉불량": ["접촉 불량", "단선", "끊김"], "체결불량": ["볼트", "체결", "헐거"], "과열": ["과열", "온도", "열이"], "오작동": ["오작동", "불안정", "멈춤"]}
SEV_KW = [(2, ["즉시 운용 중지", "사용 불가", "위험"]), (1, ["성능 저하", "조속한"]), (0, ["경미", "지장이 없"])]
CAUSE_KW = {"제조 불량": ["신품", "출고", "인수"], "정비 품질": ["정비 후", "재조립", "정비 직후"], "운용 부주의": ["충격", "과부하", "점검 없이"],
"설계 취약": ["동일 증상", "구조적", "여러 부대"], "자연 노후": ["사용 연한", "노후", "수명"]}
PARTICLES = ("에서", "으로", "이", "가", "은", "는", "을", "를", "에", "의", "로", "와", "과", "도", "함", "음", "됨")
def norm(t):
"""정규화: 전각->반각, 특수문자·중복 공백 제거, 약어 확장, 오타 교정"""
t = unicodedata.normalize("NFKC", str(t))
t = re.sub(r"[!#~*@^]+", " ", t)
for a, b in ABBR.items():
t = t.replace(a, b)
for a, b in TYPO.items():
t = t.replace(a, b)
return re.sub(r"\s+", " ", t).strip()
def tok(t):
out = []
for w in re.split(r"[\s.,]+", norm(t)):
if not w or re.fullmatch(r"[\d\-A-Za-z]+", w):
continue
for p in PARTICLES:
if len(w) > len(p) + 1 and w.endswith(p):
w = w[:-len(p)]
break
out.append(w)
return out
def label_part(t):
for a, i in PART_KW:
if a in t:
return i
return -1
def label_defect(t):
for d, ks in DEF_KW.items():
if any(k in t for k in ks):
return d
return "미분류"
def label_sev(t):
for s, ks in SEV_KW:
if any(k in t for k in ks):
return s
return -1
def label_cause(t):
for c, ks in CAUSE_KW.items():
if any(k in t for k in ks):
return c
return "미상"
CONFIG = {"retrain_tolerance": 0.005, "alarm_ratio": 1.9, "alarm_z": 4.0, "since_short": 62, "min_cases": 60, "mfg_share": 0.22, "design_share": 0.20, "vendor_share": 0.28}
OPS_GUIDE = { # docs/qlt05_operations_guide.md 요약
"1. 목적과 범위": "야전 QDR 서술·실적으로 다발 결함 부위와 품질 취약점을 식별하는 보조 도구. 하자 구상·설계 변경 소요 제기의 결정은 품질관.",
"2. 입력 데이터": "QDR(부대·장비·부위·서술·치명도), 정비 수행처와 경과일, 검사 대기·재검사. 가상 식별코드 사용, 실제 부대 자료는 부대 내에서만 처리.",
"3. 일일 운용 절차": "접수 -> 검증·격리 -> 정제·라벨링 -> 치명도 분류 -> 월별 집계·경보 -> 후보 제안(대기) -> 품질관 결정 -> 결과 기록.",
"4. 승인(HITL) 기준": "제조사 하자 구상·설계 변경·외주정비 시정은 품질관 승인 후 확정, 무응답은 조치 보류 후 분기 회의 재상정, 사유 기록.",
"5. 재학습 절차": "분기 1회 신규 QDR로 도전자 학습, 검증 정확도가 나빠지지 않을 때만 교체.",
"6. 장애 대응": "모델 오류 시 키워드 규칙으로 대체, 손상·비정상 서술은 수동 확인 목록으로 분리.",
"7. 연동 인터페이스": "국방 군수 정보체계와 QDR·정비 이력 표준 필드(장비코드·분류군·일시)를 CSV/API로 교환하는 안을 차기 과제로 둔다.",
}
names = [p[0] for p in PARTS]
oh = lambda d: sp.csr_matrix(np.hstack([np.eye(NP)[d["part"]], pd.get_dummies(d["defect"]).reindex(columns=DNAMES, fill_value=0).values.astype(float)]))
def validate(d):
ok = d["text"].str.len().between(5, 400) & ~d["text"].str.contains("�", regex=False)
return d[ok].copy(), d[~ok].copy()
def fit_sev(d):
vec = TfidfVectorizer(tokenizer=tok, lowercase=False, token_pattern=None, min_df=5)
X = sp.hstack([vec.fit_transform(d["text"]), oh(d)]).tocsr()
return vec, LogisticRegression(C=5, max_iter=500).fit(X, d["sev"])
def predict_sev(model, d):
vec, clf = model
return clf.predict(sp.hstack([vec.transform(d["text"]), oh(d)]).tocsr())
def retrain(champion, old, new):
"""도전자(지난달+이번 달 앞 2/3) vs 챔피언(지난달) -> 이번 달 뒤 1/3 검증 정확도 비교, 비슷하거나 나을 때만 교체"""
cut = new["month"].quantile(2 / 3)
hold, new_tr = new[new["month"] > cut], new[new["month"] <= cut]
challenger = fit_sev(pd.concat([old, new_tr], ignore_index=True))
a_old, a_new = (predict_sev(champion, hold) == hold["sev"].values).mean(), (predict_sev(challenger, hold) == hold["sev"].values).mean()
swap = a_new >= a_old - CONFIG["retrain_tolerance"]
return (challenger if swap else champion), {"챔피언": round(float(a_old), 3), "도전자": round(float(a_new), 3), "교체": bool(swap)}
def sev_safe(model, d):
try:
return predict_sev(model, d), "모델"
except Exception:
return d["text"].map(label_sev).replace(-1, 0).values, "키워드 규칙 대체"
def cause_rule(d):
c = d["text"].map(norm).map(label_cause)
short = (c == "미상") & (d["vendor"] == "외주B") & (d["since_maint"] <= CONFIG["since_short"])
return c.where(~short, "정비 품질")
# ---------------- 1. 통합 실행 ----------------
t_start = time.perf_counter()
old, _, _ = make_qdr(36, seed=11, scale=1.5) # 지난달 실적
new, _, _ = make_qdr(36, seed=1, scale=1.5) # 이번 달 실적
ops, use, events = make_qdr(24, seed=21, scale=1.5) # 다음 달 운용(신규 QDR)
ops.iloc[[10, 300], ops.columns.get_loc("text")] = ["", "�� 오류"] # 손상 서술 2건 주입
champion = fit_sev(old)
model, rt = retrain(champion, old, new)
print(f"[교육용가상자료] 재학습: {rt} -> {'도전자 교체' if rt['교체'] else '챔피언 유지'}")
good, bad = validate(ops)
good = good.copy()
good["sev_pred"], src = sev_safe(model, good)
good["cause_pred"] = cause_rule(good)
sec = time.perf_counter() - t_start
acc = (good["sev_pred"] == good["sev"]).mean()
print(f"운용 QDR {len(ops)}건: 분석 {len(good)}건, 수동 확인 {len(bad)}건, 치명도 출처 '{src}', 정확도 {acc:.1%}, 전체 실행 {sec:.1f}초")
# 월별 집계·경보·취약성 지수
M = good.pivot_table(index="part", columns="month", values="qdr_id", aggfunc="size", fill_value=0).reindex(index=range(NP), columns=range(24), fill_value=0)
rate = (M / use[:24]).T.rolling(12, min_periods=12).mean().shift(1).T
exp = rate * use[:24]
Z = (M - exp) / np.sqrt(exp.clip(lower=1))
A = (M / exp.clip(lower=1) > CONFIG["alarm_ratio"]) & (Z > CONFIG["alarm_z"])
W_SEV = np.array([1, 3, 9])
V = good.assign(v=W_SEV[good["sev_pred"]]).groupby("part")["v"].sum()
recent = good[good["month"] >= 21].assign(v=lambda d: W_SEV[d["sev_pred"]]).groupby("part")["v"].sum().sort_values(ascending=False)
# 후보 제안(승인 대기): 최근 12개월 근거
ev = good[good["month"] >= 12]
cands = []
for p, g in ev.groupby("part"):
mf, ds = (g["cause_pred"] == "제조 불량").mean(), (g["cause_pred"] == "설계 취약").mean()
if len(g) >= CONFIG["min_cases"] and mf >= CONFIG["mfg_share"]: cands.append((names[p], "하자 구상", len(g), mf))
if len(g) >= CONFIG["min_cases"] and ds >= CONFIG["design_share"]: cands.append((names[p], "설계 변경", len(g), ds))
for v, g in ev.groupby("vendor"):
sh = (g["cause_pred"] == "정비 품질").mean()
if v != "부대정비" and len(g) >= 100 and sh >= CONFIG["vendor_share"]: cands.append((v, "외주정비 시정", len(g), sh))
C = pd.DataFrame(cands, columns=["대상", "유형", "근거 건수", "원인 비중"])
C["status"] = "PENDING" # 품질관 결정 전까지 확정하지 않는다
# ---------------- 2. 자체 점검 ----------------
_, bad_src = sev_safe(None, good.head(3))
checks = {
"QDR 유실 0건": len(good) + len(bad) == len(ops),
"손상 서술 2건 수동 확인 분리": len(bad) == 2,
"재학습 파이프라인 실행": "교체" in rt,
"치명도 정확도 80% 이상": bool(acc >= 0.80),
"모델 장애 시 키워드 규칙 대체": bad_src == "키워드 규칙 대체",
"경보 셀 모두 12개월 이후": not bool(A.iloc[:, :12].values.any()),
"후보 제안 1건 이상 생성": len(C) >= 1,
"자동 승인 0건(후보는 대기)": bool((C["status"] == "PENDING").all()),
}
print(f"\n자체 점검 {sum(checks.values())}/{len(checks)} 통과, 자동 재학습 파이프라인 {'정상 실행' if checks['재학습 파이프라인 실행'] else '실패'}")
for k, v in checks.items():
if not v:
print(" 실패:", k)
doc = sum(bool(v.strip()) for v in OPS_GUIDE.values()) / len(OPS_GUIDE)
print(f"운영 문서 {len(OPS_GUIDE)}개 장 작성률 {doc:.0%} (목표 100%: {'달성' if doc == 1 else '미달'}) - " + ", ".join(k.split(". ")[1] for k in OPS_GUIDE))
# ---------------- 3. 화면과 브리핑 ----------------
fig, ax = plt.subplots(1, 2, figsize=(11, 4))
ax[0].imshow((M / exp.clip(lower=1)).clip(upper=3).values, aspect="auto", cmap="Reds"); ax[0].set_title("QDR concentration (x expected)"); ax[0].set_xlabel("month"); ax[0].set_ylabel("part")
ax[1].bar(range(NP), V.reindex(range(NP)).fillna(0).values); ax[1].set_title("Vulnerability index by part"); ax[1].set_xlabel("part id")
plt.tight_layout(); plt.show()
fa = [(names[p], int(m)) for p, m in zip(*np.where(A.values))]
print("\n[품질 담당 장교 브리핑 요약] [교육용가상자료]")
print(f"1. 운용 24개월 QDR {len(ops)}건 중 손상 {len(bad)}건 수동 확인, 치명도 Critical {int((good['sev_pred'] == 2).sum())}건({(good['sev_pred'] == 2).mean():.0%})")
print("2. 최근 3개월 취약성 지수 상위 부위: " + ", ".join(f"{names[p]} {v:.0f}" for p, v in recent.head(3).items()))
print(f"3. 월별 급증 경보 {len(fa)}건" + (": " + ", ".join(f"{n}({m}월)" for n, m in fa[:3]) if fa else ""))
print(f"4. 후보 제안 {len(C)}건은 품질관 승인 대기(자동 승인 0건): " + ", ".join(f"{r['대상']} {r['유형']}" for _, r in C.head(4).iterrows()))
vb = good[good["vendor"] == "외주B"]
print(f"5. 외주B 검사 대기 평균 {vb['inspect_wait'].mean():.1f}일(타 수행처 {good.loc[good['vendor'] != '외주B', 'inspect_wait'].mean():.1f}일), 재검사율 {vb['reinspect'].mean():.0%}")
print(f"6. 치명도 모델: {'신규 실적으로 교체' if rt['교체'] else '기존 유지'}(검증 정확도 {max(rt['챔피언'], rt['도전자']):.1%})")
print("7. 건의: 외주정비 업체 시정 요구와 제조사 하자 구상 근거 정리, 국방 군수 정보체계와 QDR 표준 필드 연동 인터페이스 정의")