# QLT05 정답 코드 - 외주정비 품질검사 실적·병목 분석 > [교육용가상자료] 모든 데이터는 코드 안에서 난수로 만든 가상 자료이며 실제 부대·장비·인원·작전 정보와 무관합니다. ## 실행 환경 - Python 3.10 이상, numpy, pandas, scipy, scikit-learn, matplotlib - 미션별 코드는 각각 한 파일로 독립 실행됩니다. 외부 파일 없이 코드 안에서 가상 데이터를 생성합니다. - 그래프의 글자는 한글 글꼴이 없는 실행 환경에서도 깨지지 않도록 영문으로 표기했습니다. 표·출력 결과는 한글입니다. - 난수 시드를 고정해 두어 같은 코드를 실행하면 같은 결과가 나옵니다. ## 미션 1. 야전 장비 품질결함보고(QDR, Quality Deficiency Report) 데이터 스키마 분석 - 난이도: 초급 / 예상 소요시간: 1.5시간 - 코드 설명: 야전 장비 품질결함보고(QDR) 데이터(보고 정보, 결함 서술 텍스트, 외주정비·검사 실적, 조치 결과)의 스키마·데이터 딕셔너리(피처 20개 이상)와 결함 분석 업무 프로세스 맵을 정의하고, 가상 QDR 표본을 검증하며 오류 주입으로 검출 여부를 확인한다. ```python """야전 장비 품질결함보고(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)) ``` 실행 결과 예시 ```text [교육용가상자료] 가상 QDR 9221건 생성, 표본 20건 중 검증 통과 20건, 오류 0건 전체 데이터 부위·치명도·원인 허용값 검증: 통과 [오류 주입 결과] - report.qdr_id: 형식 불일치 'QDR-7' - report.severity: 허용되지 않은 값 'Severe' - text.text: 서술이 너무 짧음 - inspection.vendor: 필수 필드 누락 - inspection.inspect_wait: 자료형 오류(str) - result.cost: 최대값 100 초과 [업무 프로세스 맵] 1.결함 발견 -> 2.QDR 작성 -> 3.접수·검토 -> 4.검사·판정 -> 5.원인 분석 -> 6.조치 -> 7.실적 집계 -> 8.다발 결함 식별 -> 9.사후검토(AAR) 결함 발견 : 발견 부대, 장비, 부위, 서술 텍스트 QDR 작성 : QDR ID, 작성일, 치명도(Critical/Major/Minor) 접수·검토 : 텍스트 정제, 부위·결함 유형 라벨 검사·판정 : 검사 대기일, 재검사 여부, 외주정비 업체 원인 분석 : 원인 구분(제조·정비·운용·설계·노후), 제조사 조치 : 수리·교환·하자 구상 여부, 조치 비용 실적 집계 : 부위별·월별 건수, 취약성 지수 다발 결함 식별 : 파레토(ABC), 이상 신호 사후검토(AAR) : 원인 오분류 사례, 룰 조정 이력 [데이터 딕셔너리] 피처 22개 (목표 20개 이상: 달성) 그룹 필드 자료형 설명 report qdr_id str QDR ID report month int 보고 월(모의 시작 기준 경과 월) report unit str 보고 부대 코드(가상) report equip_code str 장비 식별코드(국방 표준 코드 형식을 모사한 가상 코드) report fsc_group str 보급 분류군(가상 4자리) report part str 결함 부위(장비 계통) report severity str 결함 치명도 text text str 결함 서술 텍스트(비정형) text defect str 결함 유형 라벨 text n_tokens int 정제 후 토큰 수 text part_label str 서술에서 추출한 부위 라벨 inspection vendor str 직전 정비 수행처(외주 업체 또는 부대정비) inspection since_maint int 직전 정비 후 경과일 inspection inspect_wait float 검사 대기일(검사 병목 지표) inspection reinspect bool 재검사 여부 ... (이하 생략) ``` ## 미션 2. 비정형 결함 서술 텍스트 정제, 장비 부위별/결함 유형별 라벨링 전처리 구현 - 난이도: 초급~중급 / 예상 소요시간: 2.0시간 - 코드 설명: 표기 불일치·중복·전각·특수문자·약어·오타·불가능값·결측이 섞인 가상 QDR 원천 자료에 정제 규칙 10종(텍스트 정규화, 약어 확장, 오타 교정, KNN 결측 보정, 부위·결함 유형 키워드 라벨링 포함)을 적용하고, 긴급 보고는 보존하며 정합성 검증 통과율과 라벨링 정확도, 정제 전후 통계를 확인한다. ```python """표기 불일치·중복·전각·특수문자·약어·오타·불가능값·결측이 섞인 가상 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()) ``` 실행 결과 예시 ```text 원천 자료 9371행 (정상 9221행 + 긴급 보고 40건 + 중복 150행, 오류 주입 2480행) [적용 규칙과 처리 건수] R1 중복 QDR 제거 150건 R2 부대 코드 표기 통일 238건 R3 일자 형식 통일 400건 R4 텍스트 정규화(전각·특수문자·공백) 600건 R5 약어 확장(T/M, BAT) 294건 R6 오타 교정(사전 기반) 400건 R7 불가능값(음수 대기일) 결측 처리 80건 R8 대기일·정비후경과일 KNN 보정 474건 R9 부위·결함 유형 키워드 라벨링 9221건 R10 긴급 보고 보존(일반 이상값은 표시만) 40건 정합성 검증 10/10 통과, 통과율 100% (목표 100%: 달성) 라벨링 정확도: 부위 98.5% -> 100.0%, 결함 유형 96.8% -> 100.0% (정제 전 -> 후) 긴급 보고 40건 보존, 일반 이상 표시 1건 [정제 전후 기술통계] 정제 전 정제 후 행 수 9371.0 9221.0 부대 표기 종류 수 98.0 30.0 서술 평균 길이(자) 56.4 56.1 대기일 최소 -1.0 0.0 대기일 결측 수 0.0 0.0 정비후경과일 결측 수 406.0 0.0 ``` ## 미션 3. 빈도 분석 기반 1차 다발 결함 부품 도출 Baseline 알고리즘 구현 - 난이도: 중급 / 예상 소요시간: 2.5시간 - 코드 설명: 빈도 분석 기반 1차 다발 결함 부위 도출 Baseline(학습 기간 건수 상위 5개 부위)의 적중률과 한계(치명도·비용 순위와의 차이)를 확인하고, 탐색적 분석(EDA 차트 8종, 월간 QDR 건수와 피처의 상관계수·p-value, 외주정비 업체별 검사 대기·원인, 건수·비용 파레토 ABC 분류)으로 핵심 관리 대상을 식별한다. ```python """빈도 분석 기반 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는 훈련 강도와 계절에 연동") ``` 실행 결과 예시 ```text [교육용가상자료] QDR 9221건, 부위 12종, 36개월 (학습 24개월 5906건, 검증 12개월 3315건) Baseline 상위 5 부위: 전기·배터리, 엔진 연료계통, 냉각 계통, 제동 장치, 변속기 검증 기간 건수 상위 5와 일치 5/5, 월별 평균 적중률 80% 치명도 가중 취약성 지수 상위 5와 일치 4/5 -> 건수만 보면 놓치는 부위: 조향 장치 Critical 결함 366건 중 Baseline 대상 밖 부위 비중 46% [파레토] 조치 비용 기준(가상 단가지수) 건수 비용지수 누적비율 등급 부위 변속기 558 13464.0 18% A 엔진 연료계통 770 11550.0 33% A 제동 장치 596 10220.0 47% A 조향 장치 397 7155.0 56% A 차체 용접부 277 6776.0 65% A 유압 계통 462 5607.0 73% B 상위 20% 부위(변속기, 엔진 연료계통, 제동 장치)가 전체 조치 비용의 47%, A등급 5종 [상관분석] 피처 | 스피어만 rho | p-value | 유의(p<0.05, |rho|>=0.3) 정비 후 60일 이내 건수 +0.94 0.0000 유의 평균 검사 대기일 +0.83 0.0000 유의 훈련 강도 +0.82 0.0000 유의 계절 요인 +0.78 0.0000 유의 경과 월(추세) +0.39 0.0200 유의 외주B 비중 -0.22 0.1928 - Critical 비중 +0.16 0.3583 - [업체별 검사 실적] 직전 정비 수행처 기준 건수 대기일 재검사율 정비품질원인 vendor 부대정비 1811 3.11 0.18 0.18 외주A 2231 5.00 0.17 0.18 외주B 1916 8.44 0.27 0.36 외주C 1864 5.08 0.16 0.17 외주D 1399 4.98 0.17 0.17 외주B: 대기일 8.4일(타 수행처 4.5일), '정비 품질' 원인 36%(타 18%), 정비 후 60일 이내 재발 49% EDA 차트 8종 생성(목표 8종 이상: 달성), 유의한 피처 5개 (목표 5개 이상: 달성) 핵심 발견: 1) 건수 상위만 보면 조향 장치처럼 치명도 높은 부위를 놓침 2) 외주B는 검사 대기가 길고 정비 후 조기 재발 비중이 높음 3) 월별 QDR는 훈련 강도와 계절에 연동 ``` ## 미션 4. 텍스트 임베딩 및 토픽 모델링 기반 장비 구조적 품질 취약점 자동 군집화 모델 고도화 - 난이도: 중급 / 예상 소요시간: 3.0시간 - 코드 설명: QDR 서술 텍스트를 TF-IDF로 벡터화하고 NMF 토픽 모델링으로 장비 구조적 품질 취약점을 자동 군집화해 부위·결함 라벨과의 일치도(NMI)와 취약 군집(치명도·설계 취약 비중)을 확인하고, 토픽 비중 등 파생 피처 14개로 부위별 월간 QDR 건수 기준 모델을 만들어 시계열 교차검증 MAPE를 평가하고 순열 중요도로 피처를 줄인다. ```python """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%}") ``` 실행 결과 예시 ```text [교육용가상자료] QDR 27675건, 36개월 (토픽 모델은 결함 서술 핵심 문장 '부위에서 증상'에 적용하고, 치명도·원인은 군집 해석에 사용) TF-IDF 어휘 90개, 토픽 36개: 군집-라벨 일치도(NMI) 부위 0.45, 결함 유형 0.60, 부위+결함 0.63 참고: 키워드 규칙 부위 라벨 정확도 100.0% [취약 군집 상위 5] 토픽 | 건수 | Critical 비중 | 주 부위 | 주 결함 | 핵심어 T03 1711 10% 엔진 연료계통 / 누유 [나옴, 새어, 오일, 라디에이터] T24 1631 11% 변속기 / 마모 [닳은, 흔적, 있음, 산화] T28 935 30% 조향 장치 / 체결불량 [조향, 로드, 장치, 크랙] T02 1430 12% 냉각 계통 / 누유 [누유, 발생, 녹이, 라디에이터] T25 1009 18% 조향 장치 / 접촉불량 [연동부, 핸들, 무전기, 접촉] 설계 취약 비중 상위 토픽 8개(구조적 취약 후보): T05, T28, T35, T13, T11, T25, T30, T02 -> 해당 토픽 QDR의 설계 취약 비중 21% (전체 17%) 월간 건수 예측: 파생 피처 14개 (목표 10개 이상: 달성), 대상 8개 부위 x 33개월 시계열 교차검증 폴드별 MAPE: 27.8%, 13.9%, 16.9% -> 평균 19.5% (목표 25% 이하: 달성), 전월값 기준 평균 18.9% 피처 중요도 상위 5: lag1(6.77), usage(1.86), exp_count(1.78), st_lag1(0.81), lag2(0.68) 차원 축소 14개 -> 7개: 검증 MAPE 16.9% -> 15.3% ``` ## 미션 5. 결함 치명도(Critical/Major/Minor) 기반 종합 품질 취약성 지수 산출 엔진 개발 - 난이도: 중급~고급 / 예상 소요시간: 3.5시간 - 코드 설명: QDR 서술·부위·결함 정보로 개별 결함의 치명도(Critical/Major/Minor)를 분류하는 모델(키워드 규칙 기준 대비)과 신뢰도 보정을 확인하고, 치명도 가중 부위별 월간 종합 품질 취약성 지수를 앙상블(릿지에 그래디언트 부스팅·랜덤 포레스트 잔차 보정을 더한 모델)로 예측해 학습 기간 평균 기준 모델과 MAE·RMSE·R2를 비교하는 취약성 지수 산출 엔진을 구현한다. ```python """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") ``` 실행 결과 예시 ```text [교육용가상자료] QDR 51700건 (학습 36개월 36834건, 검증 12개월 14866건) 치명도 분류 정확도: 키워드 규칙 72.0% -> 텍스트 모델 87.2%, Critical 재현율 34.7% -> 69.0% [신뢰도 보정] 구간 | 건수 | 평균 신뢰도 | 실제 정확도 0.00~0.60 2394 0.48 0.46 0.60~0.80 1827 0.68 0.68 0.80~0.95 264 0.86 0.89 0.95~1.00 10381 1.00 1.00 [취약성 지수 예측] 학습 월 12~41, Hold-out 월 42~47 (72건 중 급증 이벤트 월 1건 제외 평가), 선택 GBR depth=3, ridge alpha=10, 잔차 보정 가중치(GBR, RF)=(0.0, 0.0) 모델 | MAE | RMSE | R2 기준(학습 기간 부위별 평균) 63.5 88.5 0.582 전월값(참고) 51.1 74.9 0.700 릿지 단독 39.3 51.8 0.857 고도화(선택 모델) 39.3 51.8 0.857 (참고) 이벤트 월 포함 전체: 기준 MAE 68.6 -> 앙상블 43.9, R2 0.777 개선율 MAE 38.0%, RMSE 41.4% (목표 20% 이상: 달성), R2 0.857 (목표 0.85 이상: 달성) [취약성 지수 카드] 마지막 달 예측 상위 5 부위 | 예측 | 실제 제동 장치 예측 409 실제 412 (우선 점검 권고) 차체 용접부 예측 315 실제 171 엔진 연료계통 예측 304 실제 237 변속기 예측 298 실제 303 조향 장치 예측 272 실제 314 예측 상위 5와 실제 상위 5 일치 3/5 ``` ## 미션 6. 야전 QDR 발생 현황 및 장비 부위별 결함 집중도 히트맵 대시보드 구현 - 난이도: 중급~고급 / 예상 소요시간: 3.0시간 - 코드 설명: 야전 QDR 발생 현황을 부위별·월별 히트맵과 집중도 경보로 구성하고, 치명도 분류 모델의 예측마다 기여도 상위 3개 인자(서술 단어·부위·결함 유형)를 자동 산출하며(근거 유효성 검증), 정비·품질 담당자가 읽을 수 있는 자연어 요약 문장을 생성하는 설명가능성(XAI) 모듈을 구현한다. ```python """야전 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() ``` 실행 결과 예시 ```text [교육용가상자료] QDR 18434건, 검증 6823건 치명도 분류 정확도 87.6% 예측 6823건 중 기여도 상위 3개 인자 산출 6823건 (산출률 100%, 목표 100%: 달성) 근거 유효성: 1순위 인자를 제거하면 예측 확률이 낮아지는 비율 100.0% (평균 0.87 -> 0.80) [점검 카드] 치명도 | 실제 | 설명 예측 Critical (실제 Critical) 엔진 연료계통의 마모 결함, 치명도 Critical 가능성 99%. 주요 근거: '중지', '즉시', '운용'. 기여도: 중지(+2.1) / 즉시(+2.1) / 운용(+0.3) 예측 Major (실제 Major) 엔진 연료계통의 체결불량 결함, 치명도 Major 가능성 99%. 주요 근거: '기능', '부분적', '제한'. 기여도: 기능(+1.3) / 부분적(+1.3) / 제한(+1.3) 예측 Minor (실제 Minor) 엔진 연료계통의 체결불량 결함, 치명도 Minor 가능성 100%. 주요 근거: '없음', '기능에', '영향'. 기여도: 없음(+1.7) / 기능에(+1.4) / 영향(+1.4) [QDR 발생 히트맵] 부위 | 월 30~35 건수 | 학습 월평균 대비 최대 배수 냉각 계통 124 87 99 67 56 38 x2.27 엔진 연료계통 99 89 87 75 58 49 x1.61 전기·배터리 70 58 67 65 71 71 x1.14 제동 장치 53 50 71 55 66 62 x1.50 변속기 67 69 60 58 44 45 x1.42 유압 계통 54 60 59 63 41 42 x1.57 집중도 경보 19건: 차체 용접부(34월, x3.0), 냉각 계통(30월, x2.3), 냉각 계통(32월, x1.8), 조향 장치(31월, x1.7) ``` ## 미션 7. 제조사 하자 구상 및 설계 변경 소요 제기를 위한 품질관 HITL 검토 모듈 설계 - 난이도: 고급 / 예상 소요시간: 3.5시간 - 코드 설명: 최근 12개월 QDR 근거로 부위별 제조사 하자 구상(제조 불량 우세)과 설계 변경 소요 제기(설계 취약 우세) 후보를 제안하되 자동 확정하지 않고 품질관 검토 대기열에 올려 승인·반려·무응답(조치 보류)을 사유와 함께 해시 체인 감사 로그에 남기는 HITL 모듈을 시뮬레이션하고, 개별 사후 수리 대비 비용 절감률과 승인 지연·처리 용량·전시 전환 민감도를 분석한다. ```python """최근 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() ``` 실행 결과 예시 ```text [교육용가상자료] QDR 19316건, 검토 기준 월 24, 후보 제안 13건(하자 구상·설계 변경·외주정비 시정) [비용 비교] 방식 | 이후 24개월 비용지수 | 절감액 개별 사후 수리(기본 계획) 144670 0 제안 + 품질관 승인 122929 21741 제안 전부 적용(비교용) 119586 25084 HITL 적용 비용 절감률 15.0% (목표 15% 이상: 달성), 비교용 전부 적용 17.3% 최종 결정 13건: 승인 12건, 반려 1건, 무응답 0건 제안 13건 중 결정 기록 13건(로깅률 100%), 자동 승인 0건, 무응답 2건은 조치 보류 후 분기 회의 재상정 반려 사유 예: 원인 비중 23%로 평균 수준, 추가 관찰 감사 로그 무결성: 정상, 사후 변조 시 seq 3에서 탐지 [민감도 1] 승인까지 지연(개월) -> 절감률 0개월: 15.9% 1개월: 15.0% 3개월: 13.6% 6개월: 11.4% 9개월: 9.2% [민감도 2] 품질관 처리 용량(승인 가능 건수) -> 절감률 최대 2건: 14.2% 최대 5건: 14.4% 최대 8건: 14.8% 최대 무제한건: 15.0% [민감도 3] 전시 전환 가정(QDR 비용 1.5배 급증) -> 절감률 x1.0: 15.0% x1.5: 15.1% x2.0: 15.2% ``` ## 미션 8. 10년치 대용량 야전 QDR 텍스트 데이터 일괄 분석 스트레스 테스트 - 난이도: 고급 / 예상 소요시간: 3.0시간 - 코드 설명: 10년(120개월)치 대용량 야전 QDR 텍스트를 한꺼번에 주입해 정규화·부위/결함/치명도 라벨링·월별 집계·집중도 경보를 일괄 분석하고, 규모별 처리 시간, 불량 레코드(빈 서술·인코딩 깨짐·과대 길이) 격리와 유실 0건, 대시보드 화면 구성·필터 응답 시간, 급증 이벤트에 대한 경보 정확도를 확인하는 스트레스 시험을 수행한다. ```python """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() ``` 실행 결과 예시 ```text [교육용가상자료] 10년(120개월) QDR 62293건 생성(6.5초, 분석 시간에는 포함하지 않음), 부위 12종, 급증 이벤트 20건 [기간별 일괄 분석] 기간 | 입력 | 정상 | 격리 | 유실 | 분석 시간 | 처리량 1년 4177 4136 41 0 0.14초 30805건/초 3년 14068 13928 140 0 0.36초 39039건/초 6년 32040 31720 320 0 0.77초 41671건/초 10년 62293 61671 622 0 1.78초 34950건/초 라벨링 정확도(10년치): 부위 100.0%, 결함 유형 100.0%, 치명도 87.0% (단서 없는 건은 Minor 처리) 급증 경보(12개월 이후 1296개 부위-월 셀): 경보 정확도 99.8% (목표 95% 이상: 달성), 재현율 90%, 정밀도 100%, 실제 이벤트 20건 화면 구성(연도별 히트맵·상위 5·경보 18건) 1ms, 필터 1회 응답 4ms (목표 2초 이내: 달성) 상위 5 부위: 엔진 연료계통 7848, 전기·배터리 7816, 냉각 계통 7095, 변속기 6301, 제동 장치 5667 운용 기준: 일괄 분석은 야간 배치로 집계표를 갱신하고 화면은 집계표만 읽는다. 처리가 느려지면 최근 36개월만 재계산하고 격리율이 5%를 넘으면 서술 입력 체계를 점검한다. ``` ## 미션 9. AAR 기반 결함 원인 오분류 분석 및 텍스트 마이닝 룰셋 재튜닝 - 난이도: 고급 / 예상 소요시간: 3.0시간 - 코드 설명: AAR 기간(12~23개월) QDR의 결함 원인 분류 오류를 원인별로 분석하고, 키워드 기반 텍스트 마이닝 룰셋에 외주 정비 후 경과일·업체·결함 유형 등 보조 규칙(결정 트리로 도출)을 더해 재튜닝한 뒤, 검증 기간(24~35개월)에서 정확도와 오분류 비용 변화를 확인하고 3대 정량 효과 지표(비용 절감, 확인 시간 단축, 제조·정비 책임 건 식별 증가)와 도입 로드맵을 산출한다. ```python """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년~): 군수 정보체계 정비 이력과 원인 라벨 환류") ``` 실행 결과 예시 ```text [교육용가상자료] AAR 기간 4568건, 검증 기간 5103건 기존 룰셋 정확도: AAR 60.3%, 검증 61.7% AAR 오분류 1815건 (39.7%): 원인 단서 없음(미상) 1617건 (89%), 단서와 실제 원인 불일치 198건 (11%) 미상 건의 실제 원인: 제조 불량 407, 자연 노후 354, 정비 품질 331, 설계 취약 266, 운용 부주의 259 불일치 상위: 룰 '설계 취약' -> 실제 '정비 품질' 15건, 룰 '제조 불량' -> 실제 '정비 품질' 15건 사례: '연료계통에서 누설 흔적이 있음. 재검사를 요청함.' (업체 외주B, 정비 후 6일) -> 룰 미상, 실제 정비 품질 [재튜닝 보조 규칙(결정 트리, 미상 건 대상)] |--- reinspect <= 0.50 | |--- since_maint <= 62.50 | | |--- vendor_B <= 0.50 | | | |--- class: 제조 불량 | | |--- vendor_B > 0.50 | | | |--- class: 정비 품질 | |--- since_maint > 62.50 | | |--- part5 <= 0.50 | | | |--- class: 자연 노후 정확도 변화: AAR(튜닝) 60.3% -> 65.4%, 검증(미사용) 61.7% -> 66.2% 제조 불량 재현율(검증): 63% -> 67% 정비 품질 재현율(검증): 62% -> 79% [3대 정량 효과 지표] (검증 12개월, 가정 단가) 1) 비용 절감: 오분류 재작업 비용지수 8127 -> 7253 (10.8% 절감), 연 환산 약 874 2) 시간 단축: 원인 수작업 확인 1409h -> 1126h (20.1% 단축, 확신 45% 미만은 미상으로 남겨 사람이 확인) 3) 제조·정비 책임 건 정확 식별: 1454건 -> 1685건 (+16%), 하자 구상·시정 요구 근거 확보 증가 [도입 로드맵] 단기(1~3월): 보조 규칙 적용 + 품질관 확인 병행 / 중기(3~12월): 분기 AAR로 규칙 재학습, 단서 용어 사전 확장 / 장기(1년~): 군수 정보체계 정비 이력과 원인 라벨 환류 ``` ## 미션 10. 야전 장비 결함 보고(QDR) 데이터 분석 및 품질 취약점 식별 도구 통합 완성 - 난이도: 최고급 / 예상 소요시간: 3.5시간 - 코드 설명: QDR 서술 검증·정제·라벨링, 치명도 분류 모델의 자동 재학습(챔피언·도전자 비교 후 교체), 부위별 취약성 지수와 급증 경보, 재튜닝 규칙의 원인 분류, 하자 구상·설계 변경·외주정비 시정 후보의 품질관 승인 대기 목록까지 야전 QDR 품질 취약점 식별 도구를 한 파일로 통합 실행하고, 자체 점검·운영 문서 완비 점검·품질 담당 장교 브리핑 요약을 출력한다. ```python """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 표준 필드 연동 인터페이스 정의") ``` 실행 결과 예시 ```text [교육용가상자료] 재학습: {'챔피언': 0.867, '도전자': 0.865, '교체': True} -> 도전자 교체 운용 QDR 8923건: 분석 8921건, 수동 확인 2건, 치명도 출처 '모델', 정확도 87.8%, 전체 실행 7.3초 자체 점검 8/8 통과, 자동 재학습 파이프라인 정상 실행 운영 문서 7개 장 작성률 100% (목표 100%: 달성) - 목적과 범위, 입력 데이터, 일일 운용 절차, 승인(HITL) 기준, 재학습 절차, 장애 대응, 연동 인터페이스 [품질 담당 장교 브리핑 요약] [교육용가상자료] 1. 운용 24개월 QDR 8923건 중 손상 2건 수동 확인, 치명도 Critical 717건(8%) 2. 최근 3개월 취약성 지수 상위 부위: 제동 장치 378, 조향 장치 268, 엔진 연료계통 244 3. 월별 급증 경보 3건: 조향 장치(20월), 냉각 계통(16월), 전기·배터리(18월) 4. 후보 제안 3건은 품질관 승인 대기(자동 승인 0건): 냉각 계통 하자 구상, 전기·배터리 하자 구상, 외주B 외주정비 시정 5. 외주B 검사 대기 평균 8.7일(타 수행처 4.5일), 재검사율 26% 6. 치명도 모델: 신규 실적으로 교체(검증 정확도 86.7%) 7. 건의: 외주정비 업체 시정 요구와 제조사 하자 구상 근거 정리, 국방 군수 정보체계와 QDR 표준 필드 연동 인터페이스 정의 ```