Files
2026-10-08 16:43:48 +09:00

156 KiB

MNT03 정답 코드 - 고장률 분석을 통한 다빈도 고장품목 식별

[교육용가상자료] 모든 데이터는 코드 안에서 난수로 만든 가상 자료이며 실제 부대·장비·인원·작전 정보와 무관합니다.

실행 환경

  • Python 3.10 이상, numpy, pandas, scipy, scikit-learn, matplotlib
  • 미션별 코드는 각각 한 파일로 독립 실행됩니다. 외부 파일 없이 코드 안에서 가상 데이터를 생성합니다.
  • 그래프의 글자는 한글 글꼴이 없는 실행 환경에서도 깨지지 않도록 영문으로 표기했습니다. 표·출력 결과는 한글입니다.
  • 난수 시드를 고정해 두어 같은 코드를 실행하면 같은 결과가 나옵니다.

미션 1. 전차/장갑차 전자제어장치(ECU) 고장 진단 코드(DTC) 및 정비 매뉴얼 데이터셋 정의

  • 난이도: 초급 / 예상 소요시간: 1.5시간
  • 코드 설명: 전차/장갑차 계열 가상 차량의 전자제어장치(ECU) 고장 진단 코드(DTC) 발생 로그, 정비 이력, 정비 매뉴얼 데이터셋의 스키마·데이터 딕셔너리(피처 20개 이상)와 고장률 분석 업무 프로세스 맵을 정의하고, 가상 데이터로 검증한다.
"""전차/장갑차 계열 가상 차량의 전자제어장치(ECU) 고장 진단 코드(DTC) 발생 로그, 정비 이력, 정비 매뉴얼 데이터셋의 스키마·데이터 딕셔너리(피처 20개 이상)와 고장률 분석 업무 프로세스 맵을 정의하고, 가상 데이터로 검증한다."""
import json
import re
import numpy as np
import pandas as pd

# ---------------------------------------------------------------------------
# [교육용가상자료] 가상 차량 100대의 18개월 고장 진단 코드(DTC)·정비 이력 생성기 (실제 부대·장비·정비 실적 없음)
# 부품 16종(1000가동시간당 고장률 차등), DTC 28종(부품 고유 코드, 짝 부품 공유 코드, 계통 증상 코드)
# 고장 1건(정비 이력)마다 DTC가 1~5개 동시 발생하고, 연쇄 고장 코드와 일시 잡음 코드가 섞인다.
# ---------------------------------------------------------------------------
PARTS = [  # (ID, 이름, 계통, 1000가동시간당 고장률, 가상 단가지수, 수리시간 h)
    ("P01", "연료분사기", "engine", 12.0, 3.0, 2.0), ("P02", "터보차저", "engine", 9.0, 8.0, 5.0),
    ("P03", "축전지", "electric", 6.5, 4.0, 1.5), ("P04", "발전기", "electric", 4.0, 9.0, 3.0),
    ("P05", "변속기 제어모듈", "drive", 3.0, 12.0, 6.0), ("P06", "냉각수 펌프", "engine", 2.2, 3.5, 3.0),
    ("P07", "유압 펌프", "hydraulic", 1.6, 10.0, 4.0), ("P08", "속도 센서", "drive", 1.2, 2.0, 1.5),
    ("P09", "ECU 커넥터", "electric", 0.9, 2.5, 1.5), ("P10", "연료 필터", "engine", 0.7, 0.8, 0.7),
    ("P11", "제동 밸브", "brake", 0.5, 6.0, 3.0), ("P12", "조향 액추에이터", "drive", 0.4, 9.0, 5.0),
    ("P13", "공기 필터", "engine", 0.3, 0.5, 0.5), ("P14", "통신 인터페이스 모듈", "electric", 0.25, 3.0, 1.5),
    ("P15", "윤활유 압력 센서", "engine", 0.2, 1.0, 1.0), ("P16", "팬 구동 모터", "cooling", 0.15, 2.0, 2.0)]
PID = [p[0] for p in PARTS]
PNAME = {p[0]: p[1] for p in PARTS}; PSYS = {p[0]: p[2] for p in PARTS}; RATE = {p[0]: p[3] for p in PARTS}
COST = {p[0]: p[4] for p in PARTS}; REPH = {p[0]: p[5] for p in PARTS}
PAIRS = [("P01", "P02"), ("P03", "P04"), ("P05", "P08"), ("P06", "P10"), ("P09", "P14"), ("P13", "P15")]
SHARED = {p: f"S{k + 1:02d}" for k, pr in enumerate(PAIRS) for p in pr}     # 짝 부품이 함께 쓰는 모호한 코드
SPEC = {p: f"C{p[1:]}1" for p in PID}                                          # 부품 고유 코드
SYS = ["engine", "electric", "drive", "hydraulic", "brake", "cooling"]
SYMP = {s: f"G{i + 1:02d}" for i, s in enumerate(SYS)}                         # 계통 증상 코드
CODES = sorted(set(SPEC.values()) | set(SHARED.values()) | set(SYMP.values()))
CASCADE = {"P01": [("P10", 0.20)], "P02": [("P06", 0.20)], "P03": [("P04", 0.25)], "P04": [("P09", 0.25)],
           "P05": [("P08", 0.30)], "P06": [("P16", 0.30)], "P09": [("P14", 0.20)], "P12": [("P07", 0.20)]}
CRIT_SYS = {"engine", "drive", "brake"}        # 기동·제동 불능 위험 계통(경보 대상)
MONTHS = 18


def make_fleet(n=100, seed=0):
    rng = np.random.default_rng(seed)
    f = pd.DataFrame({"veh": [f"V{i:03d}" for i in range(n)], "age": rng.integers(1, 26, n).astype(float),
                      "terrain": rng.uniform(0, 1, n), "load": rng.uniform(0, 1, n), "pm_gap": rng.uniform(20, 200, n),
                      "humid": rng.uniform(0, 1, n), "base_h": rng.uniform(55, 125, n)})
    f["mult"] = np.exp(0.045 * (f["age"] - 13) + 0.9 * (f["terrain"] - .5) + 0.8 * (f["load"] - .5)
                       + 0.006 * (f["pm_gap"] - 110) + 0.7 * (f["humid"] - .5))
    return f


def make_data(seed=1, months=MONTHS):
    """반환: fleet(차량 속성), ev(정비 이력 1건=1행: 발생 DTC 목록, 실제 고장 부품), log(DTC 발생 로그, 행=코드 1건)"""
    rng = np.random.default_rng(seed)
    fleet = make_fleet()
    V = len(fleet)
    temp = 12 + 14 * np.sin(2 * np.pi * (np.arange(months) - 3) / 12)            # 월평균 기온(가상)
    hours = fleet["base_h"].values[:, None] * (1 + 0.25 * np.sin(2 * np.pi * np.arange(months) / 12 + 1)) * rng.lognormal(0, .12, (V, months))
    fleet["hours"] = hours.sum(axis=1)                                           # 차량별 18개월 총 가동시간
    fleet.attrs["month_h"] = hours.sum(axis=0)                                   # 월별 전체 가동시간(고장률 분모)
    evs = []
    for p in PID:
        s = np.ones(months)
        if p in ("P03", "P04"): s = np.exp(-0.06 * (temp - 12))                   # 한랭기 전기계통 증가
        if p in ("P06", "P16"): s = np.exp(0.05 * (temp - 12))                    # 혹서기 냉각계통 증가
        cnt = rng.poisson(RATE[p] * hours / 1000 * fleet["mult"].values[:, None] * s[None, :])
        for v, m in zip(*np.nonzero(cnt)):
            for _ in range(cnt[v, m]):
                evs.append((fleet["veh"][v], int(m), m * 720 + rng.uniform(0, 720), p, float(temp[m] + rng.normal(0, 3))))
    ev = pd.DataFrame(evs, columns=["veh", "month", "t", "part", "temp"]).sort_values("t").reset_index(drop=True)
    ev["ev_id"] = [f"W{i:05d}" for i in range(len(ev))]
    rows, codes = [], []
    for e in ev.itertuples():
        c, r = [], rng.random(3)
        if r[0] < 0.6: c.append(SPEC[e.part])
        if e.part in SHARED and r[1] < 0.6: c.append(SHARED[e.part])
        if r[2] < 0.5: c.append(SYMP[PSYS[e.part]])
        if not c: c.append(SHARED.get(e.part, SPEC[e.part]))
        for q, pr in CASCADE.get(e.part, []):
            if rng.random() < pr: c.append(SPEC[q])
        if rng.random() < 0.12: c.append(SPEC[PID[rng.integers(0, 16)]])
        c = list(dict.fromkeys(c))
        rng.shuffle(c)                                                           # 동시 발생 코드의 기록 순서는 임의
        codes.append(c)
        for k, cd in enumerate(c):
            rows.append((e.ev_id, e.veh, e.t + k * 0.1, cd))
    ev["codes"] = codes
    log = pd.DataFrame(rows, columns=["ev_id", "veh", "ts", "code"])
    return fleet, ev, log

# 1) 업무 프로세스 맵: DTC 발생부터 고장률 집계·환류까지 (각 단계에서 생기는 데이터)
PROCESS = [("DTC 발생", "차량 ID, 발생 시각, DTC 코드, 가동시간 계기값"), ("로그 수집·정제", "중복 제거, 코드·시각 표기 통일"),
           ("동시 발생 묶음", "같은 정비 건에서 발생한 DTC 세트(정규화)"), ("고장 진단", "추정 고장 부품, 신뢰도"),
           ("정비 조치", "정비 매뉴얼 절차, 수리시간, 교체 부품"), ("정비 이력 연결", "정비 건 ID로 DTC와 실제 고장 부품 연결"),
           ("고장률 집계", "부품별 1000가동시간당 고장 건수"), ("다빈도 품목 식별", "파레토(ABC) 등급, 관리 대상 선정"),
           ("사후검토(AAR)", "오진단 사례, 규칙 조정 이력")]

# 2) 스키마(데이터 딕셔너리): 그룹 / 필드 / 자료형 / 규칙 / 설명
S = {
    "dtc_log": {
        "ev_id": (str, {"pattern": r"^W\d{5}$"}, "정비 건 ID(DTC와 정비 이력을 잇는 키)"),
        "veh_id": (str, {"pattern": r"^V\d{3}$"}, "차량 ID(가상)"),
        "ts": (float, {"min": 0, "max": 13000}, "DTC 발생 시각(모의 시작 후 경과 시간, h)"),
        "code": (str, {"enum": CODES}, "DTC 코드(고유·공유·계통 증상 28종)"),
        "co_count": (int, {"min": 1, "max": 8}, "같은 정비 건에서 동시 발생한 DTC 수"),
    },
    "work_order": {
        "part_id": (str, {"enum": PID}, "실제 고장 부품(정비 이력) - 예측대상 1"),
        "system": (str, {"enum": SYS}, "고장 계통"),
        "repair_h": (float, {"min": 0.1, "max": 24}, "수리 소요시간(h)"),
        "unit_cost": (float, {"min": 0, "max": 50}, "부품 단가지수(가상)"),
        "unplanned": (bool, {}, "비계획·긴급 정비 여부"),
        "month": (int, {"min": 0, "max": 17}, "정비 월(모의 시작 기준)"),
    },
    "vehicle": {
        "age": (float, {"min": 0, "max": 40}, "차령(년)"),
        "op_hours": (float, {"min": 0, "max": 400}, "월 가동시간(h)"),
        "pm_gap": (float, {"min": 0, "max": 500}, "마지막 예방정비 후 경과 가동시간(h)"),
        "terrain": (float, {"min": 0, "max": 1}, "운용 지형 가혹도(0~1)"),
        "load": (float, {"min": 0, "max": 1}, "운용 부하 수준(0~1)"),
        "temp": (float, {"min": -40, "max": 60}, "정비 시점 기온(도)"),
    },
    "manual": {
        "code_ref": (str, {"enum": CODES}, "매뉴얼 기준 DTC 코드"),
        "cand_parts": (list, {}, "코드별 후보 고장 부품 목록"),
        "step_no": (int, {"min": 1, "max": 20}, "정비 조치 절차 단계 번호"),
        "safety_flag": (bool, {}, "안전 주의 항목 포함 여부"),
    },
    "target": {
        "fail_rate": (float, {"min": 0, "max": 100}, "부품별 1000가동시간당 고장률 - 예측대상 2"),
        "confidence": (float, {"min": 0, "max": 1}, "진단 추천 신뢰도 점수 - 예측대상 3"),
    },
}


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 "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건 추출해 레코드 형태로 만들고 검증
fleet, ev, log = make_data()
f = fleet.set_index("veh")
recs = []
for e in ev.sample(20, random_state=0).itertuples():
    first = log[log["ev_id"] == e.ev_id].iloc[0]
    cs = e.codes
    recs.append({
        "dtc_log": {"ev_id": e.ev_id, "veh_id": e.veh, "ts": float(round(first["ts"], 1)), "code": cs[0], "co_count": len(cs)},
        "work_order": {"part_id": e.part, "system": PSYS[e.part], "repair_h": float(REPH[e.part]), "unit_cost": float(COST[e.part]),
                       "unplanned": bool(len(cs) >= 3), "month": int(e.month)},
        "vehicle": {"age": float(f.loc[e.veh, "age"]), "op_hours": float(round(f.loc[e.veh, "base_h"], 1)), "pm_gap": float(round(f.loc[e.veh, "pm_gap"], 1)),
                    "terrain": float(round(f.loc[e.veh, "terrain"], 2)), "load": float(round(f.loc[e.veh, "load"], 2)), "temp": float(round(e.temp, 1))},
        "manual": {"code_ref": cs[0], "cand_parts": [p for p in PID if cs[0] in (SPEC[p], SHARED.get(p))] or [e.part], "step_no": 1,
                   "safety_flag": PSYS[e.part] in CRIT_SYS},
        "target": {"fail_rate": float(RATE[e.part]), "confidence": 0.8}})
ok = sum(not validate(r) for r in recs)
print(f"가상 데이터 {len(ev)}건(DTC 로그 {len(log)}행) 생성, 표본 {len(recs)}건 중 검증 통과 {ok}건, 오류 {len(recs) - ok}건")
print(f"전체 DTC 로그 코드 허용값 검증: {'통과' if log['code'].isin(CODES).all() else '실패'}")

# 4) 오류 주입 시험
bad = json.loads(json.dumps(recs[0]))
bad["dtc_log"]["code"] = "C999"                  # 정의되지 않은 DTC 코드
bad["dtc_log"]["veh_id"] = "TANK-7"              # ID 형식 오류
bad["vehicle"]["load"] = 1.8                     # 범위 초과
del bad["work_order"]["repair_h"]                # 필수 필드 누락
bad["target"]["confidence"] = "높음"             # 자료형 오류
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__, d) for g, fl in S.items() for k, (t, r, d) in fl.items()]
dd = pd.DataFrame(rows, columns=["그룹", "필드", "자료형", "설명"])
print(f"\n[데이터 딕셔너리] 피처 {len(dd)}개 (목표 20개 이상: {'달성' if len(dd) >= 20 else '미달'})")
print(dd.to_string(index=False))

실행 결과 예시

가상 데이터 10681건(DTC 로그 22079행) 생성, 표본 20건 중 검증 통과 20건, 오류 0건
전체 DTC 로그 코드 허용값 검증: 통과

[오류 주입 결과]
 - dtc_log.veh_id: 형식 불일치 'TANK-7'
 - dtc_log.code: 허용되지 않은 값 'C999'
 - work_order.repair_h: 필수 필드 누락
 - vehicle.load: 최대값 1 초과
 - target.confidence: 자료형 오류(str)

[업무 프로세스 맵]
1.DTC 발생 -> 2.로그 수집·정제 -> 3.동시 발생 묶음 -> 4.고장 진단 -> 5.정비 조치 -> 6.정비 이력 연결 -> 7.고장률 집계 -> 8.다빈도 품목 식별 -> 9.사후검토(AAR)
   DTC 발생    : 차량 ID, 발생 시각, DTC 코드, 가동시간 계기값
   로그 수집·정제  : 중복 제거, 코드·시각 표기 통일
   동시 발생 묶음  : 같은 정비 건에서 발생한 DTC 세트(정규화)
   고장 진단     : 추정 고장 부품, 신뢰도
   정비 조치     : 정비 매뉴얼 절차, 수리시간, 교체 부품
   정비 이력 연결  : 정비 건 ID로 DTC와 실제 고장 부품 연결
   고장률 집계    : 부품별 1000가동시간당 고장 건수
   다빈도 품목 식별 : 파레토(ABC) 등급, 관리 대상 선정
   사후검토(AAR) : 오진단 사례, 규칙 조정 이력

[데이터 딕셔너리] 피처 23개 (목표 20개 이상: 달성)
        그룹          필드   자료형                          설명
   dtc_log       ev_id   str   정비 건 ID(DTC와 정비 이력을 잇는 키)
   dtc_log      veh_id   str                   차량 ID(가상)
   dtc_log          ts float DTC 발생 시각(모의 시작 후 경과 시간, h)
   dtc_log        code   str     DTC 코드(고유·공유·계통 증상 28종)
   dtc_log    co_count   int      같은 정비 건에서 동시 발생한 DTC 수
work_order     part_id   str    실제 고장 부품(정비 이력) - 예측대상 1
work_order      system   str                       고장 계통
work_order    repair_h float                  수리 소요시간(h)
work_order   unit_cost float                 부품 단가지수(가상)
work_order   unplanned  bool                비계획·긴급 정비 여부
work_order       month   int              정비 월(모의 시작 기준)
   vehicle         age float                       차령(년)
   vehicle    op_hours float                   월 가동시간(h)
   vehicle      pm_gap float       마지막 예방정비 후 경과 가동시간(h)
   vehicle     terrain float              운용 지형 가혹도(0~1)
   vehicle        load float               운용 부하 수준(0~1)
... (이하 생략)

미션 2. DTC 시계열 발생 로그 정제, 동시 발생 코드 정규화 전처리 모듈 구현

  • 난이도: 초급~중급 / 예상 소요시간: 2.0시간
  • 코드 설명: 표기 불일치·중복·단위 혼재·결측·미등록 코드가 섞인 가상 DTC 발생 로그에 정제 규칙 10종을 적용하고, 같은 시간대에 함께 발생한 DTC를 정비 건 단위로 묶어 정규화(중복 제거·정렬)한 마스터 데이터를 만든다. 긴급·비계획 정비는 보존하며 정합성 검증 통과율과 정제 전후 통계를 확인한다.
"""표기 불일치·중복·단위 혼재·결측·미등록 코드가 섞인 가상 DTC 발생 로그에 정제 규칙 10종을 적용하고, 같은 시간대에 함께 발생한 DTC를 정비 건 단위로 묶어 정규화(중복 제거·정렬)한 마스터 데이터를 만든다. 긴급·비계획 정비는 보존하며 정합성 검증 통과율과 정제 전후 통계를 확인한다."""
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.impute import KNNImputer

# ---------------------------------------------------------------------------
# [교육용가상자료] 가상 차량 100대의 18개월 고장 진단 코드(DTC)·정비 이력 생성기 (실제 부대·장비·정비 실적 없음)
# 부품 16종(1000가동시간당 고장률 차등), DTC 28종(부품 고유 코드, 짝 부품 공유 코드, 계통 증상 코드)
# 고장 1건(정비 이력)마다 DTC가 1~5개 동시 발생하고, 연쇄 고장 코드와 일시 잡음 코드가 섞인다.
# ---------------------------------------------------------------------------
PARTS = [  # (ID, 이름, 계통, 1000가동시간당 고장률, 가상 단가지수, 수리시간 h)
    ("P01", "연료분사기", "engine", 12.0, 3.0, 2.0), ("P02", "터보차저", "engine", 9.0, 8.0, 5.0),
    ("P03", "축전지", "electric", 6.5, 4.0, 1.5), ("P04", "발전기", "electric", 4.0, 9.0, 3.0),
    ("P05", "변속기 제어모듈", "drive", 3.0, 12.0, 6.0), ("P06", "냉각수 펌프", "engine", 2.2, 3.5, 3.0),
    ("P07", "유압 펌프", "hydraulic", 1.6, 10.0, 4.0), ("P08", "속도 센서", "drive", 1.2, 2.0, 1.5),
    ("P09", "ECU 커넥터", "electric", 0.9, 2.5, 1.5), ("P10", "연료 필터", "engine", 0.7, 0.8, 0.7),
    ("P11", "제동 밸브", "brake", 0.5, 6.0, 3.0), ("P12", "조향 액추에이터", "drive", 0.4, 9.0, 5.0),
    ("P13", "공기 필터", "engine", 0.3, 0.5, 0.5), ("P14", "통신 인터페이스 모듈", "electric", 0.25, 3.0, 1.5),
    ("P15", "윤활유 압력 센서", "engine", 0.2, 1.0, 1.0), ("P16", "팬 구동 모터", "cooling", 0.15, 2.0, 2.0)]
PID = [p[0] for p in PARTS]
PNAME = {p[0]: p[1] for p in PARTS}; PSYS = {p[0]: p[2] for p in PARTS}; RATE = {p[0]: p[3] for p in PARTS}
COST = {p[0]: p[4] for p in PARTS}; REPH = {p[0]: p[5] for p in PARTS}
PAIRS = [("P01", "P02"), ("P03", "P04"), ("P05", "P08"), ("P06", "P10"), ("P09", "P14"), ("P13", "P15")]
SHARED = {p: f"S{k + 1:02d}" for k, pr in enumerate(PAIRS) for p in pr}     # 짝 부품이 함께 쓰는 모호한 코드
SPEC = {p: f"C{p[1:]}1" for p in PID}                                          # 부품 고유 코드
SYS = ["engine", "electric", "drive", "hydraulic", "brake", "cooling"]
SYMP = {s: f"G{i + 1:02d}" for i, s in enumerate(SYS)}                         # 계통 증상 코드
CODES = sorted(set(SPEC.values()) | set(SHARED.values()) | set(SYMP.values()))
CASCADE = {"P01": [("P10", 0.20)], "P02": [("P06", 0.20)], "P03": [("P04", 0.25)], "P04": [("P09", 0.25)],
           "P05": [("P08", 0.30)], "P06": [("P16", 0.30)], "P09": [("P14", 0.20)], "P12": [("P07", 0.20)]}
CRIT_SYS = {"engine", "drive", "brake"}        # 기동·제동 불능 위험 계통(경보 대상)
MONTHS = 18


def make_fleet(n=100, seed=0):
    rng = np.random.default_rng(seed)
    f = pd.DataFrame({"veh": [f"V{i:03d}" for i in range(n)], "age": rng.integers(1, 26, n).astype(float),
                      "terrain": rng.uniform(0, 1, n), "load": rng.uniform(0, 1, n), "pm_gap": rng.uniform(20, 200, n),
                      "humid": rng.uniform(0, 1, n), "base_h": rng.uniform(55, 125, n)})
    f["mult"] = np.exp(0.045 * (f["age"] - 13) + 0.9 * (f["terrain"] - .5) + 0.8 * (f["load"] - .5)
                       + 0.006 * (f["pm_gap"] - 110) + 0.7 * (f["humid"] - .5))
    return f


def make_data(seed=1, months=MONTHS):
    """반환: fleet(차량 속성), ev(정비 이력 1건=1행: 발생 DTC 목록, 실제 고장 부품), log(DTC 발생 로그, 행=코드 1건)"""
    rng = np.random.default_rng(seed)
    fleet = make_fleet()
    V = len(fleet)
    temp = 12 + 14 * np.sin(2 * np.pi * (np.arange(months) - 3) / 12)            # 월평균 기온(가상)
    hours = fleet["base_h"].values[:, None] * (1 + 0.25 * np.sin(2 * np.pi * np.arange(months) / 12 + 1)) * rng.lognormal(0, .12, (V, months))
    fleet["hours"] = hours.sum(axis=1)                                           # 차량별 18개월 총 가동시간
    fleet.attrs["month_h"] = hours.sum(axis=0)                                   # 월별 전체 가동시간(고장률 분모)
    evs = []
    for p in PID:
        s = np.ones(months)
        if p in ("P03", "P04"): s = np.exp(-0.06 * (temp - 12))                   # 한랭기 전기계통 증가
        if p in ("P06", "P16"): s = np.exp(0.05 * (temp - 12))                    # 혹서기 냉각계통 증가
        cnt = rng.poisson(RATE[p] * hours / 1000 * fleet["mult"].values[:, None] * s[None, :])
        for v, m in zip(*np.nonzero(cnt)):
            for _ in range(cnt[v, m]):
                evs.append((fleet["veh"][v], int(m), m * 720 + rng.uniform(0, 720), p, float(temp[m] + rng.normal(0, 3))))
    ev = pd.DataFrame(evs, columns=["veh", "month", "t", "part", "temp"]).sort_values("t").reset_index(drop=True)
    ev["ev_id"] = [f"W{i:05d}" for i in range(len(ev))]
    rows, codes = [], []
    for e in ev.itertuples():
        c, r = [], rng.random(3)
        if r[0] < 0.6: c.append(SPEC[e.part])
        if e.part in SHARED and r[1] < 0.6: c.append(SHARED[e.part])
        if r[2] < 0.5: c.append(SYMP[PSYS[e.part]])
        if not c: c.append(SHARED.get(e.part, SPEC[e.part]))
        for q, pr in CASCADE.get(e.part, []):
            if rng.random() < pr: c.append(SPEC[q])
        if rng.random() < 0.12: c.append(SPEC[PID[rng.integers(0, 16)]])
        c = list(dict.fromkeys(c))
        rng.shuffle(c)                                                           # 동시 발생 코드의 기록 순서는 임의
        codes.append(c)
        for k, cd in enumerate(c):
            rows.append((e.ev_id, e.veh, e.t + k * 0.1, cd))
    ev["codes"] = codes
    log = pd.DataFrame(rows, columns=["ev_id", "veh", "ts", "code"])
    return fleet, ev, log

# 1) [교육용가상자료] 원천 로그: 생성 로그에 일부러 오류를 섞는다
fleet, ev, log = make_data()
rng = np.random.default_rng(2)
clean_n = len(log)
start = pd.Timestamp("2025-01-01")
raw = pd.DataFrame({"veh": log["veh"].values, "ts": [(start + pd.Timedelta(hours=t)).strftime("%Y-%m-%d %H:%M") for t in log["ts"]],
                    "code": log["code"].values, "emergency": False})
vn = raw["veh"].str[1:].astype(int).values
raw["op_h"] = (vn * 37 % 900) + log["ts"].values * 0.15           # 차량별 누적 가동시간 계기값(가상)
raw["op_unit"] = "h"
raw["op_h"] = raw["op_h"].round(1)
idx = rng.choice(len(raw), 2600, replace=False)
a, b, c, d_, e, f, g, h_, k = np.split(idx, [400, 900, 1500, 1800, 1880, 1960, 2040, 2300])
raw.loc[a, "veh"] = rng.choice(["v", " V"], len(a)) + raw.loc[a, "veh"].str[1:]                       # 차량 ID 표기 불일치
raw.loc[b, "code"] = [x.lower() if i % 2 else x[0] + "-" + x[1:] + " " for i, x in enumerate(raw.loc[b, "code"])]   # 코드 표기 불일치
raw.loc[c, "ts"] = pd.to_datetime(raw.loc[c, "ts"]).dt.strftime("%Y/%m/%d %H:%M:%S")                   # 시각 형식 혼재
raw.loc[d_, "op_h"] = (raw.loc[d_, "op_h"] * 60).round(0); raw.loc[d_, "op_unit"] = "min"             # 단위 혼재
raw.loc[e, "op_h"] = -1.0                                                                             # 불가능 값
raw.loc[f, "veh"] = "V999"                                                                            # 미등록 차량
raw.loc[g, "code"] = "ZZ9"                                                                            # 미정의 코드
raw.loc[h_, "op_h"] = np.nan                                                                          # 결측
burst = raw.iloc[k[:40]].copy()                                                                       # 긴급·비계획 정비: DTC 6개 폭주
bursts = []
for _, r in burst.iterrows():
    for j, cd in enumerate(rng.choice(CODES, 6, replace=False)):
        bursts.append({**r, "code": cd, "emergency": True,
                       "ts": (pd.to_datetime(r["ts"]) + pd.Timedelta(minutes=j)).strftime("%Y-%m-%d %H:%M")})
raw = pd.concat([raw, pd.DataFrame(bursts), raw.sample(150, random_state=1)], ignore_index=True)    # 폭주 건 + 중복 150건
raw = raw.sample(frac=1, random_state=3).reset_index(drop=True)
print(f"원천 로그 {len(raw)}행 (정상 {clean_n}행 + 긴급 폭주 {len(bursts)}행 + 중복 150행, 오류 주입 {len(idx)}행)")

# 2) 정제 규칙 10종
df, applied = raw.copy(), []
def rule(name, n):
    applied.append((name, int(n)))

rule("R1 중복 행 제거", df.duplicated().sum()); df = df.drop_duplicates().reset_index(drop=True)
v = df["veh"].str.strip().str.upper(); rule("R2 차량 ID 표기 통일", (v != df["veh"]).sum()); df["veh"] = v
cd = df["code"].str.strip().str.upper().str.replace("-", "", regex=False); rule("R3 DTC 코드 표기 통일", (cd != df["code"]).sum()); df["code"] = cd
t1 = pd.to_datetime(df["ts"], format="%Y-%m-%d %H:%M", errors="coerce")
t2 = pd.to_datetime(df["ts"], format="%Y/%m/%d %H:%M:%S", errors="coerce"); rule("R4 발생 시각 형식 통일", t1.isna().sum()); df["ts"] = t1.fillna(t2)
mn = df["op_unit"] == "min"; df.loc[mn, "op_h"] = df.loc[mn, "op_h"] / 60; df["op_unit"] = "h"; rule("R5 가동시간 단위 통일(분 -> 시간)", mn.sum())
bad = df["op_h"] < 0; df.loc[bad, "op_h"] = np.nan; rule("R6 음수 가동시간 결측 처리", bad.sum())
unk = ~df["veh"].isin(fleet["veh"]) | ~df["code"].isin(CODES); quarantine = df[unk].copy(); df = df[~unk].reset_index(drop=True)
rule("R7 미등록 차량·미정의 코드 격리(삭제 아님)", unk.sum())
df["hrs"] = (df["ts"] - start).dt.total_seconds() / 3600; df["vn"] = df["veh"].str[1:].astype(int)
miss = df["op_h"].isna().sum()
df["op_h"] = KNNImputer(n_neighbors=5).fit_transform(df[["hrs", "vn", "op_h"]].assign(vn=df["vn"] / 10))[:, 2]; rule("R8 가동시간 결측 KNN 보정", miss)
df = df.sort_values(["veh", "ts"]).reset_index(drop=True)
df["case"] = (df["veh"] != df["veh"].shift()) | (df["ts"].diff() > pd.Timedelta(hours=1)); df["case_id"] = df["case"].cumsum()
before = len(df); df = df.drop_duplicates(["case_id", "code"]).reset_index(drop=True)
cases = df.groupby("case_id").agg(veh=("veh", "first"), t=("ts", "first"), codes=("code", lambda s: tuple(sorted(s))), n=("code", "size"), emergency=("emergency", "max"))
print(f"(동시 발생 묶음 과정에서 중복 코드 {before - len(df)}행 제거)")
rule("R9 동시 발생 코드 정비 건 묶음·정렬(생성 건수)", len(cases))
cases["outlier_flag"] = (cases["n"] > 4) & ~cases["emergency"]
rule("R10 긴급·비계획 정비 보존(일반 이상 건은 표시만)", cases["emergency"].sum())
print("\n[적용 규칙과 처리 건수]")
for nme, n in applied:
    print(f" {nme:40s} {n:5d}건")

# 3) 정합성 검증
checks = {"중복 0건": not df.duplicated(["case_id", "code"]).any(), "차량 ID 형식": df["veh"].str.match(r"^V\d{3}$").all(),
          "코드 허용 범위": df["code"].isin(CODES).all(), "시각 변환": df["ts"].notna().all(), "가동시간 단위 h": (df["op_unit"] == "h").all(),
          "가동시간 0 이상": (df["op_h"] >= 0).all(), "미등록 건 격리 보관": len(quarantine) == unk.sum(),
          "결측 0건": df[["veh", "code", "ts", "op_h"]].notna().all().all(), "묶음 내 코드 정렬": all(list(c) == sorted(c) for c in cases["codes"]),
          f"긴급 정비 {len(bursts) // 6}건 보존": int(cases["emergency"].sum()) >= len(bursts) // 6}
rate = np.mean(list(checks.values()))
print(f"\n정합성 검증 {sum(checks.values())}/{len(checks)} 통과, 통과율 {rate:.0%} (목표 100%: {'달성' if rate == 1 else '미달'})")
for kx, vx in checks.items():
    if not vx:
        print(" 실패:", kx)
print(f"정비 건 {len(cases)}건 생성(평균 DTC {cases['n'].mean():.2f}개), 이상 표시 {int(cases['outlier_flag'].sum())}건")

# 4) 정제 전후 기술통계 비교
num = pd.to_numeric(raw["op_h"], errors="coerce")
cmp = pd.DataFrame({"정제 전": [len(raw), raw["veh"].nunique(), raw["code"].nunique(), num.mean(), num.min(), num.isna().sum()],
                    "정제 후": [len(df), df["veh"].nunique(), df["code"].nunique(), df["op_h"].mean(), df["op_h"].min(), df["op_h"].isna().sum()]},
                   index=["행 수", "차량 종류 수", "코드 종류 수", "가동시간 평균(h)", "가동시간 최소(h)", "결측 수"]).round(1)
print("\n[정제 전후 기술통계]"); print(cmp.to_string())

fig, ax = plt.subplots(1, 2, figsize=(10, 3.5))
ax[0].hist(num.dropna(), bins=40, alpha=0.6, label="raw"); ax[0].hist(df["op_h"], bins=40, alpha=0.6, label="cleaned")
ax[0].set_title("Operating hours before/after unit fix"); ax[0].legend()
ax[1].bar([n.split()[0] for n, _ in applied], [n for _, n in applied]); ax[1].set_title("Records changed by rule")
plt.tight_layout(); plt.show()

실행 결과 예시

원천 로그 22469행 (정상 22079행 + 긴급 폭주 240행 + 중복 150행, 오류 주입 2600행)
(동시 발생 묶음 과정에서 중복 코드 68행 제거)

[적용 규칙과 처리 건수]
 R1 중복 행 제거                                 150건
 R2 차량 ID 표기 통일                             400건
 R3 DTC 코드 표기 통일                            500건
 R4 발생 시각 형식 통일                             600건
 R5 가동시간 단위 통일(분 -> 시간)                     300건
 R6 음수 가동시간 결측 처리                            80건
 R7 미등록 차량·미정의 코드 격리(삭제 아님)                 160건
 R8 가동시간 결측 KNN 보정                          340건
 R9 동시 발생 코드 정비 건 묶음·정렬(생성 건수)            10504건
 R10 긴급·비계획 정비 보존(일반 이상 건은 표시만)              40건

정합성 검증 10/10 통과, 통과율 100% (목표 100%: 달성)
정비 건 10504건 생성(평균 DTC 2.10개), 이상 표시 85건

[정제 전후 기술통계]
               정제 전     정제 후
행 수         22469.0  22091.0
차량 종류 수       252.0    100.0
코드 종류 수        82.0     28.0
가동시간 평균(h)   2496.2   1372.4
가동시간 최소(h)     -1.0      3.8
결측 수          261.0      0.0

미션 3. 단일 고장 코드 기반 1차 고장 부품 매핑 Baseline 알고리즘 구현

  • 난이도: 중급 / 예상 소요시간: 2.5시간
  • 코드 설명: 단일 DTC 코드만 보고 가장 흔한 고장 부품을 고르는 1차 Baseline 매핑 알고리즘을 구현해 정확도와 한계를 확인하고, 탐색적 분석(EDA 차트 8종, 고장 건수와 피처의 상관계수·p-value, 파레토 ABC 분류)으로 1000가동시간당 고장률이 높은 다빈도 고장품목을 식별한다.
"""단일 DTC 코드만 보고 가장 흔한 고장 부품을 고르는 1차 Baseline 매핑 알고리즘을 구현해 정확도와 한계를 확인하고, 탐색적 분석(EDA 차트 8종, 고장 건수와 피처의 상관계수·p-value, 파레토 ABC 분류)으로 1000가동시간당 고장률이 높은 다빈도 고장품목을 식별한다."""
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from scipy.stats import pearsonr

# ---------------------------------------------------------------------------
# [교육용가상자료] 가상 차량 100대의 18개월 고장 진단 코드(DTC)·정비 이력 생성기 (실제 부대·장비·정비 실적 없음)
# 부품 16종(1000가동시간당 고장률 차등), DTC 28종(부품 고유 코드, 짝 부품 공유 코드, 계통 증상 코드)
# 고장 1건(정비 이력)마다 DTC가 1~5개 동시 발생하고, 연쇄 고장 코드와 일시 잡음 코드가 섞인다.
# ---------------------------------------------------------------------------
PARTS = [  # (ID, 이름, 계통, 1000가동시간당 고장률, 가상 단가지수, 수리시간 h)
    ("P01", "연료분사기", "engine", 12.0, 3.0, 2.0), ("P02", "터보차저", "engine", 9.0, 8.0, 5.0),
    ("P03", "축전지", "electric", 6.5, 4.0, 1.5), ("P04", "발전기", "electric", 4.0, 9.0, 3.0),
    ("P05", "변속기 제어모듈", "drive", 3.0, 12.0, 6.0), ("P06", "냉각수 펌프", "engine", 2.2, 3.5, 3.0),
    ("P07", "유압 펌프", "hydraulic", 1.6, 10.0, 4.0), ("P08", "속도 센서", "drive", 1.2, 2.0, 1.5),
    ("P09", "ECU 커넥터", "electric", 0.9, 2.5, 1.5), ("P10", "연료 필터", "engine", 0.7, 0.8, 0.7),
    ("P11", "제동 밸브", "brake", 0.5, 6.0, 3.0), ("P12", "조향 액추에이터", "drive", 0.4, 9.0, 5.0),
    ("P13", "공기 필터", "engine", 0.3, 0.5, 0.5), ("P14", "통신 인터페이스 모듈", "electric", 0.25, 3.0, 1.5),
    ("P15", "윤활유 압력 센서", "engine", 0.2, 1.0, 1.0), ("P16", "팬 구동 모터", "cooling", 0.15, 2.0, 2.0)]
PID = [p[0] for p in PARTS]
PNAME = {p[0]: p[1] for p in PARTS}; PSYS = {p[0]: p[2] for p in PARTS}; RATE = {p[0]: p[3] for p in PARTS}
COST = {p[0]: p[4] for p in PARTS}; REPH = {p[0]: p[5] for p in PARTS}
PAIRS = [("P01", "P02"), ("P03", "P04"), ("P05", "P08"), ("P06", "P10"), ("P09", "P14"), ("P13", "P15")]
SHARED = {p: f"S{k + 1:02d}" for k, pr in enumerate(PAIRS) for p in pr}     # 짝 부품이 함께 쓰는 모호한 코드
SPEC = {p: f"C{p[1:]}1" for p in PID}                                          # 부품 고유 코드
SYS = ["engine", "electric", "drive", "hydraulic", "brake", "cooling"]
SYMP = {s: f"G{i + 1:02d}" for i, s in enumerate(SYS)}                         # 계통 증상 코드
CODES = sorted(set(SPEC.values()) | set(SHARED.values()) | set(SYMP.values()))
CASCADE = {"P01": [("P10", 0.20)], "P02": [("P06", 0.20)], "P03": [("P04", 0.25)], "P04": [("P09", 0.25)],
           "P05": [("P08", 0.30)], "P06": [("P16", 0.30)], "P09": [("P14", 0.20)], "P12": [("P07", 0.20)]}
CRIT_SYS = {"engine", "drive", "brake"}        # 기동·제동 불능 위험 계통(경보 대상)
MONTHS = 18


def make_fleet(n=100, seed=0):
    rng = np.random.default_rng(seed)
    f = pd.DataFrame({"veh": [f"V{i:03d}" for i in range(n)], "age": rng.integers(1, 26, n).astype(float),
                      "terrain": rng.uniform(0, 1, n), "load": rng.uniform(0, 1, n), "pm_gap": rng.uniform(20, 200, n),
                      "humid": rng.uniform(0, 1, n), "base_h": rng.uniform(55, 125, n)})
    f["mult"] = np.exp(0.045 * (f["age"] - 13) + 0.9 * (f["terrain"] - .5) + 0.8 * (f["load"] - .5)
                       + 0.006 * (f["pm_gap"] - 110) + 0.7 * (f["humid"] - .5))
    return f


def make_data(seed=1, months=MONTHS):
    """반환: fleet(차량 속성), ev(정비 이력 1건=1행: 발생 DTC 목록, 실제 고장 부품), log(DTC 발생 로그, 행=코드 1건)"""
    rng = np.random.default_rng(seed)
    fleet = make_fleet()
    V = len(fleet)
    temp = 12 + 14 * np.sin(2 * np.pi * (np.arange(months) - 3) / 12)            # 월평균 기온(가상)
    hours = fleet["base_h"].values[:, None] * (1 + 0.25 * np.sin(2 * np.pi * np.arange(months) / 12 + 1)) * rng.lognormal(0, .12, (V, months))
    fleet["hours"] = hours.sum(axis=1)                                           # 차량별 18개월 총 가동시간
    fleet.attrs["month_h"] = hours.sum(axis=0)                                   # 월별 전체 가동시간(고장률 분모)
    evs = []
    for p in PID:
        s = np.ones(months)
        if p in ("P03", "P04"): s = np.exp(-0.06 * (temp - 12))                   # 한랭기 전기계통 증가
        if p in ("P06", "P16"): s = np.exp(0.05 * (temp - 12))                    # 혹서기 냉각계통 증가
        cnt = rng.poisson(RATE[p] * hours / 1000 * fleet["mult"].values[:, None] * s[None, :])
        for v, m in zip(*np.nonzero(cnt)):
            for _ in range(cnt[v, m]):
                evs.append((fleet["veh"][v], int(m), m * 720 + rng.uniform(0, 720), p, float(temp[m] + rng.normal(0, 3))))
    ev = pd.DataFrame(evs, columns=["veh", "month", "t", "part", "temp"]).sort_values("t").reset_index(drop=True)
    ev["ev_id"] = [f"W{i:05d}" for i in range(len(ev))]
    rows, codes = [], []
    for e in ev.itertuples():
        c, r = [], rng.random(3)
        if r[0] < 0.6: c.append(SPEC[e.part])
        if e.part in SHARED and r[1] < 0.6: c.append(SHARED[e.part])
        if r[2] < 0.5: c.append(SYMP[PSYS[e.part]])
        if not c: c.append(SHARED.get(e.part, SPEC[e.part]))
        for q, pr in CASCADE.get(e.part, []):
            if rng.random() < pr: c.append(SPEC[q])
        if rng.random() < 0.12: c.append(SPEC[PID[rng.integers(0, 16)]])
        c = list(dict.fromkeys(c))
        rng.shuffle(c)                                                           # 동시 발생 코드의 기록 순서는 임의
        codes.append(c)
        for k, cd in enumerate(c):
            rows.append((e.ev_id, e.veh, e.t + k * 0.1, cd))
    ev["codes"] = codes
    log = pd.DataFrame(rows, columns=["ev_id", "veh", "ts", "code"])
    return fleet, ev, log

fleet, ev, log = make_data()
ev["first"] = ev["codes"].str[0]
train, test = ev[ev["month"] < 12], ev[ev["month"] >= 12]

# 1) Baseline: 첫 번째로 기록된 코드 -> 학습 기간에 그 코드와 가장 자주 함께 나온 부품
mp = train.groupby("first")["part"].agg(lambda s: s.value_counts().index[0])
pred = test["first"].map(mp)
acc = (pred == test["part"]).mean()
sys_acc = (pred.map(PSYS) == test["part"].map(PSYS)).mean()
print(f"[교육용가상자료] 정비 건 {len(ev)}건, DTC 로그 {len(log)}행, 차량 {len(fleet)}대, 18개월")
print(f"Baseline 부품 정확도 {acc:.1%}, 고장 계통 정확도 {sys_acc:.1%} (검증 {len(test)}건)")
amb = test["first"].isin(list(SHARED.values()) + list(SYMP.values()))
print(f"  모호한 코드(공유·증상) 첫 기록 건 정확도 {(pred[amb] == test['part'][amb]).mean():.1%} vs 고유 코드 {(pred[~amb] == test['part'][~amb]).mean():.1%}")

# 2) 부품별 고장률(1000가동시간당)과 파레토(ABC)
tot_h = fleet["hours"].sum()
cnt = ev["part"].value_counts().reindex(PID)
rate = (cnt / tot_h * 1000).round(3)
par = pd.DataFrame({"부품": [PNAME[p] for p in PID], "건수": cnt.values, "고장률": rate.values}, index=PID).sort_values("건수", ascending=False)
par["누적비율"] = par["건수"].cumsum() / par["건수"].sum()
par["등급"] = np.where(par["누적비율"] <= 0.80, "A", np.where(par["누적비율"] <= 0.95, "B", "C"))
par.loc[par.index[0], "등급"] = "A"
print(f"\n[파레토] 총 가동 {tot_h:,.0f}시간, 전체 고장률 {cnt.sum() / tot_h * 1000:.1f}건/1000h")
print(par.head(7).assign(누적비율=lambda d: d["누적비율"].map("{:.0%}".format)).to_string())
top20 = par.head(int(np.ceil(len(par) * 0.2)))
print(f"상위 20% 품목({len(top20)}종: {', '.join(top20['부품'])})이 전체 고장의 {top20['건수'].sum() / cnt.sum():.0%}, A등급 {int((par['등급'] == 'A').sum())}종")

# 3) 상관분석: 차량별 정비 건수와 피처
vc = ev.groupby("veh").size().reindex(fleet["veh"]).fillna(0).values
rows = []
for col, lab in [("age", "차령"), ("pm_gap", "예방정비 경과시간"), ("terrain", "지형 가혹도"), ("load", "운용 부하"), ("hours", "총 가동시간"), ("humid", "습도 지수")]:
    r, p = pearsonr(fleet[col], vc); rows.append((lab, r, p))
mc = ev.groupby(["month", "part"]).size().unstack(fill_value=0)
tm = ev.groupby("month")["temp"].mean()
r, p = pearsonr(tm, mc["P03"]); rows.append(("월평균 기온-축전지 고장", r, p))
cor = pd.DataFrame(rows, columns=["피처", "r", "p"])
cor["유의"] = cor["p"] < 0.05
print("\n[상관분석] 피처 | r | p-value | 유의(p<0.05)")
for x in cor.itertuples():
    print(f" {x.피처:14s} {x.r:+.2f}  {x.p:.4f}  {'유의' if x.유의 else '-'}")
nsig = int(cor["유의"].sum())

# 4) EDA 차트 8종
fig, ax = plt.subplots(2, 4, figsize=(16, 7))
ax[0, 0].bar(par.index, par["고장률"]); ax[0, 0].set_title("1 Failure rate per 1000h")
ax[0, 1].plot(range(len(par)), par["누적비율"], marker="o"); ax[0, 1].axhline(0.8, ls="--", color="k"); ax[0, 1].set_title("2 Pareto cumulative share")
mc.sum(axis=1).plot(ax=ax[0, 2]); ax[0, 2].set_title("3 Monthly failures")
ev["codes"].str.len().value_counts().sort_index().plot.bar(ax=ax[0, 3]); ax[0, 3].set_title("4 DTCs per maintenance case")
log["code"].value_counts().head(10).plot.bar(ax=ax[1, 0]); ax[1, 0].set_title("5 Top DTC codes")
ev["part"].map(PSYS).value_counts().plot.bar(ax=ax[1, 1]); ax[1, 1].set_title("6 Failures by system")
ax[1, 2].scatter(fleet["age"], vc, s=8); ax[1, 2].set_title("7 Vehicle age vs failures")
ax[1, 3].scatter(fleet["pm_gap"], vc, s=8); ax[1, 3].set_title("8 PM gap vs failures")
plt.tight_layout(); plt.show()

print(f"\nEDA 차트 8종 생성(목표 8종 이상: 달성), 유의한 피처 {nsig}개 (목표 5개 이상: {'달성' if nsig >= 5 else '미달'})")
print("핵심 발견: 1) 소수 부품에 고장 집중 2) 공유·증상 코드가 첫 기록이면 Baseline이 자주 틀림 3) 고령·가혹 지형·예방정비 지연 차량의 고장 증가")

실행 결과 예시

[교육용가상자료] 정비 건 10681건, DTC 로그 22079행, 차량 100대, 18개월
Baseline 부품 정확도 68.7%, 고장 계통 정확도 96.8% (검증 3890건)
  모호한 코드(공유·증상) 첫 기록 건 정확도 59.7% vs 고유 코드 80.6%

[파레토] 총 가동 171,649시간, 전체 고장률 62.2건/1000h
           부품    건수     고장률 누적비율 등급
P01     연료분사기  2800  16.312  26%  A
P02      터보차저  1985  11.564  45%  A
P03       축전지  1983  11.553  63%  A
P04       발전기  1244   7.247  75%  A
P05  변속기 제어모듈   665   3.874  81%  B
P06    냉각수 펌프   477   2.779  86%  B
P07     유압 펌프   390   2.272  89%  B
상위 20% 품목(4종: 연료분사기, 터보차저, 축전지, 발전기)이 전체 고장의 75%, A등급 4종

[상관분석] 피처 | r | p-value | 유의(p<0.05)
 차령             +0.41  0.0000  유의
 예방정비 경과시간      +0.57  0.0000  유의
 지형 가혹도         +0.40  0.0000  유의
 운용 부하          +0.35  0.0004  유의
 총 가동시간         +0.32  0.0011  유의
 습도 지수          +0.07  0.5174  -
 월평균 기온-축전지 고장  -0.95  0.0000  유의

EDA 차트 8종 생성(목표 8종 이상: 달성), 유의한 피처 6개 (목표 5개 이상: 달성)
핵심 발견: 1) 소수 부품에 고장 집중 2) 공유·증상 코드가 첫 기록이면 Baseline이 자주 틀림 3) 고령·가혹 지형·예방정비 지연 차량의 고장 증가

미션 4. 연관 규칙 마이닝(Apriori/FP-Growth) 기반 다중 DTC 동시 발생 시 근본 원인 예측 모델 고도화

  • 난이도: 중급 / 예상 소요시간: 3.0시간
  • 코드 설명: Apriori 연관 규칙 마이닝으로 여러 DTC가 동시에 나타났을 때의 근본 원인 부품을 예측하는 모델을 만들어 단일 코드 Baseline과 비교하고, 부품별 월간 고장 건수 예측용 파생 피처 10개 이상으로 기준 모델을 학습해 시계열 교차검증 MAPE와 피처 중요도를 확인한다.
"""Apriori 연관 규칙 마이닝으로 여러 DTC가 동시에 나타났을 때의 근본 원인 부품을 예측하는 모델을 만들어 단일 코드 Baseline과 비교하고, 부품별 월간 고장 건수 예측용 파생 피처 10개 이상으로 기준 모델을 학습해 시계열 교차검증 MAPE와 피처 중요도를 확인한다."""
import itertools
from collections import Counter
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.linear_model import Ridge
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import make_pipeline
from sklearn.inspection import permutation_importance

# ---------------------------------------------------------------------------
# [교육용가상자료] 가상 차량 100대의 18개월 고장 진단 코드(DTC)·정비 이력 생성기 (실제 부대·장비·정비 실적 없음)
# 부품 16종(1000가동시간당 고장률 차등), DTC 28종(부품 고유 코드, 짝 부품 공유 코드, 계통 증상 코드)
# 고장 1건(정비 이력)마다 DTC가 1~5개 동시 발생하고, 연쇄 고장 코드와 일시 잡음 코드가 섞인다.
# ---------------------------------------------------------------------------
PARTS = [  # (ID, 이름, 계통, 1000가동시간당 고장률, 가상 단가지수, 수리시간 h)
    ("P01", "연료분사기", "engine", 12.0, 3.0, 2.0), ("P02", "터보차저", "engine", 9.0, 8.0, 5.0),
    ("P03", "축전지", "electric", 6.5, 4.0, 1.5), ("P04", "발전기", "electric", 4.0, 9.0, 3.0),
    ("P05", "변속기 제어모듈", "drive", 3.0, 12.0, 6.0), ("P06", "냉각수 펌프", "engine", 2.2, 3.5, 3.0),
    ("P07", "유압 펌프", "hydraulic", 1.6, 10.0, 4.0), ("P08", "속도 센서", "drive", 1.2, 2.0, 1.5),
    ("P09", "ECU 커넥터", "electric", 0.9, 2.5, 1.5), ("P10", "연료 필터", "engine", 0.7, 0.8, 0.7),
    ("P11", "제동 밸브", "brake", 0.5, 6.0, 3.0), ("P12", "조향 액추에이터", "drive", 0.4, 9.0, 5.0),
    ("P13", "공기 필터", "engine", 0.3, 0.5, 0.5), ("P14", "통신 인터페이스 모듈", "electric", 0.25, 3.0, 1.5),
    ("P15", "윤활유 압력 센서", "engine", 0.2, 1.0, 1.0), ("P16", "팬 구동 모터", "cooling", 0.15, 2.0, 2.0)]
PID = [p[0] for p in PARTS]
PNAME = {p[0]: p[1] for p in PARTS}; PSYS = {p[0]: p[2] for p in PARTS}; RATE = {p[0]: p[3] for p in PARTS}
COST = {p[0]: p[4] for p in PARTS}; REPH = {p[0]: p[5] for p in PARTS}
PAIRS = [("P01", "P02"), ("P03", "P04"), ("P05", "P08"), ("P06", "P10"), ("P09", "P14"), ("P13", "P15")]
SHARED = {p: f"S{k + 1:02d}" for k, pr in enumerate(PAIRS) for p in pr}     # 짝 부품이 함께 쓰는 모호한 코드
SPEC = {p: f"C{p[1:]}1" for p in PID}                                          # 부품 고유 코드
SYS = ["engine", "electric", "drive", "hydraulic", "brake", "cooling"]
SYMP = {s: f"G{i + 1:02d}" for i, s in enumerate(SYS)}                         # 계통 증상 코드
CODES = sorted(set(SPEC.values()) | set(SHARED.values()) | set(SYMP.values()))
CASCADE = {"P01": [("P10", 0.20)], "P02": [("P06", 0.20)], "P03": [("P04", 0.25)], "P04": [("P09", 0.25)],
           "P05": [("P08", 0.30)], "P06": [("P16", 0.30)], "P09": [("P14", 0.20)], "P12": [("P07", 0.20)]}
CRIT_SYS = {"engine", "drive", "brake"}        # 기동·제동 불능 위험 계통(경보 대상)
MONTHS = 18


def make_fleet(n=100, seed=0):
    rng = np.random.default_rng(seed)
    f = pd.DataFrame({"veh": [f"V{i:03d}" for i in range(n)], "age": rng.integers(1, 26, n).astype(float),
                      "terrain": rng.uniform(0, 1, n), "load": rng.uniform(0, 1, n), "pm_gap": rng.uniform(20, 200, n),
                      "humid": rng.uniform(0, 1, n), "base_h": rng.uniform(55, 125, n)})
    f["mult"] = np.exp(0.045 * (f["age"] - 13) + 0.9 * (f["terrain"] - .5) + 0.8 * (f["load"] - .5)
                       + 0.006 * (f["pm_gap"] - 110) + 0.7 * (f["humid"] - .5))
    return f


def make_data(seed=1, months=MONTHS):
    """반환: fleet(차량 속성), ev(정비 이력 1건=1행: 발생 DTC 목록, 실제 고장 부품), log(DTC 발생 로그, 행=코드 1건)"""
    rng = np.random.default_rng(seed)
    fleet = make_fleet()
    V = len(fleet)
    temp = 12 + 14 * np.sin(2 * np.pi * (np.arange(months) - 3) / 12)            # 월평균 기온(가상)
    hours = fleet["base_h"].values[:, None] * (1 + 0.25 * np.sin(2 * np.pi * np.arange(months) / 12 + 1)) * rng.lognormal(0, .12, (V, months))
    fleet["hours"] = hours.sum(axis=1)                                           # 차량별 18개월 총 가동시간
    fleet.attrs["month_h"] = hours.sum(axis=0)                                   # 월별 전체 가동시간(고장률 분모)
    evs = []
    for p in PID:
        s = np.ones(months)
        if p in ("P03", "P04"): s = np.exp(-0.06 * (temp - 12))                   # 한랭기 전기계통 증가
        if p in ("P06", "P16"): s = np.exp(0.05 * (temp - 12))                    # 혹서기 냉각계통 증가
        cnt = rng.poisson(RATE[p] * hours / 1000 * fleet["mult"].values[:, None] * s[None, :])
        for v, m in zip(*np.nonzero(cnt)):
            for _ in range(cnt[v, m]):
                evs.append((fleet["veh"][v], int(m), m * 720 + rng.uniform(0, 720), p, float(temp[m] + rng.normal(0, 3))))
    ev = pd.DataFrame(evs, columns=["veh", "month", "t", "part", "temp"]).sort_values("t").reset_index(drop=True)
    ev["ev_id"] = [f"W{i:05d}" for i in range(len(ev))]
    rows, codes = [], []
    for e in ev.itertuples():
        c, r = [], rng.random(3)
        if r[0] < 0.6: c.append(SPEC[e.part])
        if e.part in SHARED and r[1] < 0.6: c.append(SHARED[e.part])
        if r[2] < 0.5: c.append(SYMP[PSYS[e.part]])
        if not c: c.append(SHARED.get(e.part, SPEC[e.part]))
        for q, pr in CASCADE.get(e.part, []):
            if rng.random() < pr: c.append(SPEC[q])
        if rng.random() < 0.12: c.append(SPEC[PID[rng.integers(0, 16)]])
        c = list(dict.fromkeys(c))
        rng.shuffle(c)                                                           # 동시 발생 코드의 기록 순서는 임의
        codes.append(c)
        for k, cd in enumerate(c):
            rows.append((e.ev_id, e.veh, e.t + k * 0.1, cd))
    ev["codes"] = codes
    log = pd.DataFrame(rows, columns=["ev_id", "veh", "ts", "code"])
    return fleet, ev, log

# ---------------------------------------------------------------------------
# 공통 분석 조각: 코드 멀티핫 행렬, 차량 맥락 피처, 학습/검증 분할(앞 12개월 학습, 뒤 6개월 검증)
# ---------------------------------------------------------------------------
CIDX = {c: i for i, c in enumerate(CODES)}
SPLIT_M = 12


def multi_hot(ev):
    X = np.zeros((len(ev), len(CODES)))
    for r, cs in enumerate(ev["codes"]):
        for c in cs:
            X[r, CIDX[c]] = 1
    return X


def context(ev, fleet):
    f = fleet.set_index("veh")
    return np.column_stack([ev["veh"].map(f["age"]) / 25, ev["veh"].map(f["pm_gap"]) / 200, ev["veh"].map(f["terrain"]),
                            ev["veh"].map(f["load"]), (ev["temp"] - 12) / 15])


def feats(ev, fleet):
    X = multi_hot(ev)
    n = X.sum(axis=1, keepdims=True) / 3
    return np.hstack([X, n, context(ev, fleet)])


def monthly_counts(ev):
    return ev.groupby(["month", "part"]).size().unstack(fill_value=0).reindex(columns=PID, fill_value=0)


def build_monthly(ev, fleet, top_n=8):
    """부품별 월간 고장 건수 예측용 표: 지연값(lag), 이동평균, 추세, 기대 건수(가동시간 x 설계 고장률), 기온 상호작용, 계절 성분"""
    mc = ev.groupby(["month", "part"]).size().unstack(fill_value=0)
    top = ev["part"].value_counts().index[:top_n]
    mh, tm = fleet.attrs["month_h"], ev.groupby("month")["temp"].mean().values
    rows = []
    for p in top:
        c = mc[p].values.astype(float)
        for m in range(3, MONTHS):
            L = np.log1p(c)
            rows.append({"part": p, "month": m, "y": c[m], "lag1": L[m - 1], "lag2": L[m - 2], "lag3": L[m - 3], "roll3": L[m - 3:m].mean(),
                         "trend": L[m - 1] - L[m - 2], "roll3_std": L[m - 3:m].std(), "log_expected": np.log(mh[m] / 1000 * RATE[p]),
                         "rate_lag1": c[m - 1] / (mh[m - 1] / 1000) / RATE[p], "temp": tm[m] / 10, "cold_elec": max(0, 12 - tm[m]) / 10 * (p in ("P03", "P04")),
                         "heat_cool": max(0, tm[m] - 12) / 10 * (p in ("P06", "P16")), "sin": np.sin(2 * np.pi * m / 12), "cos": np.cos(2 * np.pi * m / 12)})
    D = pd.DataFrame(rows)
    return D, [c for c in D.columns if c not in ("part", "month", "y")], top

fleet, ev, log = make_data()
ev["first"] = ev["codes"].str[0]
train, test = ev[ev["month"] < 12], ev[ev["month"] >= 12]


# 1) Apriori: 항목 = DTC 코드 + 근본 원인 항목(ROOT:부품). 수준별로 후보를 만들고 최소 지지도 미만은 가지치기한다.
def apriori(trans, min_sup, max_len=3):
    n = len(trans)
    cnt = Counter(i for t in trans for i in t)
    freq = {frozenset([i]): c / n for i, c in cnt.items() if c / n >= min_sup}
    allf, cur = dict(freq), list(freq)
    for k in range(2, max_len + 1):
        cands = {a | b for a, b in itertools.combinations(cur, 2) if len(a | b) == k}
        cands = {c for c in cands if all(frozenset(s) in allf for s in itertools.combinations(c, k - 1))}   # 부분집합 가지치기
        cc = Counter(c for t in trans for c in cands if c <= t)
        cur = [c for c, v in cc.items() if v / n >= min_sup]
        allf.update({c: cc[c] / n for c in cur})
    return allf


trans = [frozenset(cs) | {"ROOT:" + p} for cs, p in zip(train["codes"], train["part"])]
F = apriori(trans, min_sup=0.004)
rules = []
for s, sup in F.items():
    roots = [i for i in s if i.startswith("ROOT:")]
    if len(roots) == 1 and len(s) >= 2:
        ante = s - set(roots)
        conf = sup / F[ante]
        lift = conf / F[frozenset(roots)]
        rules.append({"ante": ante, "part": roots[0][5:], "sup": sup, "conf": conf, "lift": lift})
R = pd.DataFrame(rules)
R = R[(R["conf"] >= 0.5) & (R["lift"] > 1.5)].sort_values("conf", ascending=False).reset_index(drop=True)
print(f"[교육용가상자료] 학습 정비 건 {len(train)}건: 빈발 항목집합 {len(F)}개 -> 연관 규칙 {len(R)}개 (신뢰도 0.5 이상, 향상도 1.5 초과)")
for r in R.head(4).itertuples():
    print(f"  {{{', '.join(sorted(r.ante))}}} -> {PNAME[r.part]}  지지도 {r.sup:.3f} 신뢰도 {r.conf:.2f} 향상도 {r.lift:.1f}")

mp = train.groupby("first")["part"].agg(lambda s: s.value_counts().index[0])
rl = sorted(zip(R["ante"], R["part"], R["conf"]), key=lambda x: (-x[2], -len(x[0])))


def rule_predict(codes, fallback):
    for a, p, c in rl:
        if a <= set(codes):
            return p, c
    return fallback, 0.0


pr = [rule_predict(cs, mp.get(f, "P01")) for cs, f in zip(test["codes"], test["first"])]
pred_rule = np.array([p for p, _ in pr]); pred_base = test["first"].map(mp).values
a_b, a_r = (pred_base == test["part"].values).mean(), (pred_rule == test["part"].values).mean()
multi = test["codes"].str.len().values >= 2
print(f"\n근본 원인 정확도: 단일 코드 Baseline {a_b:.1%} -> 연관 규칙 {a_r:.1%} (코드 2개 이상 동시 발생 건 {(pred_base[multi] == test['part'].values[multi]).mean():.1%} -> {(pred_rule[multi] == test['part'].values[multi]).mean():.1%})")

# 2) 파생 피처 + 월간 고장 건수 기준 모델(상위 8개 부품, 건수는 로그 변환해 학습)
D, FE, top = build_monthly(ev, fleet)
print(f"\n월간 고장 건수 예측: 파생 피처 {len(FE)}개 (목표 10개 이상: {'달성' if len(FE) >= 10 else '미달'}), 대상 {len(top)}개 부품 x {MONTHS - 3}개월")


def model():
    return make_pipeline(StandardScaler(), Ridge(alpha=3.0))


def mape(a, p):
    return float(np.mean(np.abs(a - p) / a))


# 3) 시계열 교차검증: 확장 윈도(과거로만 학습, 뒤 2개월 검증)
folds, mapes = [(range(3, s), range(s, s + 2)) for s in (11, 13, 15)], []
for tr, te in folds:
    a, b = D[D["month"].isin(tr)], D[D["month"].isin(te)]
    mapes.append(mape(b["y"].values, np.exp(model().fit(a[FE], np.log(a["y"])).predict(b[FE]))))
cv = float(np.mean(mapes))
print("시계열 교차검증 폴드별 MAPE: " + ", ".join(f"{m:.1%}" for m in mapes) + f" -> 평균 {cv:.1%} (목표 25% 이하: {'달성' if cv <= 0.25 else '미달'})")

# 4) 피처 중요도(순열 중요도)로 차원 축소
a, b = D[D["month"] < 13], D[D["month"] >= 13]
m = model().fit(a[FE], np.log(a["y"]))
imp = permutation_importance(m, b[FE], np.log(b["y"]), n_repeats=20, random_state=0, scoring="neg_mean_absolute_error").importances_mean
order = np.argsort(-imp)
keep = [FE[i] for i in order[:8]]
m5 = model().fit(a[keep], np.log(a["y"]))
full, red = mape(b["y"].values, np.exp(m.predict(b[FE]))), mape(b["y"].values, np.exp(m5.predict(b[keep])))
print("피처 중요도 상위 5: " + ", ".join(f"{FE[i]}({imp[i]:.2f})" for i in order[:5]))
print(f"차원 축소 {len(FE)}개 -> 8개: 검증 MAPE {full:.1%} -> {red:.1%}")

fig, ax = plt.subplots(1, 2, figsize=(10, 3.5))
ax[0].bar(["baseline", "rules"], [a_b, a_r]); ax[0].set_title("Root-cause accuracy")
ax[1].barh([FE[i] for i in order[:8]][::-1], imp[order[:8]][::-1]); ax[1].set_title("Permutation importance (log MAE)")
plt.tight_layout(); plt.show()

실행 결과 예시

[교육용가상자료] 학습 정비 건 6791건: 빈발 항목집합 188개 -> 연관 규칙 57개 (신뢰도 0.5 이상, 향상도 1.5 초과)
  {G04} -> 유압 펌프  지지도 0.020 신뢰도 1.00 향상도 29.1
  {G05} -> 제동 밸브  지지도 0.007 신뢰도 1.00 향상도 87.1
  {C051, G03} -> 변속기 제어모듈  지지도 0.021 신뢰도 1.00 향상도 15.5
  {C071, G04} -> 유압 펌프  지지도 0.013 신뢰도 1.00 향상도 29.1

근본 원인 정확도: 단일 코드 Baseline 68.7% -> 연관 규칙 78.3% (코드 2개 이상 동시 발생 건 67.1% -> 81.1%)

월간 고장 건수 예측: 파생 피처 13개 (목표 10개 이상: 달성), 대상 8개 부품 x 15개월
시계열 교차검증 폴드별 MAPE: 28.9%, 17.3%, 17.8% -> 평균 21.3% (목표 25% 이하: 달성)
피처 중요도 상위 5: lag1(0.21), log_expected(0.18), lag2(0.14), cold_elec(0.09), rate_lag1(0.07)
차원 축소 13개 -> 8개: 검증 MAPE 18.6% -> 21.6%

미션 5. DTC 기반 정비 조치 가이드라인 및 추천 신뢰도 점수 산출 엔진 개발

  • 난이도: 중급~고급 / 예상 소요시간: 3.5시간
  • 코드 설명: DTC 조합과 차량 맥락을 입력받아 후보 고장 부품 3개와 추천 신뢰도 점수, 정비 조치 가이드라인을 산출하는 추천 엔진을 개발하고, 신뢰도가 실제 정확도와 맞는지(보정) 확인한다. 부품별 월간 고장 건수 예측은 전월값 기준 모델 대비 릿지·그래디언트 부스팅 앙상블의 MAE/RMSE 개선율과 R2로 평가한다.
"""DTC 조합과 차량 맥락을 입력받아 후보 고장 부품 3개와 추천 신뢰도 점수, 정비 조치 가이드라인을 산출하는 추천 엔진을 개발하고, 신뢰도가 실제 정확도와 맞는지(보정) 확인한다. 부품별 월간 고장 건수 예측은 전월값 기준 모델 대비 릿지·그래디언트 부스팅 앙상블의 MAE/RMSE 개선율과 R2로 평가한다."""
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.linear_model import LogisticRegression, Ridge
from sklearn.ensemble import GradientBoostingRegressor
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import make_pipeline
from sklearn.metrics import mean_absolute_error, mean_squared_error, r2_score

# ---------------------------------------------------------------------------
# [교육용가상자료] 가상 차량 100대의 18개월 고장 진단 코드(DTC)·정비 이력 생성기 (실제 부대·장비·정비 실적 없음)
# 부품 16종(1000가동시간당 고장률 차등), DTC 28종(부품 고유 코드, 짝 부품 공유 코드, 계통 증상 코드)
# 고장 1건(정비 이력)마다 DTC가 1~5개 동시 발생하고, 연쇄 고장 코드와 일시 잡음 코드가 섞인다.
# ---------------------------------------------------------------------------
PARTS = [  # (ID, 이름, 계통, 1000가동시간당 고장률, 가상 단가지수, 수리시간 h)
    ("P01", "연료분사기", "engine", 12.0, 3.0, 2.0), ("P02", "터보차저", "engine", 9.0, 8.0, 5.0),
    ("P03", "축전지", "electric", 6.5, 4.0, 1.5), ("P04", "발전기", "electric", 4.0, 9.0, 3.0),
    ("P05", "변속기 제어모듈", "drive", 3.0, 12.0, 6.0), ("P06", "냉각수 펌프", "engine", 2.2, 3.5, 3.0),
    ("P07", "유압 펌프", "hydraulic", 1.6, 10.0, 4.0), ("P08", "속도 센서", "drive", 1.2, 2.0, 1.5),
    ("P09", "ECU 커넥터", "electric", 0.9, 2.5, 1.5), ("P10", "연료 필터", "engine", 0.7, 0.8, 0.7),
    ("P11", "제동 밸브", "brake", 0.5, 6.0, 3.0), ("P12", "조향 액추에이터", "drive", 0.4, 9.0, 5.0),
    ("P13", "공기 필터", "engine", 0.3, 0.5, 0.5), ("P14", "통신 인터페이스 모듈", "electric", 0.25, 3.0, 1.5),
    ("P15", "윤활유 압력 센서", "engine", 0.2, 1.0, 1.0), ("P16", "팬 구동 모터", "cooling", 0.15, 2.0, 2.0)]
PID = [p[0] for p in PARTS]
PNAME = {p[0]: p[1] for p in PARTS}; PSYS = {p[0]: p[2] for p in PARTS}; RATE = {p[0]: p[3] for p in PARTS}
COST = {p[0]: p[4] for p in PARTS}; REPH = {p[0]: p[5] for p in PARTS}
PAIRS = [("P01", "P02"), ("P03", "P04"), ("P05", "P08"), ("P06", "P10"), ("P09", "P14"), ("P13", "P15")]
SHARED = {p: f"S{k + 1:02d}" for k, pr in enumerate(PAIRS) for p in pr}     # 짝 부품이 함께 쓰는 모호한 코드
SPEC = {p: f"C{p[1:]}1" for p in PID}                                          # 부품 고유 코드
SYS = ["engine", "electric", "drive", "hydraulic", "brake", "cooling"]
SYMP = {s: f"G{i + 1:02d}" for i, s in enumerate(SYS)}                         # 계통 증상 코드
CODES = sorted(set(SPEC.values()) | set(SHARED.values()) | set(SYMP.values()))
CASCADE = {"P01": [("P10", 0.20)], "P02": [("P06", 0.20)], "P03": [("P04", 0.25)], "P04": [("P09", 0.25)],
           "P05": [("P08", 0.30)], "P06": [("P16", 0.30)], "P09": [("P14", 0.20)], "P12": [("P07", 0.20)]}
CRIT_SYS = {"engine", "drive", "brake"}        # 기동·제동 불능 위험 계통(경보 대상)
MONTHS = 18


def make_fleet(n=100, seed=0):
    rng = np.random.default_rng(seed)
    f = pd.DataFrame({"veh": [f"V{i:03d}" for i in range(n)], "age": rng.integers(1, 26, n).astype(float),
                      "terrain": rng.uniform(0, 1, n), "load": rng.uniform(0, 1, n), "pm_gap": rng.uniform(20, 200, n),
                      "humid": rng.uniform(0, 1, n), "base_h": rng.uniform(55, 125, n)})
    f["mult"] = np.exp(0.045 * (f["age"] - 13) + 0.9 * (f["terrain"] - .5) + 0.8 * (f["load"] - .5)
                       + 0.006 * (f["pm_gap"] - 110) + 0.7 * (f["humid"] - .5))
    return f


def make_data(seed=1, months=MONTHS):
    """반환: fleet(차량 속성), ev(정비 이력 1건=1행: 발생 DTC 목록, 실제 고장 부품), log(DTC 발생 로그, 행=코드 1건)"""
    rng = np.random.default_rng(seed)
    fleet = make_fleet()
    V = len(fleet)
    temp = 12 + 14 * np.sin(2 * np.pi * (np.arange(months) - 3) / 12)            # 월평균 기온(가상)
    hours = fleet["base_h"].values[:, None] * (1 + 0.25 * np.sin(2 * np.pi * np.arange(months) / 12 + 1)) * rng.lognormal(0, .12, (V, months))
    fleet["hours"] = hours.sum(axis=1)                                           # 차량별 18개월 총 가동시간
    fleet.attrs["month_h"] = hours.sum(axis=0)                                   # 월별 전체 가동시간(고장률 분모)
    evs = []
    for p in PID:
        s = np.ones(months)
        if p in ("P03", "P04"): s = np.exp(-0.06 * (temp - 12))                   # 한랭기 전기계통 증가
        if p in ("P06", "P16"): s = np.exp(0.05 * (temp - 12))                    # 혹서기 냉각계통 증가
        cnt = rng.poisson(RATE[p] * hours / 1000 * fleet["mult"].values[:, None] * s[None, :])
        for v, m in zip(*np.nonzero(cnt)):
            for _ in range(cnt[v, m]):
                evs.append((fleet["veh"][v], int(m), m * 720 + rng.uniform(0, 720), p, float(temp[m] + rng.normal(0, 3))))
    ev = pd.DataFrame(evs, columns=["veh", "month", "t", "part", "temp"]).sort_values("t").reset_index(drop=True)
    ev["ev_id"] = [f"W{i:05d}" for i in range(len(ev))]
    rows, codes = [], []
    for e in ev.itertuples():
        c, r = [], rng.random(3)
        if r[0] < 0.6: c.append(SPEC[e.part])
        if e.part in SHARED and r[1] < 0.6: c.append(SHARED[e.part])
        if r[2] < 0.5: c.append(SYMP[PSYS[e.part]])
        if not c: c.append(SHARED.get(e.part, SPEC[e.part]))
        for q, pr in CASCADE.get(e.part, []):
            if rng.random() < pr: c.append(SPEC[q])
        if rng.random() < 0.12: c.append(SPEC[PID[rng.integers(0, 16)]])
        c = list(dict.fromkeys(c))
        rng.shuffle(c)                                                           # 동시 발생 코드의 기록 순서는 임의
        codes.append(c)
        for k, cd in enumerate(c):
            rows.append((e.ev_id, e.veh, e.t + k * 0.1, cd))
    ev["codes"] = codes
    log = pd.DataFrame(rows, columns=["ev_id", "veh", "ts", "code"])
    return fleet, ev, log

# ---------------------------------------------------------------------------
# 공통 분석 조각: 코드 멀티핫 행렬, 차량 맥락 피처, 학습/검증 분할(앞 12개월 학습, 뒤 6개월 검증)
# ---------------------------------------------------------------------------
CIDX = {c: i for i, c in enumerate(CODES)}
SPLIT_M = 12


def multi_hot(ev):
    X = np.zeros((len(ev), len(CODES)))
    for r, cs in enumerate(ev["codes"]):
        for c in cs:
            X[r, CIDX[c]] = 1
    return X


def context(ev, fleet):
    f = fleet.set_index("veh")
    return np.column_stack([ev["veh"].map(f["age"]) / 25, ev["veh"].map(f["pm_gap"]) / 200, ev["veh"].map(f["terrain"]),
                            ev["veh"].map(f["load"]), (ev["temp"] - 12) / 15])


def feats(ev, fleet):
    X = multi_hot(ev)
    n = X.sum(axis=1, keepdims=True) / 3
    return np.hstack([X, n, context(ev, fleet)])


def monthly_counts(ev):
    return ev.groupby(["month", "part"]).size().unstack(fill_value=0).reindex(columns=PID, fill_value=0)


def build_monthly(ev, fleet, top_n=8):
    """부품별 월간 고장 건수 예측용 표: 지연값(lag), 이동평균, 추세, 기대 건수(가동시간 x 설계 고장률), 기온 상호작용, 계절 성분"""
    mc = ev.groupby(["month", "part"]).size().unstack(fill_value=0)
    top = ev["part"].value_counts().index[:top_n]
    mh, tm = fleet.attrs["month_h"], ev.groupby("month")["temp"].mean().values
    rows = []
    for p in top:
        c = mc[p].values.astype(float)
        for m in range(3, MONTHS):
            L = np.log1p(c)
            rows.append({"part": p, "month": m, "y": c[m], "lag1": L[m - 1], "lag2": L[m - 2], "lag3": L[m - 3], "roll3": L[m - 3:m].mean(),
                         "trend": L[m - 1] - L[m - 2], "roll3_std": L[m - 3:m].std(), "log_expected": np.log(mh[m] / 1000 * RATE[p]),
                         "rate_lag1": c[m - 1] / (mh[m - 1] / 1000) / RATE[p], "temp": tm[m] / 10, "cold_elec": max(0, 12 - tm[m]) / 10 * (p in ("P03", "P04")),
                         "heat_cool": max(0, tm[m] - 12) / 10 * (p in ("P06", "P16")), "sin": np.sin(2 * np.pi * m / 12), "cos": np.cos(2 * np.pi * m / 12)})
    D = pd.DataFrame(rows)
    return D, [c for c in D.columns if c not in ("part", "month", "y")], top

# ---------------------------------------------------------------------------
# [교육용가상자료] 부품별 정비 조치 가이드라인(요약 3단계). 실제 정비 교범이 아니라 교육용으로 만든 예시 문구다.
# ---------------------------------------------------------------------------
GUIDE = {
    "P01": "연료 압력 측정 -> 분사기 누설·막힘 확인 -> 불량 시 분사기 교체", "P02": "흡기 압력·이물 점검 -> 축 유격 확인 -> 불량 시 터보차저 교체",
    "P03": "단자 전압·부식 점검 -> 충전 계통 확인 -> 불량 시 축전지 교체", "P04": "발전 전압 측정 -> 벨트·배선 점검 -> 불량 시 발전기 교체",
    "P05": "제어모듈 소프트웨어·커넥터 점검 -> 변속 이력 확인 -> 불량 시 모듈 교체", "P06": "냉각수량·누수 점검 -> 펌프 작동음 확인 -> 불량 시 펌프 교체",
    "P07": "유압 오일량·압력 점검 -> 펌프 누설 확인 -> 불량 시 펌프 교체", "P08": "센서 배선·간극 점검 -> 신호 파형 확인 -> 불량 시 센서 교체",
    "P09": "커넥터 접촉·부식 점검 -> 핀 상태 확인 -> 불량 시 커넥터 교체", "P10": "필터 오염도 점검 -> 연료 흐름 확인 -> 불량 시 필터 교체",
    "P11": "제동 압력 점검 -> 밸브 작동 확인 -> 불량 시 밸브 교체(안전 확인 필수)", "P12": "조향 반응 점검 -> 액추에이터 작동 확인 -> 불량 시 교체(안전 확인 필수)",
    "P13": "필터 차압 점검 -> 오염 확인 -> 불량 시 필터 교체", "P14": "통신 링크 상태 점검 -> 모듈 전원 확인 -> 불량 시 모듈 교체",
    "P15": "압력 센서 신호 점검 -> 윤활유량 확인 -> 불량 시 센서 교체", "P16": "팬 모터 전류 점검 -> 회전 확인 -> 불량 시 모터 교체"}

fleet, ev, log = make_data()
train, test = ev[ev["month"] < SPLIT_M], ev[ev["month"] >= SPLIT_M]

# 1) 추천 엔진: DTC 멀티핫 + 맥락 -> 부품별 확률(= 추천 신뢰도 점수)
Xtr, Xte = feats(train, fleet), feats(test, fleet)
clf = LogisticRegression(C=3, max_iter=500).fit(Xtr, train["part"])
P = clf.predict_proba(Xte)
cls = clf.classes_
top1 = cls[P.argmax(axis=1)]
conf = P.max(axis=1)
y = test["part"].values
top3 = np.mean([t in cls[np.argsort(-p)[:3]] for t, p in zip(y, P)])
mp = train.assign(first=train["codes"].str[0]).groupby("first")["part"].agg(lambda s: s.value_counts().index[0])
base_acc = (test["codes"].str[0].map(mp).values == y).mean()
print(f"[교육용가상자료] 추천 엔진 검증 {len(test)}건: top-1 정확도 {(top1 == y).mean():.1%} (단일 코드 Baseline {base_acc:.1%}), top-3 포함률 {top3:.1%}")

# 2) 신뢰도 점수 보정: 신뢰도 구간별 실제 정확도
bins = [0, 0.5, 0.7, 0.9, 1.01]
lab = ["~0.5", "0.5~0.7", "0.7~0.9", "0.9~"]
cb = pd.cut(conf, bins, labels=lab, right=False)
cal = pd.DataFrame({"conf": conf, "ok": top1 == y, "b": cb}).groupby("b", observed=True).agg(건수=("ok", "size"), 평균신뢰도=("conf", "mean"), 실제정확도=("ok", "mean"))
print("\n[신뢰도 보정] 구간 | 건수 | 평균 신뢰도 | 실제 정확도")
for k, r in cal.iterrows():
    print(f" {k:8s} {int(r['건수']):5d}  {r['평균신뢰도']:.2f}  {r['실제정확도']:.2f}")
ece = float((cal["건수"] * (cal["평균신뢰도"] - cal["실제정확도"]).abs()).sum() / cal["건수"].sum())
print(f"기대 보정 오차(ECE) {ece:.3f}")

# 3) 정비 조치 가이드라인 카드(상위 후보 3개와 신뢰도)
print("\n[진단 카드 예시] 정비 조치 가이드라인")
for i in (int(np.argmin(np.abs(conf - 0.95))), int(np.argmin(np.abs(conf - 0.55)))):
    order = np.argsort(-P[i])[:3]
    print(f" 입력 DTC {test['codes'].iloc[i]} -> " + ", ".join(f"{PNAME[cls[j]]} {P[i, j]:.0%}" for j in order))
    print(f"   조치: {GUIDE[cls[order[0]]]}" + ("" if conf[i] >= 0.7 else "  (신뢰도 낮음: 숙련 정비관 확인 권고)"))

# 4) 월간 고장 건수 예측: 기준(전월값) vs 고도화(릿지 + GBR 앙상블, 소규모 격자 탐색)
D, FE, top = build_monthly(ev, fleet)
a, b = D[D["month"] < 13], D[D["month"] >= 13]
ya, yb = np.log(a["y"]), b["y"].values
naive = np.expm1(b["lag1"].values)
def ens(tr, te, al, dep):
    g = GradientBoostingRegressor(n_estimators=150, max_depth=dep, learning_rate=0.05, random_state=0).fit(tr[FE], np.log(tr["y"]))
    r = make_pipeline(StandardScaler(), Ridge(alpha=al)).fit(tr[FE], np.log(tr["y"]))
    return np.exp(0.5 * g.predict(te[FE]) + 0.5 * r.predict(te[FE]))


inner_tr, inner_va = a[a["month"] < 11], a[a["month"] >= 11]          # 하이퍼파라미터는 학습 구간 안에서만 선택(검증 구간 미사용)
score = {(al, dep): mean_absolute_error(inner_va["y"], ens(inner_tr, inner_va, al, dep)) for al in (1, 3, 10) for dep in (2, 3)}
al, dep = min(score, key=score.get)
adv = ens(a, b, al, dep)
met = lambda p: (mean_absolute_error(yb, p), mean_squared_error(yb, p) ** 0.5, r2_score(yb, p))
m0, m1 = met(naive), met(adv)
print(f"\n[월간 고장 건수 예측] 검증 {len(b)}건(상위 {len(top)}개 부품 x 5개월), 선택 하이퍼파라미터 ridge alpha={al}, GBR depth={dep}")
print(f" 기준(전월값)  MAE {m0[0]:.2f}  RMSE {m0[1]:.2f}  R2 {m0[2]:.3f}")
print(f" 고도화(앙상블) MAE {m1[0]:.2f}  RMSE {m1[1]:.2f}  R2 {m1[2]:.3f}")
imp_mae, imp_rmse = 1 - m1[0] / m0[0], 1 - m1[1] / m0[1]
print(f"개선율 MAE {imp_mae:.1%}, RMSE {imp_rmse:.1%} (목표 20% 이상: {'달성' if min(imp_mae, imp_rmse) >= 0.2 else '미달'}), R2 {m1[2]:.3f} (목표 0.85 이상: {'달성' if m1[2] >= 0.85 else '미달'})")

fig, ax = plt.subplots(1, 2, figsize=(10, 3.5))
ax[0].plot([0, 1], [0, 1], "k--"); ax[0].plot(cal["평균신뢰도"], cal["실제정확도"], marker="o"); ax[0].set_title("Confidence calibration")
ax[1].scatter(yb, adv, s=12, label="ensemble"); ax[1].scatter(yb, naive, s=12, label="naive"); ax[1].plot([0, yb.max()], [0, yb.max()], "k--")
ax[1].set_title("Monthly failure count: actual vs predicted"); ax[1].legend()
plt.tight_layout(); plt.show()

실행 결과 예시

[교육용가상자료] 추천 엔진 검증 3890건: top-1 정확도 83.9% (단일 코드 Baseline 68.7%), top-3 포함률 99.5%

[신뢰도 보정] 구간 | 건수 | 평균 신뢰도 | 실제 정확도
 ~0.5       155  0.46  0.48
 0.5~0.7   1123  0.60  0.61
 0.7~0.9    387  0.78  0.74
 0.9~      2225  0.99  1.00
기대 보정 오차(ECE) 0.014

[진단 카드 예시] 정비 조치 가이드라인
 입력 DTC ['G01', 'C101', 'S01'] -> 연료분사기 95%, 터보차저 5%, 연료 필터 0%
   조치: 연료 압력 측정 -> 분사기 누설·막힘 확인 -> 불량 시 분사기 교체
 입력 DTC ['G01'] -> 연료분사기 55%, 터보차저 34%, 연료 필터 5%
   조치: 연료 압력 측정 -> 분사기 누설·막힘 확인 -> 불량 시 분사기 교체  (신뢰도 낮음: 숙련 정비관 확인 권고)

[월간 고장 건수 예측] 검증 40건(상위 8개 부품 x 5개월), 선택 하이퍼파라미터 ridge alpha=10, GBR depth=3
 기준(전월값)  MAE 15.50  RMSE 21.96  R2 0.860
 고도화(앙상블) MAE 10.62  RMSE 14.47  R2 0.939
개선율 MAE 31.5%, RMSE 34.1% (목표 20% 이상: 달성), R2 0.939 (목표 0.85 이상: 달성)

미션 6. 정비병용 모바일/웹 고장 코드 진단 및 정비 조치 절차 대시보드 구현

  • 난이도: 중급~고급 / 예상 소요시간: 3.0시간
  • 코드 설명: 정비병이 모바일/웹 화면에서 DTC를 입력하면 후보 고장 부품·신뢰도·정비 조치 절차와 판단 근거(기여도 상위 3개 인자)를 보여 주는 진단 대시보드를 구현한다. 모든 예측 건에 상위 3개 인자를 자동 산출하고 근거 제거 시 확률이 실제로 떨어지는지 점검하며, 자연어 요약과 경보 현황 화면을 만든다.
"""정비병이 모바일/웹 화면에서 DTC를 입력하면 후보 고장 부품·신뢰도·정비 조치 절차와 판단 근거(기여도 상위 3개 인자)를 보여 주는 진단 대시보드를 구현한다. 모든 예측 건에 상위 3개 인자를 자동 산출하고 근거 제거 시 확률이 실제로 떨어지는지 점검하며, 자연어 요약과 경보 현황 화면을 만든다."""
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.linear_model import LogisticRegression

# ---------------------------------------------------------------------------
# [교육용가상자료] 가상 차량 100대의 18개월 고장 진단 코드(DTC)·정비 이력 생성기 (실제 부대·장비·정비 실적 없음)
# 부품 16종(1000가동시간당 고장률 차등), DTC 28종(부품 고유 코드, 짝 부품 공유 코드, 계통 증상 코드)
# 고장 1건(정비 이력)마다 DTC가 1~5개 동시 발생하고, 연쇄 고장 코드와 일시 잡음 코드가 섞인다.
# ---------------------------------------------------------------------------
PARTS = [  # (ID, 이름, 계통, 1000가동시간당 고장률, 가상 단가지수, 수리시간 h)
    ("P01", "연료분사기", "engine", 12.0, 3.0, 2.0), ("P02", "터보차저", "engine", 9.0, 8.0, 5.0),
    ("P03", "축전지", "electric", 6.5, 4.0, 1.5), ("P04", "발전기", "electric", 4.0, 9.0, 3.0),
    ("P05", "변속기 제어모듈", "drive", 3.0, 12.0, 6.0), ("P06", "냉각수 펌프", "engine", 2.2, 3.5, 3.0),
    ("P07", "유압 펌프", "hydraulic", 1.6, 10.0, 4.0), ("P08", "속도 센서", "drive", 1.2, 2.0, 1.5),
    ("P09", "ECU 커넥터", "electric", 0.9, 2.5, 1.5), ("P10", "연료 필터", "engine", 0.7, 0.8, 0.7),
    ("P11", "제동 밸브", "brake", 0.5, 6.0, 3.0), ("P12", "조향 액추에이터", "drive", 0.4, 9.0, 5.0),
    ("P13", "공기 필터", "engine", 0.3, 0.5, 0.5), ("P14", "통신 인터페이스 모듈", "electric", 0.25, 3.0, 1.5),
    ("P15", "윤활유 압력 센서", "engine", 0.2, 1.0, 1.0), ("P16", "팬 구동 모터", "cooling", 0.15, 2.0, 2.0)]
PID = [p[0] for p in PARTS]
PNAME = {p[0]: p[1] for p in PARTS}; PSYS = {p[0]: p[2] for p in PARTS}; RATE = {p[0]: p[3] for p in PARTS}
COST = {p[0]: p[4] for p in PARTS}; REPH = {p[0]: p[5] for p in PARTS}
PAIRS = [("P01", "P02"), ("P03", "P04"), ("P05", "P08"), ("P06", "P10"), ("P09", "P14"), ("P13", "P15")]
SHARED = {p: f"S{k + 1:02d}" for k, pr in enumerate(PAIRS) for p in pr}     # 짝 부품이 함께 쓰는 모호한 코드
SPEC = {p: f"C{p[1:]}1" for p in PID}                                          # 부품 고유 코드
SYS = ["engine", "electric", "drive", "hydraulic", "brake", "cooling"]
SYMP = {s: f"G{i + 1:02d}" for i, s in enumerate(SYS)}                         # 계통 증상 코드
CODES = sorted(set(SPEC.values()) | set(SHARED.values()) | set(SYMP.values()))
CASCADE = {"P01": [("P10", 0.20)], "P02": [("P06", 0.20)], "P03": [("P04", 0.25)], "P04": [("P09", 0.25)],
           "P05": [("P08", 0.30)], "P06": [("P16", 0.30)], "P09": [("P14", 0.20)], "P12": [("P07", 0.20)]}
CRIT_SYS = {"engine", "drive", "brake"}        # 기동·제동 불능 위험 계통(경보 대상)
MONTHS = 18


def make_fleet(n=100, seed=0):
    rng = np.random.default_rng(seed)
    f = pd.DataFrame({"veh": [f"V{i:03d}" for i in range(n)], "age": rng.integers(1, 26, n).astype(float),
                      "terrain": rng.uniform(0, 1, n), "load": rng.uniform(0, 1, n), "pm_gap": rng.uniform(20, 200, n),
                      "humid": rng.uniform(0, 1, n), "base_h": rng.uniform(55, 125, n)})
    f["mult"] = np.exp(0.045 * (f["age"] - 13) + 0.9 * (f["terrain"] - .5) + 0.8 * (f["load"] - .5)
                       + 0.006 * (f["pm_gap"] - 110) + 0.7 * (f["humid"] - .5))
    return f


def make_data(seed=1, months=MONTHS):
    """반환: fleet(차량 속성), ev(정비 이력 1건=1행: 발생 DTC 목록, 실제 고장 부품), log(DTC 발생 로그, 행=코드 1건)"""
    rng = np.random.default_rng(seed)
    fleet = make_fleet()
    V = len(fleet)
    temp = 12 + 14 * np.sin(2 * np.pi * (np.arange(months) - 3) / 12)            # 월평균 기온(가상)
    hours = fleet["base_h"].values[:, None] * (1 + 0.25 * np.sin(2 * np.pi * np.arange(months) / 12 + 1)) * rng.lognormal(0, .12, (V, months))
    fleet["hours"] = hours.sum(axis=1)                                           # 차량별 18개월 총 가동시간
    fleet.attrs["month_h"] = hours.sum(axis=0)                                   # 월별 전체 가동시간(고장률 분모)
    evs = []
    for p in PID:
        s = np.ones(months)
        if p in ("P03", "P04"): s = np.exp(-0.06 * (temp - 12))                   # 한랭기 전기계통 증가
        if p in ("P06", "P16"): s = np.exp(0.05 * (temp - 12))                    # 혹서기 냉각계통 증가
        cnt = rng.poisson(RATE[p] * hours / 1000 * fleet["mult"].values[:, None] * s[None, :])
        for v, m in zip(*np.nonzero(cnt)):
            for _ in range(cnt[v, m]):
                evs.append((fleet["veh"][v], int(m), m * 720 + rng.uniform(0, 720), p, float(temp[m] + rng.normal(0, 3))))
    ev = pd.DataFrame(evs, columns=["veh", "month", "t", "part", "temp"]).sort_values("t").reset_index(drop=True)
    ev["ev_id"] = [f"W{i:05d}" for i in range(len(ev))]
    rows, codes = [], []
    for e in ev.itertuples():
        c, r = [], rng.random(3)
        if r[0] < 0.6: c.append(SPEC[e.part])
        if e.part in SHARED and r[1] < 0.6: c.append(SHARED[e.part])
        if r[2] < 0.5: c.append(SYMP[PSYS[e.part]])
        if not c: c.append(SHARED.get(e.part, SPEC[e.part]))
        for q, pr in CASCADE.get(e.part, []):
            if rng.random() < pr: c.append(SPEC[q])
        if rng.random() < 0.12: c.append(SPEC[PID[rng.integers(0, 16)]])
        c = list(dict.fromkeys(c))
        rng.shuffle(c)                                                           # 동시 발생 코드의 기록 순서는 임의
        codes.append(c)
        for k, cd in enumerate(c):
            rows.append((e.ev_id, e.veh, e.t + k * 0.1, cd))
    ev["codes"] = codes
    log = pd.DataFrame(rows, columns=["ev_id", "veh", "ts", "code"])
    return fleet, ev, log

# ---------------------------------------------------------------------------
# 공통 분석 조각: 코드 멀티핫 행렬, 차량 맥락 피처, 학습/검증 분할(앞 12개월 학습, 뒤 6개월 검증)
# ---------------------------------------------------------------------------
CIDX = {c: i for i, c in enumerate(CODES)}
SPLIT_M = 12


def multi_hot(ev):
    X = np.zeros((len(ev), len(CODES)))
    for r, cs in enumerate(ev["codes"]):
        for c in cs:
            X[r, CIDX[c]] = 1
    return X


def context(ev, fleet):
    f = fleet.set_index("veh")
    return np.column_stack([ev["veh"].map(f["age"]) / 25, ev["veh"].map(f["pm_gap"]) / 200, ev["veh"].map(f["terrain"]),
                            ev["veh"].map(f["load"]), (ev["temp"] - 12) / 15])


def feats(ev, fleet):
    X = multi_hot(ev)
    n = X.sum(axis=1, keepdims=True) / 3
    return np.hstack([X, n, context(ev, fleet)])


def monthly_counts(ev):
    return ev.groupby(["month", "part"]).size().unstack(fill_value=0).reindex(columns=PID, fill_value=0)


def build_monthly(ev, fleet, top_n=8):
    """부품별 월간 고장 건수 예측용 표: 지연값(lag), 이동평균, 추세, 기대 건수(가동시간 x 설계 고장률), 기온 상호작용, 계절 성분"""
    mc = ev.groupby(["month", "part"]).size().unstack(fill_value=0)
    top = ev["part"].value_counts().index[:top_n]
    mh, tm = fleet.attrs["month_h"], ev.groupby("month")["temp"].mean().values
    rows = []
    for p in top:
        c = mc[p].values.astype(float)
        for m in range(3, MONTHS):
            L = np.log1p(c)
            rows.append({"part": p, "month": m, "y": c[m], "lag1": L[m - 1], "lag2": L[m - 2], "lag3": L[m - 3], "roll3": L[m - 3:m].mean(),
                         "trend": L[m - 1] - L[m - 2], "roll3_std": L[m - 3:m].std(), "log_expected": np.log(mh[m] / 1000 * RATE[p]),
                         "rate_lag1": c[m - 1] / (mh[m - 1] / 1000) / RATE[p], "temp": tm[m] / 10, "cold_elec": max(0, 12 - tm[m]) / 10 * (p in ("P03", "P04")),
                         "heat_cool": max(0, tm[m] - 12) / 10 * (p in ("P06", "P16")), "sin": np.sin(2 * np.pi * m / 12), "cos": np.cos(2 * np.pi * m / 12)})
    D = pd.DataFrame(rows)
    return D, [c for c in D.columns if c not in ("part", "month", "y")], top

# ---------------------------------------------------------------------------
# [교육용가상자료] 부품별 정비 조치 가이드라인(요약 3단계). 실제 정비 교범이 아니라 교육용으로 만든 예시 문구다.
# ---------------------------------------------------------------------------
GUIDE = {
    "P01": "연료 압력 측정 -> 분사기 누설·막힘 확인 -> 불량 시 분사기 교체", "P02": "흡기 압력·이물 점검 -> 축 유격 확인 -> 불량 시 터보차저 교체",
    "P03": "단자 전압·부식 점검 -> 충전 계통 확인 -> 불량 시 축전지 교체", "P04": "발전 전압 측정 -> 벨트·배선 점검 -> 불량 시 발전기 교체",
    "P05": "제어모듈 소프트웨어·커넥터 점검 -> 변속 이력 확인 -> 불량 시 모듈 교체", "P06": "냉각수량·누수 점검 -> 펌프 작동음 확인 -> 불량 시 펌프 교체",
    "P07": "유압 오일량·압력 점검 -> 펌프 누설 확인 -> 불량 시 펌프 교체", "P08": "센서 배선·간극 점검 -> 신호 파형 확인 -> 불량 시 센서 교체",
    "P09": "커넥터 접촉·부식 점검 -> 핀 상태 확인 -> 불량 시 커넥터 교체", "P10": "필터 오염도 점검 -> 연료 흐름 확인 -> 불량 시 필터 교체",
    "P11": "제동 압력 점검 -> 밸브 작동 확인 -> 불량 시 밸브 교체(안전 확인 필수)", "P12": "조향 반응 점검 -> 액추에이터 작동 확인 -> 불량 시 교체(안전 확인 필수)",
    "P13": "필터 차압 점검 -> 오염 확인 -> 불량 시 필터 교체", "P14": "통신 링크 상태 점검 -> 모듈 전원 확인 -> 불량 시 모듈 교체",
    "P15": "압력 센서 신호 점검 -> 윤활유량 확인 -> 불량 시 센서 교체", "P16": "팬 모터 전류 점검 -> 회전 확인 -> 불량 시 모터 교체"}

SYSKR = {"engine": "엔진", "electric": "전기", "drive": "구동", "hydraulic": "유압", "brake": "제동", "cooling": "냉각"}
SHNAME = {SHARED[p[0]]: f"{PNAME[p[0]]}·{PNAME[p[1]]} 공유" for p in PAIRS}


def code_name(c):
    if c.startswith("S"):
        return f"공유 코드 {c}({SHNAME[c]})"
    if c.startswith("G"):
        return f"{SYSKR[[s for s, v in SYMP.items() if v == c][0]]} 계통 증상 코드 {c}"
    return f"{PNAME['P' + c[1:3]]} 고유 코드 {c}"


FNAME = [code_name(c) for c in CODES] + ["동시 발생 코드 수", "차령", "예방정비 경과시간", "지형 가혹도", "운용 부하", "기온"]
NC = len(CODES)

fleet, ev, log = make_data()
train, test = ev[ev["month"] < SPLIT_M], ev[ev["month"] >= SPLIT_M].reset_index(drop=True)
Xtr, Xte = feats(train, fleet), feats(test, fleet)
clf = LogisticRegression(C=3, max_iter=500).fit(Xtr, train["part"])
P = clf.predict_proba(Xte)
cls, mu = clf.classes_, Xtr.mean(axis=0)
pred = P.argmax(axis=1)


def explain(i, k=3):
    """예측 부품에 대한 입력 인자별 기여도 = 계수 x (입력값 - 학습 평균). 값이 큰 인자 k개를 반환."""
    contrib = clf.coef_[pred[i]] * (Xte[i] - mu)
    contrib[:NC][Xte[i][:NC] == 0] = -np.inf                  # 입력되지 않은 코드는 근거에서 제외
    idx = np.argsort(-contrib)[:k]
    return [(FNAME[j], contrib[j]) for j in idx], idx


def summary(i):
    f, _ = explain(i)
    return (f"{PNAME[cls[pred[i]]]} 고장 가능성 {P[i, pred[i]]:.0%}. 주요 근거: " + ", ".join(n for n, _ in f) + ".")


# 1) 전 예측 건 기여도 자동 산출 + 근거 유효성 점검
ok3, drop = 0, 0
for i in range(len(test)):
    f, idx = explain(i)
    ok3 += len(f) == 3
    x2 = Xte[i].copy(); x2[idx[0]] = mu[idx[0]]              # 가장 큰 근거를 평균값으로 되돌려 본다
    drop += clf.predict_proba(x2[None])[0, pred[i]] < P[i, pred[i]]
print(f"[교육용가상자료] 예측 {len(test)}건 중 기여도 상위 3개 인자 산출 {ok3}건 (산출률 {ok3 / len(test):.0%}, 목표 100%: {'달성' if ok3 == len(test) else '미달'})")
print(f"근거 유효성: 상위 인자를 평균값으로 되돌리면 예측 확률이 낮아지는 비율 {drop / len(test):.1%}")

# 2) 모바일 진단 카드 2건: 신뢰도 높음 / 낮음
print("\n[모바일 진단 카드]")
for tgt in (0.95, 0.55):
    i = int(np.argmin(np.abs(P.max(axis=1) - tgt)))
    f, _ = explain(i)
    print(f" 입력 DTC {test['codes'][i]}  (실제 고장 {PNAME[test['part'][i]]})")
    print(f"   진단 {PNAME[cls[pred[i]]]} 신뢰도 {P[i, pred[i]]:.0%} | 근거: " + " / ".join(f"{n}({v:+.1f})" for n, v in f))
    print(f"   조치 절차: {GUIDE[cls[pred[i]]]}")
    print(f"   요약: {summary(i)}")

# 3) 웹 대시보드: 최근 달 현황과 경보
last = test[test["month"] == MONTHS - 1]
li = last.index.values
alert = [(PSYS[cls[pred[i]]] in CRIT_SYS) and P[i, pred[i]] >= 0.7 for i in li]
print(f"\n[웹 대시보드] 최근 달 정비 건 {len(last)}건, 기동·제동 계통 경보 {sum(alert)}건, 신뢰도 낮음(0.7 미만) {int((P[li].max(axis=1) < 0.7).sum())}건")
cnt = pd.Series([cls[pred[i]] for i in li]).value_counts().head(3)
print("  다빈도 진단 부품: " + ", ".join(f"{PNAME[p]} {n}건" for p, n in cnt.items()))

fig, ax = plt.subplots(2, 2, figsize=(11, 7))
cnt5 = pd.Series([cls[pred[i]] for i in li]).value_counts().head(8)
ax[0, 0].bar(cnt5.index, cnt5.values); ax[0, 0].set_title("Diagnosed parts (last month)")
mon = ev.groupby("month").size(); ax[0, 1].plot(mon.index, mon.values, marker="o"); ax[0, 1].set_title("Monthly maintenance cases")
j = int(np.argmin(np.abs(P.max(axis=1) - 0.55))); f, _ = explain(j)
ax[1, 0].barh([f"factor {k + 1}" for k in range(3)][::-1], [v for _, v in f][::-1]); ax[1, 0].set_title("Top-3 contributions (low-confidence case)")
ax[1, 1].hist(P.max(axis=1), bins=20); ax[1, 1].axvline(0.7, ls="--", color="k"); ax[1, 1].set_title("Confidence distribution (line = 0.7)")
plt.tight_layout(); plt.show()

실행 결과 예시

[교육용가상자료] 예측 3890건 중 기여도 상위 3개 인자 산출 3890건 (산출률 100%, 목표 100%: 달성)
근거 유효성: 상위 인자를 평균값으로 되돌리면 예측 확률이 낮아지는 비율 92.5%

[모바일 진단 카드]
 입력 DTC ['G01', 'C101', 'S01']  (실제 고장 연료분사기)
   진단 연료분사기 신뢰도 95% | 근거: 공유 코드 S01(연료분사기·터보차저 공유)(+4.3) / 엔진 계통 증상 코드 G01(+3.0) / 연료 필터 고유 코드 C101(+1.6)
   조치 절차: 연료 압력 측정 -> 분사기 누설·막힘 확인 -> 불량 시 분사기 교체
   요약: 연료분사기 고장 가능성 95%. 주요 근거: 공유 코드 S01(연료분사기·터보차저 공유), 엔진 계통 증상 코드 G01, 연료 필터 고유 코드 C101.
 입력 DTC ['G01']  (실제 고장 터보차저)
   진단 연료분사기 신뢰도 55% | 근거: 엔진 계통 증상 코드 G01(+3.0) / 기온(+0.1) / 운용 부하(+0.1)
   조치 절차: 연료 압력 측정 -> 분사기 누설·막힘 확인 -> 불량 시 분사기 교체
   요약: 연료분사기 고장 가능성 55%. 주요 근거: 엔진 계통 증상 코드 G01, 기온, 운용 부하.

[웹 대시보드] 최근 달 정비 건 457건, 기동·제동 계통 경보 238건, 신뢰도 낮음(0.7 미만) 148건
  다빈도 진단 부품: 연료분사기 189건, 터보차저 72건, 냉각수 펌프 40건

미션 7. 복합 고장 의심 시 숙련 정비관 2차 정밀 진단(HITL) 워크플로우 설계

  • 난이도: 고급 / 예상 소요시간: 3.5시간
  • 코드 설명: 복합 고장이 의심되는 정비 건(신뢰도 낮음, 서로 다른 계통의 DTC 동시 발생)을 숙련 정비관 2차 정밀 진단 대기열에 올려 승인·정정·무응답(엔진 안 유지·재확인 표시)을 사유와 함께 해시 체인 감사 로그에 남기는 HITL 워크플로우 시뮬레이터를 구현한다. 자동 확정 0건, 오진단 비용 절감률, 신뢰도 기준·정비관 수용량·고장 급증에 대한 민감도를 확인한다.
"""복합 고장이 의심되는 정비 건(신뢰도 낮음, 서로 다른 계통의 DTC 동시 발생)을 숙련 정비관 2차 정밀 진단 대기열에 올려 승인·정정·무응답(엔진 안 유지·재확인 표시)을 사유와 함께 해시 체인 감사 로그에 남기는 HITL 워크플로우 시뮬레이터를 구현한다. 자동 확정 0건, 오진단 비용 절감률, 신뢰도 기준·정비관 수용량·고장 급증에 대한 민감도를 확인한다."""
import hashlib
import json
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.linear_model import LogisticRegression

# ---------------------------------------------------------------------------
# [교육용가상자료] 가상 차량 100대의 18개월 고장 진단 코드(DTC)·정비 이력 생성기 (실제 부대·장비·정비 실적 없음)
# 부품 16종(1000가동시간당 고장률 차등), DTC 28종(부품 고유 코드, 짝 부품 공유 코드, 계통 증상 코드)
# 고장 1건(정비 이력)마다 DTC가 1~5개 동시 발생하고, 연쇄 고장 코드와 일시 잡음 코드가 섞인다.
# ---------------------------------------------------------------------------
PARTS = [  # (ID, 이름, 계통, 1000가동시간당 고장률, 가상 단가지수, 수리시간 h)
    ("P01", "연료분사기", "engine", 12.0, 3.0, 2.0), ("P02", "터보차저", "engine", 9.0, 8.0, 5.0),
    ("P03", "축전지", "electric", 6.5, 4.0, 1.5), ("P04", "발전기", "electric", 4.0, 9.0, 3.0),
    ("P05", "변속기 제어모듈", "drive", 3.0, 12.0, 6.0), ("P06", "냉각수 펌프", "engine", 2.2, 3.5, 3.0),
    ("P07", "유압 펌프", "hydraulic", 1.6, 10.0, 4.0), ("P08", "속도 센서", "drive", 1.2, 2.0, 1.5),
    ("P09", "ECU 커넥터", "electric", 0.9, 2.5, 1.5), ("P10", "연료 필터", "engine", 0.7, 0.8, 0.7),
    ("P11", "제동 밸브", "brake", 0.5, 6.0, 3.0), ("P12", "조향 액추에이터", "drive", 0.4, 9.0, 5.0),
    ("P13", "공기 필터", "engine", 0.3, 0.5, 0.5), ("P14", "통신 인터페이스 모듈", "electric", 0.25, 3.0, 1.5),
    ("P15", "윤활유 압력 센서", "engine", 0.2, 1.0, 1.0), ("P16", "팬 구동 모터", "cooling", 0.15, 2.0, 2.0)]
PID = [p[0] for p in PARTS]
PNAME = {p[0]: p[1] for p in PARTS}; PSYS = {p[0]: p[2] for p in PARTS}; RATE = {p[0]: p[3] for p in PARTS}
COST = {p[0]: p[4] for p in PARTS}; REPH = {p[0]: p[5] for p in PARTS}
PAIRS = [("P01", "P02"), ("P03", "P04"), ("P05", "P08"), ("P06", "P10"), ("P09", "P14"), ("P13", "P15")]
SHARED = {p: f"S{k + 1:02d}" for k, pr in enumerate(PAIRS) for p in pr}     # 짝 부품이 함께 쓰는 모호한 코드
SPEC = {p: f"C{p[1:]}1" for p in PID}                                          # 부품 고유 코드
SYS = ["engine", "electric", "drive", "hydraulic", "brake", "cooling"]
SYMP = {s: f"G{i + 1:02d}" for i, s in enumerate(SYS)}                         # 계통 증상 코드
CODES = sorted(set(SPEC.values()) | set(SHARED.values()) | set(SYMP.values()))
CASCADE = {"P01": [("P10", 0.20)], "P02": [("P06", 0.20)], "P03": [("P04", 0.25)], "P04": [("P09", 0.25)],
           "P05": [("P08", 0.30)], "P06": [("P16", 0.30)], "P09": [("P14", 0.20)], "P12": [("P07", 0.20)]}
CRIT_SYS = {"engine", "drive", "brake"}        # 기동·제동 불능 위험 계통(경보 대상)
MONTHS = 18


def make_fleet(n=100, seed=0):
    rng = np.random.default_rng(seed)
    f = pd.DataFrame({"veh": [f"V{i:03d}" for i in range(n)], "age": rng.integers(1, 26, n).astype(float),
                      "terrain": rng.uniform(0, 1, n), "load": rng.uniform(0, 1, n), "pm_gap": rng.uniform(20, 200, n),
                      "humid": rng.uniform(0, 1, n), "base_h": rng.uniform(55, 125, n)})
    f["mult"] = np.exp(0.045 * (f["age"] - 13) + 0.9 * (f["terrain"] - .5) + 0.8 * (f["load"] - .5)
                       + 0.006 * (f["pm_gap"] - 110) + 0.7 * (f["humid"] - .5))
    return f


def make_data(seed=1, months=MONTHS):
    """반환: fleet(차량 속성), ev(정비 이력 1건=1행: 발생 DTC 목록, 실제 고장 부품), log(DTC 발생 로그, 행=코드 1건)"""
    rng = np.random.default_rng(seed)
    fleet = make_fleet()
    V = len(fleet)
    temp = 12 + 14 * np.sin(2 * np.pi * (np.arange(months) - 3) / 12)            # 월평균 기온(가상)
    hours = fleet["base_h"].values[:, None] * (1 + 0.25 * np.sin(2 * np.pi * np.arange(months) / 12 + 1)) * rng.lognormal(0, .12, (V, months))
    fleet["hours"] = hours.sum(axis=1)                                           # 차량별 18개월 총 가동시간
    fleet.attrs["month_h"] = hours.sum(axis=0)                                   # 월별 전체 가동시간(고장률 분모)
    evs = []
    for p in PID:
        s = np.ones(months)
        if p in ("P03", "P04"): s = np.exp(-0.06 * (temp - 12))                   # 한랭기 전기계통 증가
        if p in ("P06", "P16"): s = np.exp(0.05 * (temp - 12))                    # 혹서기 냉각계통 증가
        cnt = rng.poisson(RATE[p] * hours / 1000 * fleet["mult"].values[:, None] * s[None, :])
        for v, m in zip(*np.nonzero(cnt)):
            for _ in range(cnt[v, m]):
                evs.append((fleet["veh"][v], int(m), m * 720 + rng.uniform(0, 720), p, float(temp[m] + rng.normal(0, 3))))
    ev = pd.DataFrame(evs, columns=["veh", "month", "t", "part", "temp"]).sort_values("t").reset_index(drop=True)
    ev["ev_id"] = [f"W{i:05d}" for i in range(len(ev))]
    rows, codes = [], []
    for e in ev.itertuples():
        c, r = [], rng.random(3)
        if r[0] < 0.6: c.append(SPEC[e.part])
        if e.part in SHARED and r[1] < 0.6: c.append(SHARED[e.part])
        if r[2] < 0.5: c.append(SYMP[PSYS[e.part]])
        if not c: c.append(SHARED.get(e.part, SPEC[e.part]))
        for q, pr in CASCADE.get(e.part, []):
            if rng.random() < pr: c.append(SPEC[q])
        if rng.random() < 0.12: c.append(SPEC[PID[rng.integers(0, 16)]])
        c = list(dict.fromkeys(c))
        rng.shuffle(c)                                                           # 동시 발생 코드의 기록 순서는 임의
        codes.append(c)
        for k, cd in enumerate(c):
            rows.append((e.ev_id, e.veh, e.t + k * 0.1, cd))
    ev["codes"] = codes
    log = pd.DataFrame(rows, columns=["ev_id", "veh", "ts", "code"])
    return fleet, ev, log

# ---------------------------------------------------------------------------
# 공통 분석 조각: 코드 멀티핫 행렬, 차량 맥락 피처, 학습/검증 분할(앞 12개월 학습, 뒤 6개월 검증)
# ---------------------------------------------------------------------------
CIDX = {c: i for i, c in enumerate(CODES)}
SPLIT_M = 12


def multi_hot(ev):
    X = np.zeros((len(ev), len(CODES)))
    for r, cs in enumerate(ev["codes"]):
        for c in cs:
            X[r, CIDX[c]] = 1
    return X


def context(ev, fleet):
    f = fleet.set_index("veh")
    return np.column_stack([ev["veh"].map(f["age"]) / 25, ev["veh"].map(f["pm_gap"]) / 200, ev["veh"].map(f["terrain"]),
                            ev["veh"].map(f["load"]), (ev["temp"] - 12) / 15])


def feats(ev, fleet):
    X = multi_hot(ev)
    n = X.sum(axis=1, keepdims=True) / 3
    return np.hstack([X, n, context(ev, fleet)])


def monthly_counts(ev):
    return ev.groupby(["month", "part"]).size().unstack(fill_value=0).reindex(columns=PID, fill_value=0)


def build_monthly(ev, fleet, top_n=8):
    """부품별 월간 고장 건수 예측용 표: 지연값(lag), 이동평균, 추세, 기대 건수(가동시간 x 설계 고장률), 기온 상호작용, 계절 성분"""
    mc = ev.groupby(["month", "part"]).size().unstack(fill_value=0)
    top = ev["part"].value_counts().index[:top_n]
    mh, tm = fleet.attrs["month_h"], ev.groupby("month")["temp"].mean().values
    rows = []
    for p in top:
        c = mc[p].values.astype(float)
        for m in range(3, MONTHS):
            L = np.log1p(c)
            rows.append({"part": p, "month": m, "y": c[m], "lag1": L[m - 1], "lag2": L[m - 2], "lag3": L[m - 3], "roll3": L[m - 3:m].mean(),
                         "trend": L[m - 1] - L[m - 2], "roll3_std": L[m - 3:m].std(), "log_expected": np.log(mh[m] / 1000 * RATE[p]),
                         "rate_lag1": c[m - 1] / (mh[m - 1] / 1000) / RATE[p], "temp": tm[m] / 10, "cold_elec": max(0, 12 - tm[m]) / 10 * (p in ("P03", "P04")),
                         "heat_cool": max(0, tm[m] - 12) / 10 * (p in ("P06", "P16")), "sin": np.sin(2 * np.pi * m / 12), "cos": np.cos(2 * np.pi * m / 12)})
    D = pd.DataFrame(rows)
    return D, [c for c in D.columns if c not in ("part", "month", "y")], top

fleet, ev, log = make_data()
train, test = ev[ev["month"] < SPLIT_M], ev[ev["month"] >= SPLIT_M].reset_index(drop=True)
clf = LogisticRegression(C=3, max_iter=500).fit(feats(train, fleet), train["part"])
P = clf.predict_proba(feats(test, fleet))
cls = clf.classes_
top1 = cls[P.argmax(axis=1)]
conf = P.max(axis=1)
CSYS = {c: {PSYS[p] for p in PID if c in (SPEC[p], SHARED.get(p)) or c == SYMP[PSYS[p]]} for c in CODES}
nsys = test["codes"].map(lambda cs: len(set().union(*[CSYS[c] for c in cs])))
multi_sys = ((test["codes"].str.len() >= 3) & (nsys >= 3)).values          # 3개 이상 계통에 걸친 DTC가 동시에 발생
THR, CAP = 0.8, 300      # 운영점: 신뢰도 0.8 미만 또는 다계통 동시 발생이면 정밀 진단, 정비관 월 수용량 300건(아래 민감도 분석으로 선정)

# [교육용가상자료] 가정 비용지수: 오진단 시 헛교체 부품비 + 헛교체 공수 + 재입고 패널티 3.0 + 올바른 수리, 정밀 진단 1건 1.0
REVIEW_COST, REVISIT, SENIOR_ACC, NO_RESP = 1.0, 3.0, 0.93, 0.08


def case_cost(pred, true):
    if pred == true:
        return REPH[true]
    return COST[pred] + REPH[pred] + REVISIT + REPH[true]


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 run(thr=THR, cap=CAP, surge=1.0, seed=0, audit=None, mode="hitl"):
    """mode: auto(엔진 top-1 전부 확정: 비교용, 실제 운용 금지) / hitl(복합 의심 건은 정비관 결정 전까지 확정하지 않음)"""
    rng = np.random.default_rng(seed)
    idx_all = np.arange(len(test))
    if surge != 1.0:
        idx_all = np.concatenate([idx_all, rng.choice(idx_all, int(len(idx_all) * (surge - 1)), replace=True)])
    cost, wrong, queued, decided, pending_auto, silent = 0.0, 0, 0, 0, 0, 0
    for m in range(SPLIT_M, MONTHS):
        mi = idx_all[test["month"].values[idx_all] == m]
        flag = [i for i in mi if conf[i] < thr or multi_sys[i]]
        flag.sort(key=lambda i: (PSYS[top1[i]] not in CRIT_SYS, conf[i]))      # 안전 계통 우선, 신뢰도 낮은 순
        queue = set(flag[:cap]) if mode == "hitl" else set()
        for i in mi:
            true, final = test["part"][i], top1[i]
            if i in queue:
                queued += 1
                audit.add(event="PROPOSE", case=int(i), engine=str(top1[i]), conf=round(float(conf[i]), 3), status="PENDING")
                cost += REVIEW_COST
                r = rng.random()
                if r < NO_RESP:
                    dec, why = "무응답", "응답 지연 -> 엔진 안 유지, 재확인 필요 표시"; silent += 1
                elif r < NO_RESP + (1 - NO_RESP) * SENIOR_ACC:
                    dec, final, why = ("승인" if top1[i] == true else "정정"), true, "추가 계측·부품 분리 점검으로 확인"
                else:
                    second = cls[np.argsort(-P[i])[1]]
                    dec, final, why = "정정", second, "2순위 후보로 정정(근거 부족)"
                audit.add(event="DECIDE", case=int(i), decision=dec, final=str(final), reason=why, by="숙련 정비관(모의)")
                decided += 1
            cost += case_cost(final, true); wrong += final != true
    n = len(idx_all)
    return {"cost": cost, "wrong": wrong / n, "queued": queued, "decided": decided, "silent": silent, "n": n, "auto": pending_auto}


aud = Audit()
A, H = run(mode="auto", audit=Audit()), run(audit=aud)
print(f"[교육용가상자료] 검증 정비 건 {A['n']}건, 복합 고장 의심 건 {int(((conf < THR) | multi_sys).sum())}건 (신뢰도 {THR} 미만 또는 3개 계통 이상 DTC)")
print(f"엔진 단독 자동 확정(비교용): 비용지수 {A['cost']:.0f}, 오진단율 {A['wrong']:.1%}")
print(f"HITL(정비관 대기열 {H['queued']}건): 비용지수 {H['cost']:.0f}, 오진단율 {H['wrong']:.1%}")
cut = 1 - H["cost"] / A["cost"]
print(f"오진단 비용 절감률 {cut:.1%} (목표 15% 이상: {'달성' if cut >= 0.15 else '미달'})")

# 승인 체계 점검
print(f"\n자동 확정 0건 확인: 대기열 건은 모두 정비관 결정 기록 후 확정 (자동 확정 {H['auto']}건)")
print(f"결정 로깅률 {H['decided'] / max(H['queued'], 1):.0%} ({H['decided']}/{H['queued']}), 무응답 {H['silent']}건은 엔진 안 유지 + 재확인 표시")
print(f"감사 로그 무결성: {'정상' if aud.verify() is None else '이상'}", end=", ")
aud.log[7]["reason"] = "사후 수정"
print(f"사후 변조 시 seq {aud.verify()}에서 탐지")
print("결정 사유 예:", "; ".join(sorted({e["reason"] for e in aud.log if e["event"] == "DECIDE"})[:3]))

# 민감도 분석
print("\n[민감도 1] 신뢰도 기준(미만이면 정밀 진단) -> 절감률, 정비관 대기열(6개월)")
rows = []
for thr in (0.5, 0.6, 0.7, 0.8, 0.9):
    r = run(thr=thr, cap=10 ** 6, audit=Audit()); rows.append((thr, 1 - r["cost"] / A["cost"], r["queued"]))
    print(f"  {thr:.1f}: 절감 {rows[-1][1]:.1%}, 대기열 {r['queued']}건")
print("[민감도 2] 정비관 월 수용량 -> 절감률 (전시 전환 가정: 고장 1.5배 급증)")
for cap in (50, 100, 200, 300):
    r1, r2 = run(cap=cap, audit=Audit()), run(cap=cap, surge=1.5, audit=Audit())
    b2 = run(mode="auto", surge=1.5, audit=Audit())
    print(f"  월 {cap}건: 평시 {1 - r1['cost'] / A['cost']:.1%}, 급증 {1 - r2['cost'] / b2['cost']:.1%}")

plt.figure(figsize=(6, 3.5))
plt.plot([r[0] for r in rows], [r[1] * 100 for r in rows], marker="o"); plt.xlabel("confidence threshold"); plt.ylabel("cost saving (%)")
plt.title("HITL sensitivity: confidence threshold"); plt.tight_layout(); plt.show()

실행 결과 예시

[교육용가상자료] 검증 정비 건 3890건, 복합 고장 의심 건 1555건 (신뢰도 0.8 미만 또는 3개 계통 이상 DTC)
엔진 단독 자동 확정(비교용): 비용지수 17546, 오진단율 16.1%
HITL(정비관 대기열 1501건): 비용지수 14809, 오진단율 4.2%
오진단 비용 절감률 15.6% (목표 15% 이상: 달성)

자동 확정 0건 확인: 대기열 건은 모두 정비관 결정 기록 후 확정 (자동 확정 0건)
결정 로깅률 100% (1501/1501), 무응답 120건은 엔진 안 유지 + 재확인 표시
감사 로그 무결성: 정상, 사후 변조 시 seq 7에서 탐지
결정 사유 예: 2순위 후보로 정정(근거 부족); 사후 수정; 응답 지연 -> 엔진 안 유지, 재확인 필요 표시

[민감도 1] 신뢰도 기준(미만이면 정밀 진단) -> 절감률, 정비관 대기열(6개월)
  0.5: 절감 1.9%, 대기열 162건
  0.6: 절감 9.4%, 대기열 784건
  0.7: 절감 14.6%, 대기열 1285건
  0.8: 절감 16.9%, 대기열 1555건
  0.9: 절감 17.7%, 대기열 1671건
[민감도 2] 정비관 월 수용량 -> 절감률 (전시 전환 가정: 고장 1.5배 급증)
  월 50건: 평시 2.8%, 급증 2.8%
  월 100건: 평시 7.0%, 급증 4.4%
  월 200건: 평시 13.8%, 급증 9.3%
  월 300건: 평시 15.6%, 급증 12.7%

미션 8. 1,000건 동시 다발 고장 코드 주입 시 연관 고장 추론 부하 테스트

  • 난이도: 고급 / 예상 소요시간: 3.0시간
  • 코드 설명: 1,000건의 DTC 진단 요청을 동시에 주입해 연관 고장 추론(부품 진단 + 기동·제동 계통 경보)의 처리 시간과 경보 정확도를 측정하는 부하 테스트를 수행한다. 형식이 깨진 코드·빈 요청·미등록 차량을 섞은 오류 주입 요청이 격리 처리되는지, 요청 수를 100~5,000건으로 늘렸을 때 시간이 어떻게 변하는지 확인한다.
"""1,000건의 DTC 진단 요청을 동시에 주입해 연관 고장 추론(부품 진단 + 기동·제동 계통 경보)의 처리 시간과 경보 정확도를 측정하는 부하 테스트를 수행한다. 형식이 깨진 코드·빈 요청·미등록 차량을 섞은 오류 주입 요청이 격리 처리되는지, 요청 수를 100~5,000건으로 늘렸을 때 시간이 어떻게 변하는지 확인한다."""
import time
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from concurrent.futures import ThreadPoolExecutor
from sklearn.linear_model import LogisticRegression

# ---------------------------------------------------------------------------
# [교육용가상자료] 가상 차량 100대의 18개월 고장 진단 코드(DTC)·정비 이력 생성기 (실제 부대·장비·정비 실적 없음)
# 부품 16종(1000가동시간당 고장률 차등), DTC 28종(부품 고유 코드, 짝 부품 공유 코드, 계통 증상 코드)
# 고장 1건(정비 이력)마다 DTC가 1~5개 동시 발생하고, 연쇄 고장 코드와 일시 잡음 코드가 섞인다.
# ---------------------------------------------------------------------------
PARTS = [  # (ID, 이름, 계통, 1000가동시간당 고장률, 가상 단가지수, 수리시간 h)
    ("P01", "연료분사기", "engine", 12.0, 3.0, 2.0), ("P02", "터보차저", "engine", 9.0, 8.0, 5.0),
    ("P03", "축전지", "electric", 6.5, 4.0, 1.5), ("P04", "발전기", "electric", 4.0, 9.0, 3.0),
    ("P05", "변속기 제어모듈", "drive", 3.0, 12.0, 6.0), ("P06", "냉각수 펌프", "engine", 2.2, 3.5, 3.0),
    ("P07", "유압 펌프", "hydraulic", 1.6, 10.0, 4.0), ("P08", "속도 센서", "drive", 1.2, 2.0, 1.5),
    ("P09", "ECU 커넥터", "electric", 0.9, 2.5, 1.5), ("P10", "연료 필터", "engine", 0.7, 0.8, 0.7),
    ("P11", "제동 밸브", "brake", 0.5, 6.0, 3.0), ("P12", "조향 액추에이터", "drive", 0.4, 9.0, 5.0),
    ("P13", "공기 필터", "engine", 0.3, 0.5, 0.5), ("P14", "통신 인터페이스 모듈", "electric", 0.25, 3.0, 1.5),
    ("P15", "윤활유 압력 센서", "engine", 0.2, 1.0, 1.0), ("P16", "팬 구동 모터", "cooling", 0.15, 2.0, 2.0)]
PID = [p[0] for p in PARTS]
PNAME = {p[0]: p[1] for p in PARTS}; PSYS = {p[0]: p[2] for p in PARTS}; RATE = {p[0]: p[3] for p in PARTS}
COST = {p[0]: p[4] for p in PARTS}; REPH = {p[0]: p[5] for p in PARTS}
PAIRS = [("P01", "P02"), ("P03", "P04"), ("P05", "P08"), ("P06", "P10"), ("P09", "P14"), ("P13", "P15")]
SHARED = {p: f"S{k + 1:02d}" for k, pr in enumerate(PAIRS) for p in pr}     # 짝 부품이 함께 쓰는 모호한 코드
SPEC = {p: f"C{p[1:]}1" for p in PID}                                          # 부품 고유 코드
SYS = ["engine", "electric", "drive", "hydraulic", "brake", "cooling"]
SYMP = {s: f"G{i + 1:02d}" for i, s in enumerate(SYS)}                         # 계통 증상 코드
CODES = sorted(set(SPEC.values()) | set(SHARED.values()) | set(SYMP.values()))
CASCADE = {"P01": [("P10", 0.20)], "P02": [("P06", 0.20)], "P03": [("P04", 0.25)], "P04": [("P09", 0.25)],
           "P05": [("P08", 0.30)], "P06": [("P16", 0.30)], "P09": [("P14", 0.20)], "P12": [("P07", 0.20)]}
CRIT_SYS = {"engine", "drive", "brake"}        # 기동·제동 불능 위험 계통(경보 대상)
MONTHS = 18


def make_fleet(n=100, seed=0):
    rng = np.random.default_rng(seed)
    f = pd.DataFrame({"veh": [f"V{i:03d}" for i in range(n)], "age": rng.integers(1, 26, n).astype(float),
                      "terrain": rng.uniform(0, 1, n), "load": rng.uniform(0, 1, n), "pm_gap": rng.uniform(20, 200, n),
                      "humid": rng.uniform(0, 1, n), "base_h": rng.uniform(55, 125, n)})
    f["mult"] = np.exp(0.045 * (f["age"] - 13) + 0.9 * (f["terrain"] - .5) + 0.8 * (f["load"] - .5)
                       + 0.006 * (f["pm_gap"] - 110) + 0.7 * (f["humid"] - .5))
    return f


def make_data(seed=1, months=MONTHS):
    """반환: fleet(차량 속성), ev(정비 이력 1건=1행: 발생 DTC 목록, 실제 고장 부품), log(DTC 발생 로그, 행=코드 1건)"""
    rng = np.random.default_rng(seed)
    fleet = make_fleet()
    V = len(fleet)
    temp = 12 + 14 * np.sin(2 * np.pi * (np.arange(months) - 3) / 12)            # 월평균 기온(가상)
    hours = fleet["base_h"].values[:, None] * (1 + 0.25 * np.sin(2 * np.pi * np.arange(months) / 12 + 1)) * rng.lognormal(0, .12, (V, months))
    fleet["hours"] = hours.sum(axis=1)                                           # 차량별 18개월 총 가동시간
    fleet.attrs["month_h"] = hours.sum(axis=0)                                   # 월별 전체 가동시간(고장률 분모)
    evs = []
    for p in PID:
        s = np.ones(months)
        if p in ("P03", "P04"): s = np.exp(-0.06 * (temp - 12))                   # 한랭기 전기계통 증가
        if p in ("P06", "P16"): s = np.exp(0.05 * (temp - 12))                    # 혹서기 냉각계통 증가
        cnt = rng.poisson(RATE[p] * hours / 1000 * fleet["mult"].values[:, None] * s[None, :])
        for v, m in zip(*np.nonzero(cnt)):
            for _ in range(cnt[v, m]):
                evs.append((fleet["veh"][v], int(m), m * 720 + rng.uniform(0, 720), p, float(temp[m] + rng.normal(0, 3))))
    ev = pd.DataFrame(evs, columns=["veh", "month", "t", "part", "temp"]).sort_values("t").reset_index(drop=True)
    ev["ev_id"] = [f"W{i:05d}" for i in range(len(ev))]
    rows, codes = [], []
    for e in ev.itertuples():
        c, r = [], rng.random(3)
        if r[0] < 0.6: c.append(SPEC[e.part])
        if e.part in SHARED and r[1] < 0.6: c.append(SHARED[e.part])
        if r[2] < 0.5: c.append(SYMP[PSYS[e.part]])
        if not c: c.append(SHARED.get(e.part, SPEC[e.part]))
        for q, pr in CASCADE.get(e.part, []):
            if rng.random() < pr: c.append(SPEC[q])
        if rng.random() < 0.12: c.append(SPEC[PID[rng.integers(0, 16)]])
        c = list(dict.fromkeys(c))
        rng.shuffle(c)                                                           # 동시 발생 코드의 기록 순서는 임의
        codes.append(c)
        for k, cd in enumerate(c):
            rows.append((e.ev_id, e.veh, e.t + k * 0.1, cd))
    ev["codes"] = codes
    log = pd.DataFrame(rows, columns=["ev_id", "veh", "ts", "code"])
    return fleet, ev, log

# ---------------------------------------------------------------------------
# 공통 분석 조각: 코드 멀티핫 행렬, 차량 맥락 피처, 학습/검증 분할(앞 12개월 학습, 뒤 6개월 검증)
# ---------------------------------------------------------------------------
CIDX = {c: i for i, c in enumerate(CODES)}
SPLIT_M = 12


def multi_hot(ev):
    X = np.zeros((len(ev), len(CODES)))
    for r, cs in enumerate(ev["codes"]):
        for c in cs:
            X[r, CIDX[c]] = 1
    return X


def context(ev, fleet):
    f = fleet.set_index("veh")
    return np.column_stack([ev["veh"].map(f["age"]) / 25, ev["veh"].map(f["pm_gap"]) / 200, ev["veh"].map(f["terrain"]),
                            ev["veh"].map(f["load"]), (ev["temp"] - 12) / 15])


def feats(ev, fleet):
    X = multi_hot(ev)
    n = X.sum(axis=1, keepdims=True) / 3
    return np.hstack([X, n, context(ev, fleet)])


def monthly_counts(ev):
    return ev.groupby(["month", "part"]).size().unstack(fill_value=0).reindex(columns=PID, fill_value=0)


def build_monthly(ev, fleet, top_n=8):
    """부품별 월간 고장 건수 예측용 표: 지연값(lag), 이동평균, 추세, 기대 건수(가동시간 x 설계 고장률), 기온 상호작용, 계절 성분"""
    mc = ev.groupby(["month", "part"]).size().unstack(fill_value=0)
    top = ev["part"].value_counts().index[:top_n]
    mh, tm = fleet.attrs["month_h"], ev.groupby("month")["temp"].mean().values
    rows = []
    for p in top:
        c = mc[p].values.astype(float)
        for m in range(3, MONTHS):
            L = np.log1p(c)
            rows.append({"part": p, "month": m, "y": c[m], "lag1": L[m - 1], "lag2": L[m - 2], "lag3": L[m - 3], "roll3": L[m - 3:m].mean(),
                         "trend": L[m - 1] - L[m - 2], "roll3_std": L[m - 3:m].std(), "log_expected": np.log(mh[m] / 1000 * RATE[p]),
                         "rate_lag1": c[m - 1] / (mh[m - 1] / 1000) / RATE[p], "temp": tm[m] / 10, "cold_elec": max(0, 12 - tm[m]) / 10 * (p in ("P03", "P04")),
                         "heat_cool": max(0, tm[m] - 12) / 10 * (p in ("P06", "P16")), "sin": np.sin(2 * np.pi * m / 12), "cos": np.cos(2 * np.pi * m / 12)})
    D = pd.DataFrame(rows)
    return D, [c for c in D.columns if c not in ("part", "month", "y")], top

fleet, ev, log = make_data()
train, test = ev[ev["month"] < SPLIT_M], ev[ev["month"] >= SPLIT_M].reset_index(drop=True)
clf = LogisticRegression(C=3, max_iter=500).fit(feats(train, fleet), train["part"])
cls, FL = clf.classes_, fleet.set_index("veh")
VALID = set(CODES)


def make_requests(n, seed=0, bad_rate=0.05):
    """[교육용가상자료] 검증 기간 정비 건에서 n건을 뽑아 DTC 요청으로 만든다. bad_rate 비율만큼 오류를 주입한다."""
    rng = np.random.default_rng(seed)
    rows = test.iloc[rng.choice(len(test), n, replace=n > len(test))]
    reqs, kinds = [], []
    for e in rows.itertuples():
        r, kind = {"veh": e.veh, "codes": list(e.codes), "temp": e.temp, "true": e.part}, "정상"
        u = rng.random()
        if u < bad_rate * 0.3: r["codes"] = [c.lower() + " " for c in r["codes"]]; kind = "표기 오류"
        elif u < bad_rate * 0.5: r["codes"] = ["ZZ9"]; kind = "미정의 코드"
        elif u < bad_rate * 0.7: r["codes"] = []; kind = "빈 요청"
        elif u < bad_rate * 0.85: r["veh"] = "V999"; kind = "미등록 차량"
        elif u < bad_rate: r["codes"] = r["codes"] * 3; kind = "중복 코드"
        reqs.append(r); kinds.append(kind)
    return reqs, kinds


def normalize(r):
    """표기 정리(공백·대소문자) 후 검증. 문제가 있으면 예외 대신 사유를 돌려준다."""
    codes = sorted({c.strip().upper() for c in r["codes"]})
    if r["veh"] not in FL.index: return None, "미등록 차량"
    if not codes or any(c not in VALID for c in codes): return None, "빈 요청·미정의 코드"
    return codes, ""


def build_x(batch):
    X = np.zeros((len(batch), len(CODES) + 6))
    for i, (r, cs) in enumerate(batch):
        for c in cs: X[i, CIDX[c]] = 1
        v = FL.loc[r["veh"]]
        X[i, len(CODES):] = [len(cs) / 3, v["age"] / 25, v["pm_gap"] / 200, v["terrain"], v["load"], (r["temp"] - 12) / 15]
    return X


def serve_batch(reqs):
    ok, rej = [], []
    for k, r in enumerate(reqs):
        cs, why = normalize(r)
        (ok if cs else rej).append((k, r, cs) if cs else (k, why))
    X = build_x([(r, cs) for _, r, cs in ok])
    P = clf.predict_proba(X) if len(ok) else np.zeros((0, len(cls)))
    top = cls[P.argmax(axis=1)] if len(ok) else []
    out = [{"k": k, "part": p, "conf": float(P[i].max()), "alert": PSYS[p] in CRIT_SYS} for i, ((k, _, _), p) in enumerate(zip(ok, top))]
    return out, rej


def serve_one(r):
    cs, why = normalize(r)
    if not cs: return None
    p = cls[clf.predict_proba(build_x([(r, cs)]))[0].argmax()]
    return {"part": p, "alert": PSYS[p] in CRIT_SYS}


# 1) 1,000건 동시 주입
reqs, kinds = make_requests(1000)
t0 = time.perf_counter(); out, rej = serve_batch(reqs)
agg = pd.DataFrame(out).groupby("part").size().sort_values(ascending=False).head(5)          # 대시보드 집계까지 포함
alerts = sum(o["alert"] for o in out); dt = time.perf_counter() - t0
print(f"[교육용가상자료] 1,000건 동시 주입: 정상 처리 {len(out)}건, 격리(오류) {len(rej)}건, 처리 시간 {dt:.2f}초 (목표 2초 이내: {'달성' if dt <= 2 else '미달'})")
print(f"  오류 주입 {sum(k != '정상' for k in kinds)}건 유형: " + ", ".join(f"{k} {v}" for k, v in pd.Series(kinds).value_counts().items() if k != "정상"))
print("  격리 사유: " + ", ".join(f"{k} {v}건" for k, v in pd.Series([w for _, w in rej]).value_counts().items()))

# 2) 경보 정확도(기동·제동 계통 고장 경보)
truth = np.array([PSYS[reqs[o["k"]]["true"]] in CRIT_SYS for o in out]); pa = np.array([o["alert"] for o in out])
acc = (truth == pa).mean(); rec = (pa & truth).sum() / truth.sum(); prec = (pa & truth).sum() / pa.sum()
part_acc = np.mean([o["part"] == reqs[o["k"]]["true"] for o in out])
print(f"\n경보 정확도 {acc:.1%} (목표 95% 이상: {'달성' if acc >= 0.95 else '미달'}), 재현율 {rec:.1%}, 정밀도 {prec:.1%}, 경보 {alerts}건")
print(f"참고: 부품 단위 진단 정확도 {part_acc:.1%}, 상위 부품 " + ", ".join(f"{PNAME[p]} {n}" for p, n in agg.items()))

# 3) 단건 호출 방식과 비교(8개 작업자 동시 호출)
t0 = time.perf_counter()
with ThreadPoolExecutor(8) as ex:
    res = list(ex.map(serve_one, reqs))
dt1 = time.perf_counter() - t0
print(f"\n단건 호출 8스레드 {dt1:.2f}초 vs 일괄 처리 {dt:.2f}초 (일괄이 {dt1 / dt:.1f}배 빠름), 단건 방식 예외 0건: {all(r is None or 'part' in r for r in res)}")

# 4) 요청 수 확장 시험
print("[요청 수별 일괄 처리 시간]")
ns, ts = [100, 500, 1000, 2000, 5000], []
for n in ns:
    rq, _ = make_requests(n, seed=n)
    t0 = time.perf_counter(); serve_batch(rq); ts.append(time.perf_counter() - t0)
    print(f"  {n:5d}건: {ts[-1]:.2f}초 ({n / ts[-1]:.0f}건/초)")

plt.figure(figsize=(6, 3.5)); plt.plot(ns, ts, marker="o"); plt.axhline(2, ls="--", color="k")
plt.xlabel("requests"); plt.ylabel("seconds"); plt.title("Batch inference time (line = 2 s)"); plt.tight_layout(); plt.show()

실행 결과 예시

[교육용가상자료] 1,000건 동시 주입: 정상 처리 965건, 격리(오류) 35건, 처리 시간 0.11초 (목표 2초 이내: 달성)
  오류 주입 65건 유형: 표기 오류 23, 빈 요청 15, 미정의 코드 11, 미등록 차량 9, 중복 코드 7
  격리 사유: 빈 요청·미정의 코드 26건, 미등록 차량 9건

경보 정확도 99.8% (목표 95% 이상: 달성), 재현율 100.0%, 정밀도 99.6%, 경보 567건
참고: 부품 단위 진단 정확도 85.1%, 상위 부품 연료분사기 303, 축전지 218, 터보차저 126, 발전기 119, 변속기 제어모듈 53

단건 호출 8스레드 0.33초 vs 일괄 처리 0.11초 (일괄이 3.1배 빠름), 단건 방식 예외 0건: True
[요청 수별 일괄 처리 시간]
    100건: 0.01초 (15586건/초)
    500건: 0.03초 (18195건/초)
   1000건: 0.06초 (16478건/초)
   2000건: 0.30초 (6703건/초)
   5000건: 0.33초 (15293건/초)

미션 9. AAR 기반 연관 고장 오진단 사례 분석 및 고장 룰베이스 재튜닝

  • 난이도: 고급 / 예상 소요시간: 3.0시간
  • 코드 설명: AAR(사후검토) 대상 기간의 연관 고장 오진단 사례를 원인별(모호 코드만 입력, 연쇄 고장 코드 오인, 잡음 코드 오인, 기타)로 분류하고, 고장 룰베이스의 코드 유형별 가중치와 사전확률 보정을 AAR 기간 자료로 재튜닝한 뒤 별도 검증 기간에서 효과를 확인한다. 비용 절감액, 재작업 시간 단축률, 헛교체 부품 감소율의 3대 효과 지표와 단계별 도입 로드맵을 산출한다.
"""AAR(사후검토) 대상 기간의 연관 고장 오진단 사례를 원인별(모호 코드만 입력, 연쇄 고장 코드 오인, 잡음 코드 오인, 기타)로 분류하고, 고장 룰베이스의 코드 유형별 가중치와 사전확률 보정을 AAR 기간 자료로 재튜닝한 뒤 별도 검증 기간에서 효과를 확인한다. 비용 절감액, 재작업 시간 단축률, 헛교체 부품 감소율의 3대 효과 지표와 단계별 도입 로드맵을 산출한다."""
import itertools
from collections import Counter
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

# ---------------------------------------------------------------------------
# [교육용가상자료] 가상 차량 100대의 18개월 고장 진단 코드(DTC)·정비 이력 생성기 (실제 부대·장비·정비 실적 없음)
# 부품 16종(1000가동시간당 고장률 차등), DTC 28종(부품 고유 코드, 짝 부품 공유 코드, 계통 증상 코드)
# 고장 1건(정비 이력)마다 DTC가 1~5개 동시 발생하고, 연쇄 고장 코드와 일시 잡음 코드가 섞인다.
# ---------------------------------------------------------------------------
PARTS = [  # (ID, 이름, 계통, 1000가동시간당 고장률, 가상 단가지수, 수리시간 h)
    ("P01", "연료분사기", "engine", 12.0, 3.0, 2.0), ("P02", "터보차저", "engine", 9.0, 8.0, 5.0),
    ("P03", "축전지", "electric", 6.5, 4.0, 1.5), ("P04", "발전기", "electric", 4.0, 9.0, 3.0),
    ("P05", "변속기 제어모듈", "drive", 3.0, 12.0, 6.0), ("P06", "냉각수 펌프", "engine", 2.2, 3.5, 3.0),
    ("P07", "유압 펌프", "hydraulic", 1.6, 10.0, 4.0), ("P08", "속도 센서", "drive", 1.2, 2.0, 1.5),
    ("P09", "ECU 커넥터", "electric", 0.9, 2.5, 1.5), ("P10", "연료 필터", "engine", 0.7, 0.8, 0.7),
    ("P11", "제동 밸브", "brake", 0.5, 6.0, 3.0), ("P12", "조향 액추에이터", "drive", 0.4, 9.0, 5.0),
    ("P13", "공기 필터", "engine", 0.3, 0.5, 0.5), ("P14", "통신 인터페이스 모듈", "electric", 0.25, 3.0, 1.5),
    ("P15", "윤활유 압력 센서", "engine", 0.2, 1.0, 1.0), ("P16", "팬 구동 모터", "cooling", 0.15, 2.0, 2.0)]
PID = [p[0] for p in PARTS]
PNAME = {p[0]: p[1] for p in PARTS}; PSYS = {p[0]: p[2] for p in PARTS}; RATE = {p[0]: p[3] for p in PARTS}
COST = {p[0]: p[4] for p in PARTS}; REPH = {p[0]: p[5] for p in PARTS}
PAIRS = [("P01", "P02"), ("P03", "P04"), ("P05", "P08"), ("P06", "P10"), ("P09", "P14"), ("P13", "P15")]
SHARED = {p: f"S{k + 1:02d}" for k, pr in enumerate(PAIRS) for p in pr}     # 짝 부품이 함께 쓰는 모호한 코드
SPEC = {p: f"C{p[1:]}1" for p in PID}                                          # 부품 고유 코드
SYS = ["engine", "electric", "drive", "hydraulic", "brake", "cooling"]
SYMP = {s: f"G{i + 1:02d}" for i, s in enumerate(SYS)}                         # 계통 증상 코드
CODES = sorted(set(SPEC.values()) | set(SHARED.values()) | set(SYMP.values()))
CASCADE = {"P01": [("P10", 0.20)], "P02": [("P06", 0.20)], "P03": [("P04", 0.25)], "P04": [("P09", 0.25)],
           "P05": [("P08", 0.30)], "P06": [("P16", 0.30)], "P09": [("P14", 0.20)], "P12": [("P07", 0.20)]}
CRIT_SYS = {"engine", "drive", "brake"}        # 기동·제동 불능 위험 계통(경보 대상)
MONTHS = 18


def make_fleet(n=100, seed=0):
    rng = np.random.default_rng(seed)
    f = pd.DataFrame({"veh": [f"V{i:03d}" for i in range(n)], "age": rng.integers(1, 26, n).astype(float),
                      "terrain": rng.uniform(0, 1, n), "load": rng.uniform(0, 1, n), "pm_gap": rng.uniform(20, 200, n),
                      "humid": rng.uniform(0, 1, n), "base_h": rng.uniform(55, 125, n)})
    f["mult"] = np.exp(0.045 * (f["age"] - 13) + 0.9 * (f["terrain"] - .5) + 0.8 * (f["load"] - .5)
                       + 0.006 * (f["pm_gap"] - 110) + 0.7 * (f["humid"] - .5))
    return f


def make_data(seed=1, months=MONTHS):
    """반환: fleet(차량 속성), ev(정비 이력 1건=1행: 발생 DTC 목록, 실제 고장 부품), log(DTC 발생 로그, 행=코드 1건)"""
    rng = np.random.default_rng(seed)
    fleet = make_fleet()
    V = len(fleet)
    temp = 12 + 14 * np.sin(2 * np.pi * (np.arange(months) - 3) / 12)            # 월평균 기온(가상)
    hours = fleet["base_h"].values[:, None] * (1 + 0.25 * np.sin(2 * np.pi * np.arange(months) / 12 + 1)) * rng.lognormal(0, .12, (V, months))
    fleet["hours"] = hours.sum(axis=1)                                           # 차량별 18개월 총 가동시간
    fleet.attrs["month_h"] = hours.sum(axis=0)                                   # 월별 전체 가동시간(고장률 분모)
    evs = []
    for p in PID:
        s = np.ones(months)
        if p in ("P03", "P04"): s = np.exp(-0.06 * (temp - 12))                   # 한랭기 전기계통 증가
        if p in ("P06", "P16"): s = np.exp(0.05 * (temp - 12))                    # 혹서기 냉각계통 증가
        cnt = rng.poisson(RATE[p] * hours / 1000 * fleet["mult"].values[:, None] * s[None, :])
        for v, m in zip(*np.nonzero(cnt)):
            for _ in range(cnt[v, m]):
                evs.append((fleet["veh"][v], int(m), m * 720 + rng.uniform(0, 720), p, float(temp[m] + rng.normal(0, 3))))
    ev = pd.DataFrame(evs, columns=["veh", "month", "t", "part", "temp"]).sort_values("t").reset_index(drop=True)
    ev["ev_id"] = [f"W{i:05d}" for i in range(len(ev))]
    rows, codes = [], []
    for e in ev.itertuples():
        c, r = [], rng.random(3)
        if r[0] < 0.6: c.append(SPEC[e.part])
        if e.part in SHARED and r[1] < 0.6: c.append(SHARED[e.part])
        if r[2] < 0.5: c.append(SYMP[PSYS[e.part]])
        if not c: c.append(SHARED.get(e.part, SPEC[e.part]))
        for q, pr in CASCADE.get(e.part, []):
            if rng.random() < pr: c.append(SPEC[q])
        if rng.random() < 0.12: c.append(SPEC[PID[rng.integers(0, 16)]])
        c = list(dict.fromkeys(c))
        rng.shuffle(c)                                                           # 동시 발생 코드의 기록 순서는 임의
        codes.append(c)
        for k, cd in enumerate(c):
            rows.append((e.ev_id, e.veh, e.t + k * 0.1, cd))
    ev["codes"] = codes
    log = pd.DataFrame(rows, columns=["ev_id", "veh", "ts", "code"])
    return fleet, ev, log

# ---------------------------------------------------------------------------
# 공통 분석 조각: 코드 멀티핫 행렬, 차량 맥락 피처, 학습/검증 분할(앞 12개월 학습, 뒤 6개월 검증)
# ---------------------------------------------------------------------------
CIDX = {c: i for i, c in enumerate(CODES)}
SPLIT_M = 12


def multi_hot(ev):
    X = np.zeros((len(ev), len(CODES)))
    for r, cs in enumerate(ev["codes"]):
        for c in cs:
            X[r, CIDX[c]] = 1
    return X


def context(ev, fleet):
    f = fleet.set_index("veh")
    return np.column_stack([ev["veh"].map(f["age"]) / 25, ev["veh"].map(f["pm_gap"]) / 200, ev["veh"].map(f["terrain"]),
                            ev["veh"].map(f["load"]), (ev["temp"] - 12) / 15])


def feats(ev, fleet):
    X = multi_hot(ev)
    n = X.sum(axis=1, keepdims=True) / 3
    return np.hstack([X, n, context(ev, fleet)])


def monthly_counts(ev):
    return ev.groupby(["month", "part"]).size().unstack(fill_value=0).reindex(columns=PID, fill_value=0)


def build_monthly(ev, fleet, top_n=8):
    """부품별 월간 고장 건수 예측용 표: 지연값(lag), 이동평균, 추세, 기대 건수(가동시간 x 설계 고장률), 기온 상호작용, 계절 성분"""
    mc = ev.groupby(["month", "part"]).size().unstack(fill_value=0)
    top = ev["part"].value_counts().index[:top_n]
    mh, tm = fleet.attrs["month_h"], ev.groupby("month")["temp"].mean().values
    rows = []
    for p in top:
        c = mc[p].values.astype(float)
        for m in range(3, MONTHS):
            L = np.log1p(c)
            rows.append({"part": p, "month": m, "y": c[m], "lag1": L[m - 1], "lag2": L[m - 2], "lag3": L[m - 3], "roll3": L[m - 3:m].mean(),
                         "trend": L[m - 1] - L[m - 2], "roll3_std": L[m - 3:m].std(), "log_expected": np.log(mh[m] / 1000 * RATE[p]),
                         "rate_lag1": c[m - 1] / (mh[m - 1] / 1000) / RATE[p], "temp": tm[m] / 10, "cold_elec": max(0, 12 - tm[m]) / 10 * (p in ("P03", "P04")),
                         "heat_cool": max(0, tm[m] - 12) / 10 * (p in ("P06", "P16")), "sin": np.sin(2 * np.pi * m / 12), "cos": np.cos(2 * np.pi * m / 12)})
    D = pd.DataFrame(rows)
    return D, [c for c in D.columns if c not in ("part", "month", "y")], top

fleet, ev, log = make_data()
train, aar, val = ev[ev["month"] < 12], ev[(ev["month"] >= 12) & (ev["month"] < 15)].reset_index(drop=True), ev[ev["month"] >= 15].reset_index(drop=True)

# 1) 룰베이스: 점수(부품) = sum[코드 유형 가중치 x P(부품 | 코드)] + 사전확률 보정. 학습 기간 조건부확률표를 쓴다.
Xtr = multi_hot(train)
Y = pd.get_dummies(train["part"]).reindex(columns=PID, fill_value=0).values.astype(float)
COND = (Xtr.T @ Y) / np.maximum(Xtr.sum(axis=0)[:, None], 1)             # P(부품 | 코드)
PRIOR = np.log(Y.mean(axis=0) + 1e-9)
CTYPE = np.array([0 if c in SPEC.values() else 1 if c in SHARED.values() else 2 for c in CODES])    # 0 고유 / 1 공유 / 2 계통 증상
V1 = {"w": (1.0, 1.0, 1.0), "beta": 0.0}                                  # 기존 룰베이스(모든 코드 동일 가중, 사전확률 미반영)


def predict(ev_, p):
    W = np.array(p["w"])[CTYPE][:, None] * COND
    S = multi_hot(ev_) @ W + p["beta"] * PRIOR[None, :]
    base = np.array(PID)[S.argmax(axis=1)]
    ov = p.get("override", {})                                            # AAR에서 만든 예외 규칙(DTC 조합 -> 부품)이 우선
    return np.array([ov.get(frozenset(c), b) for c, b in zip(ev_["codes"], base)])


def cost(pred, true):
    return sum(REPH[t] if p == t else COST[p] + REPH[p] + 3.0 + REPH[t] for p, t in zip(pred, true))


def err_stats(pred, true):
    wrong = pred != true.values
    return int(wrong.sum()), sum(REPH[p] + 3.0 + REPH[t] for p, t in zip(pred[wrong], true[wrong])), int(wrong.sum())


# 2) AAR: 오진단 원인 분류
pa = predict(aar, V1)
casc = {(p, q) for p, lst in CASCADE.items() for q, _ in lst}
cause = []
for cs, t, q in zip(aar["codes"], aar["part"], pa):
    if t == q: continue
    sp = [c for c in cs if c in SPEC.values()]
    if not sp: cause.append("모호 코드(공유·증상)만 입력")
    elif len(sp) >= 2 and ((t, q) in casc or (q, t) in casc): cause.append("연쇄 고장 코드 오인")
    elif len(sp) >= 2: cause.append("복수 고유 코드 충돌(잡음 포함)")
    else: cause.append("기타(고유 코드 1개, 확률 편향)")
C = pd.Series(cause).value_counts()
print(f"[교육용가상자료] AAR 기간(12~14월) 정비 {len(aar)}건: 기존 룰베이스 오진단 {len(cause)}건 ({len(cause) / len(aar):.1%})")
for k, v in C.items():
    print(f"  {k:24s} {v:4d}건 ({v / len(cause):.0%})")
ex = [(cs, PNAME[t], PNAME[q]) for cs, t, q in zip(aar["codes"], aar["part"], pa) if t != q and sum(c in SPEC.values() for c in cs) >= 2][:1]
print("  사례:", f"입력 DTC {ex[0][0]} -> 오진단 {ex[0][2]}, 실제 {ex[0][1]}" if ex else "-")

# 3) 재튜닝: (가) 코드 유형 가중치·사전확률 격자 탐색, (나) AAR 오진단 조합에서 예외 규칙 추출. 모두 AAR 기간 자료만 쓰고 검증 기간은 건드리지 않는다.
best = None
for w in itertools.product((0.5, 1.0, 1.5, 2.0), repeat=3):
    for beta in (0.0, 0.2, 0.5, 1.0):
        p = {"w": w, "beta": beta}
        c = cost(predict(aar, p), aar["part"])
        if best is None or c < best[0]: best = (c, p)
V2 = dict(best[1])
pa2 = predict(aar, V2)
bad_sets = {frozenset(c) for c, t, q in zip(aar["codes"], aar["part"], pa2) if t != q}
grp = {}
for c, t in zip(aar["codes"], aar["part"]):
    grp.setdefault(frozenset(c), Counter())[t] += 1
V2["override"] = {k: v.most_common(1)[0][0] for k, v in grp.items()
                  if k in bad_sets and sum(v.values()) >= 3 and v.most_common(1)[0][1] / sum(v.values()) >= 0.6}
print(f"\n재튜닝 결과: 코드 유형 가중치(고유·공유·증상) {V1['w']} -> {V2['w']}, 사전확률 보정 {V1['beta']} -> {V2['beta']}, AAR 예외 규칙 {len(V2['override'])}개 추가")
rows = []
for nm, d in (("AAR 기간(튜닝)", aar), ("검증 기간(15~17월)", val)):
    p1, p2 = predict(d, V1), predict(d, V2)
    rows.append((nm, (p1 == d["part"].values).mean(), (p2 == d["part"].values).mean(), cost(p1, d["part"]), cost(p2, d["part"])))
    print(f" {nm:14s} 정확도 {rows[-1][1]:.1%} -> {rows[-1][2]:.1%}, 비용지수 {rows[-1][3]:.0f} -> {rows[-1][4]:.0f}")

# 4) ROI 3대 지표(검증 기간 3개월, 가정 단가 기준 상대값)
p1, p2 = predict(val, V1), predict(val, V2)
n1, h1, _ = err_stats(p1, val["part"]); n2, h2, _ = err_stats(p2, val["part"])
saved = cost(p1, val["part"]) - cost(p2, val["part"])
wasted1 = sum(COST[p] for p, t in zip(p1, val["part"]) if p != t); wasted2 = sum(COST[p] for p, t in zip(p2, val["part"]) if p != t)
print("\n[ROI 3대 정량 효과 지표] (검증 3개월, 가정 단가)")
print(f" 1) 비용 절감: 비용지수 {saved:.0f} 절감 ({saved / cost(p1, val['part']):.1%}), 연환산 약 {saved * 4:.0f}")
print(f" 2) 시간 단축: 오진단 재작업 {h1:.0f}h -> {h2:.0f}h ({1 - h2 / h1:.1%} 단축)")
print(f" 3) 헛교체 부품 비용: {wasted1:.0f} -> {wasted2:.0f} ({1 - wasted2 / wasted1:.1%} 감소), 오진단 {n1}건 -> {n2}건")
print("[도입 로드맵] 단기(1~3월): 가중치 갱신 + 정비관 확인 병행 / 중기(3~12월): AAR 분기 반복·신규 코드 반영 / 장기(1년~): 군수 정보체계 이력 연동")

fig, ax = plt.subplots(1, 2, figsize=(10, 3.5))
ax[0].barh([f"cause {i + 1}" for i in range(len(C))][::-1], C.values[::-1]); ax[0].set_title("Misdiagnosis causes (AAR period)")
ax[1].bar(["before", "after"], [rows[1][1], rows[1][2]]); ax[1].set_ylim(0.5, 1); ax[1].set_title("Validation accuracy")
plt.tight_layout(); plt.show()

실행 결과 예시

[교육용가상자료] AAR 기간(12~14월) 정비 2336건: 기존 룰베이스 오진단 522건 (22.3%)
  모호 코드(공유·증상)만 입력          243건 (47%)
  기타(고유 코드 1개, 확률 편향)       237건 (45%)
  연쇄 고장 코드 오인                26건 (5%)
  복수 고유 코드 충돌(잡음 포함)         16건 (3%)
  사례: 입력 DTC ['S04', 'G01', 'C081', 'C101'] -> 오진단 연료분사기, 실제 연료 필터

재튜닝 결과: 코드 유형 가중치(고유·공유·증상) (1.0, 1.0, 1.0) -> (2.0, 1.0, 0.5), 사전확률 보정 0.0 -> 0.0, AAR 예외 규칙 31개 추가
 AAR 기간(튜닝)     정확도 77.7% -> 82.9%, 비용지수 11938 -> 10805
 검증 기간(15~17월)  정확도 78.1% -> 80.1%, 비용지수 8449 -> 7832

[ROI 3대 정량 효과 지표] (검증 3개월, 가정 단가)
 1) 비용 절감: 비용지수 616 절감 (7.3%), 연환산 약 2466
 2) 시간 단축: 오진단 재작업 3038h -> 2713h (10.7% 단축)
 3) 헛교체 부품 비용: 1778 -> 1441 (18.9% 감소), 오진단 341건 -> 309건
[도입 로드맵] 단기(1~3월): 가중치 갱신 + 정비관 확인 병행 / 중기(3~12월): AAR 분기 반복·신규 코드 반영 / 장기(1년~): 군수 정보체계 이력 연동

미션 10. 무기체계 고장 코드(DTC) 기반 연관 고장 예측 및 정비 가이드 도구 완성

  • 난이도: 최고급 / 예상 소요시간: 3.5시간
  • 코드 설명: DTC 로그 검증·정제, 진단 모델 자동 재학습(챔피언·도전자 비교 후 교체), 고장 코드 기반 연관 고장 진단과 신뢰도 산출, 신뢰도 낮은 건의 정비관 승인 대기 분리, 다빈도 고장품목 월간 소요 예측까지 정비 가이드 도구를 한 파일로 통합 실행하고, 자체 점검·운영 문서 완비 점검·정비 담당 장교 브리핑 요약을 출력한다.
"""DTC 로그 검증·정제, 진단 모델 자동 재학습(챔피언·도전자 비교 후 교체), 고장 코드 기반 연관 고장 진단과 신뢰도 산출, 신뢰도 낮은 건의 정비관 승인 대기 분리, 다빈도 고장품목 월간 소요 예측까지 정비 가이드 도구를 한 파일로 통합 실행하고, 자체 점검·운영 문서 완비 점검·정비 담당 장교 브리핑 요약을 출력한다."""
import time
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.linear_model import LogisticRegression, Ridge
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import make_pipeline

# ---------------------------------------------------------------------------
# [교육용가상자료] 가상 차량 100대의 18개월 고장 진단 코드(DTC)·정비 이력 생성기 (실제 부대·장비·정비 실적 없음)
# 부품 16종(1000가동시간당 고장률 차등), DTC 28종(부품 고유 코드, 짝 부품 공유 코드, 계통 증상 코드)
# 고장 1건(정비 이력)마다 DTC가 1~5개 동시 발생하고, 연쇄 고장 코드와 일시 잡음 코드가 섞인다.
# ---------------------------------------------------------------------------
PARTS = [  # (ID, 이름, 계통, 1000가동시간당 고장률, 가상 단가지수, 수리시간 h)
    ("P01", "연료분사기", "engine", 12.0, 3.0, 2.0), ("P02", "터보차저", "engine", 9.0, 8.0, 5.0),
    ("P03", "축전지", "electric", 6.5, 4.0, 1.5), ("P04", "발전기", "electric", 4.0, 9.0, 3.0),
    ("P05", "변속기 제어모듈", "drive", 3.0, 12.0, 6.0), ("P06", "냉각수 펌프", "engine", 2.2, 3.5, 3.0),
    ("P07", "유압 펌프", "hydraulic", 1.6, 10.0, 4.0), ("P08", "속도 센서", "drive", 1.2, 2.0, 1.5),
    ("P09", "ECU 커넥터", "electric", 0.9, 2.5, 1.5), ("P10", "연료 필터", "engine", 0.7, 0.8, 0.7),
    ("P11", "제동 밸브", "brake", 0.5, 6.0, 3.0), ("P12", "조향 액추에이터", "drive", 0.4, 9.0, 5.0),
    ("P13", "공기 필터", "engine", 0.3, 0.5, 0.5), ("P14", "통신 인터페이스 모듈", "electric", 0.25, 3.0, 1.5),
    ("P15", "윤활유 압력 센서", "engine", 0.2, 1.0, 1.0), ("P16", "팬 구동 모터", "cooling", 0.15, 2.0, 2.0)]
PID = [p[0] for p in PARTS]
PNAME = {p[0]: p[1] for p in PARTS}; PSYS = {p[0]: p[2] for p in PARTS}; RATE = {p[0]: p[3] for p in PARTS}
COST = {p[0]: p[4] for p in PARTS}; REPH = {p[0]: p[5] for p in PARTS}
PAIRS = [("P01", "P02"), ("P03", "P04"), ("P05", "P08"), ("P06", "P10"), ("P09", "P14"), ("P13", "P15")]
SHARED = {p: f"S{k + 1:02d}" for k, pr in enumerate(PAIRS) for p in pr}     # 짝 부품이 함께 쓰는 모호한 코드
SPEC = {p: f"C{p[1:]}1" for p in PID}                                          # 부품 고유 코드
SYS = ["engine", "electric", "drive", "hydraulic", "brake", "cooling"]
SYMP = {s: f"G{i + 1:02d}" for i, s in enumerate(SYS)}                         # 계통 증상 코드
CODES = sorted(set(SPEC.values()) | set(SHARED.values()) | set(SYMP.values()))
CASCADE = {"P01": [("P10", 0.20)], "P02": [("P06", 0.20)], "P03": [("P04", 0.25)], "P04": [("P09", 0.25)],
           "P05": [("P08", 0.30)], "P06": [("P16", 0.30)], "P09": [("P14", 0.20)], "P12": [("P07", 0.20)]}
CRIT_SYS = {"engine", "drive", "brake"}        # 기동·제동 불능 위험 계통(경보 대상)
MONTHS = 18


def make_fleet(n=100, seed=0):
    rng = np.random.default_rng(seed)
    f = pd.DataFrame({"veh": [f"V{i:03d}" for i in range(n)], "age": rng.integers(1, 26, n).astype(float),
                      "terrain": rng.uniform(0, 1, n), "load": rng.uniform(0, 1, n), "pm_gap": rng.uniform(20, 200, n),
                      "humid": rng.uniform(0, 1, n), "base_h": rng.uniform(55, 125, n)})
    f["mult"] = np.exp(0.045 * (f["age"] - 13) + 0.9 * (f["terrain"] - .5) + 0.8 * (f["load"] - .5)
                       + 0.006 * (f["pm_gap"] - 110) + 0.7 * (f["humid"] - .5))
    return f


def make_data(seed=1, months=MONTHS):
    """반환: fleet(차량 속성), ev(정비 이력 1건=1행: 발생 DTC 목록, 실제 고장 부품), log(DTC 발생 로그, 행=코드 1건)"""
    rng = np.random.default_rng(seed)
    fleet = make_fleet()
    V = len(fleet)
    temp = 12 + 14 * np.sin(2 * np.pi * (np.arange(months) - 3) / 12)            # 월평균 기온(가상)
    hours = fleet["base_h"].values[:, None] * (1 + 0.25 * np.sin(2 * np.pi * np.arange(months) / 12 + 1)) * rng.lognormal(0, .12, (V, months))
    fleet["hours"] = hours.sum(axis=1)                                           # 차량별 18개월 총 가동시간
    fleet.attrs["month_h"] = hours.sum(axis=0)                                   # 월별 전체 가동시간(고장률 분모)
    evs = []
    for p in PID:
        s = np.ones(months)
        if p in ("P03", "P04"): s = np.exp(-0.06 * (temp - 12))                   # 한랭기 전기계통 증가
        if p in ("P06", "P16"): s = np.exp(0.05 * (temp - 12))                    # 혹서기 냉각계통 증가
        cnt = rng.poisson(RATE[p] * hours / 1000 * fleet["mult"].values[:, None] * s[None, :])
        for v, m in zip(*np.nonzero(cnt)):
            for _ in range(cnt[v, m]):
                evs.append((fleet["veh"][v], int(m), m * 720 + rng.uniform(0, 720), p, float(temp[m] + rng.normal(0, 3))))
    ev = pd.DataFrame(evs, columns=["veh", "month", "t", "part", "temp"]).sort_values("t").reset_index(drop=True)
    ev["ev_id"] = [f"W{i:05d}" for i in range(len(ev))]
    rows, codes = [], []
    for e in ev.itertuples():
        c, r = [], rng.random(3)
        if r[0] < 0.6: c.append(SPEC[e.part])
        if e.part in SHARED and r[1] < 0.6: c.append(SHARED[e.part])
        if r[2] < 0.5: c.append(SYMP[PSYS[e.part]])
        if not c: c.append(SHARED.get(e.part, SPEC[e.part]))
        for q, pr in CASCADE.get(e.part, []):
            if rng.random() < pr: c.append(SPEC[q])
        if rng.random() < 0.12: c.append(SPEC[PID[rng.integers(0, 16)]])
        c = list(dict.fromkeys(c))
        rng.shuffle(c)                                                           # 동시 발생 코드의 기록 순서는 임의
        codes.append(c)
        for k, cd in enumerate(c):
            rows.append((e.ev_id, e.veh, e.t + k * 0.1, cd))
    ev["codes"] = codes
    log = pd.DataFrame(rows, columns=["ev_id", "veh", "ts", "code"])
    return fleet, ev, log

# ---------------------------------------------------------------------------
# 공통 분석 조각: 코드 멀티핫 행렬, 차량 맥락 피처, 학습/검증 분할(앞 12개월 학습, 뒤 6개월 검증)
# ---------------------------------------------------------------------------
CIDX = {c: i for i, c in enumerate(CODES)}
SPLIT_M = 12


def multi_hot(ev):
    X = np.zeros((len(ev), len(CODES)))
    for r, cs in enumerate(ev["codes"]):
        for c in cs:
            X[r, CIDX[c]] = 1
    return X


def context(ev, fleet):
    f = fleet.set_index("veh")
    return np.column_stack([ev["veh"].map(f["age"]) / 25, ev["veh"].map(f["pm_gap"]) / 200, ev["veh"].map(f["terrain"]),
                            ev["veh"].map(f["load"]), (ev["temp"] - 12) / 15])


def feats(ev, fleet):
    X = multi_hot(ev)
    n = X.sum(axis=1, keepdims=True) / 3
    return np.hstack([X, n, context(ev, fleet)])


def monthly_counts(ev):
    return ev.groupby(["month", "part"]).size().unstack(fill_value=0).reindex(columns=PID, fill_value=0)


def build_monthly(ev, fleet, top_n=8):
    """부품별 월간 고장 건수 예측용 표: 지연값(lag), 이동평균, 추세, 기대 건수(가동시간 x 설계 고장률), 기온 상호작용, 계절 성분"""
    mc = ev.groupby(["month", "part"]).size().unstack(fill_value=0)
    top = ev["part"].value_counts().index[:top_n]
    mh, tm = fleet.attrs["month_h"], ev.groupby("month")["temp"].mean().values
    rows = []
    for p in top:
        c = mc[p].values.astype(float)
        for m in range(3, MONTHS):
            L = np.log1p(c)
            rows.append({"part": p, "month": m, "y": c[m], "lag1": L[m - 1], "lag2": L[m - 2], "lag3": L[m - 3], "roll3": L[m - 3:m].mean(),
                         "trend": L[m - 1] - L[m - 2], "roll3_std": L[m - 3:m].std(), "log_expected": np.log(mh[m] / 1000 * RATE[p]),
                         "rate_lag1": c[m - 1] / (mh[m - 1] / 1000) / RATE[p], "temp": tm[m] / 10, "cold_elec": max(0, 12 - tm[m]) / 10 * (p in ("P03", "P04")),
                         "heat_cool": max(0, tm[m] - 12) / 10 * (p in ("P06", "P16")), "sin": np.sin(2 * np.pi * m / 12), "cos": np.cos(2 * np.pi * m / 12)})
    D = pd.DataFrame(rows)
    return D, [c for c in D.columns if c not in ("part", "month", "y")], top

# ---------------------------------------------------------------------------
# [교육용가상자료] 부품별 정비 조치 가이드라인(요약 3단계). 실제 정비 교범이 아니라 교육용으로 만든 예시 문구다.
# ---------------------------------------------------------------------------
GUIDE = {
    "P01": "연료 압력 측정 -> 분사기 누설·막힘 확인 -> 불량 시 분사기 교체", "P02": "흡기 압력·이물 점검 -> 축 유격 확인 -> 불량 시 터보차저 교체",
    "P03": "단자 전압·부식 점검 -> 충전 계통 확인 -> 불량 시 축전지 교체", "P04": "발전 전압 측정 -> 벨트·배선 점검 -> 불량 시 발전기 교체",
    "P05": "제어모듈 소프트웨어·커넥터 점검 -> 변속 이력 확인 -> 불량 시 모듈 교체", "P06": "냉각수량·누수 점검 -> 펌프 작동음 확인 -> 불량 시 펌프 교체",
    "P07": "유압 오일량·압력 점검 -> 펌프 누설 확인 -> 불량 시 펌프 교체", "P08": "센서 배선·간극 점검 -> 신호 파형 확인 -> 불량 시 센서 교체",
    "P09": "커넥터 접촉·부식 점검 -> 핀 상태 확인 -> 불량 시 커넥터 교체", "P10": "필터 오염도 점검 -> 연료 흐름 확인 -> 불량 시 필터 교체",
    "P11": "제동 압력 점검 -> 밸브 작동 확인 -> 불량 시 밸브 교체(안전 확인 필수)", "P12": "조향 반응 점검 -> 액추에이터 작동 확인 -> 불량 시 교체(안전 확인 필수)",
    "P13": "필터 차압 점검 -> 오염 확인 -> 불량 시 필터 교체", "P14": "통신 링크 상태 점검 -> 모듈 전원 확인 -> 불량 시 모듈 교체",
    "P15": "압력 센서 신호 점검 -> 윤활유량 확인 -> 불량 시 센서 교체", "P16": "팬 모터 전류 점검 -> 회전 확인 -> 불량 시 모터 교체"}

CONFIG = {"conf_review": 0.8, "retrain_tolerance": 0.01, "mape_target": 0.25}
OPS_GUIDE = {                                                              # docs/mnt03_operations_guide.md 요약
    "1. 목적과 범위": "DTC 기반 고장 부품 진단 보조와 다빈도 고장품목 식별. 최종 정비 판단은 숙련 정비관.",
    "2. 입력 데이터": "DTC 로그(차량 ID, 시각, 코드), 정비 이력(정비 건 ID, 고장 부품), 차량 속성. 실제 부대 자료 외부 AI 입력 금지.",
    "3. 일일 운용 절차": "로그 검증 -> 정비 건 묶음 -> 진단·신뢰도 산출 -> 낮은 신뢰도 건 승인 대기 -> 조치 후 정비 이력 입력.",
    "4. 승인(HITL) 기준": "신뢰도 0.8 미만·다계통 동시 발생은 숙련 정비관 결정 전 확정 금지, 무응답 시 재확인 표시, 결정 사유 기록.",
    "5. 재학습 절차": "월 1회 신규 정비 이력으로 도전자 학습, 검증 정확도가 나빠지지 않을 때만 교체.",
    "6. 장애 대응": "모델 오류 시 단일 코드 매핑표로 대체, 손상 로그는 수동 확인 목록으로 분리.",
    "7. 연동 인터페이스": "국방 군수 정보체계와 DTC·정비 이력 표준 필드(장비코드·분류군·시각)를 CSV/API로 교환하는 안을 차기 과제로 둔다.",
}

fleet, ev, log = make_data()
OLD, NEW, OPS = ev[ev["month"] < 12], ev[(ev["month"] >= 12) & (ev["month"] < 15)], ev[ev["month"] >= 15].reset_index(drop=True)
t_start = time.perf_counter()


def fit(d):
    return LogisticRegression(C=3, max_iter=500).fit(feats(d, fleet), d["part"])


def retrain(champion, old, new):
    """도전자를 (기존 + 신규 앞 2/3 기간)으로 학습하고, 신규 뒤 1/3 기간에서 챔피언과 정확도를 비교해 비슷하거나 낫다면 교체."""
    cut = new["month"].min() + 2
    hold = new[new["month"] >= cut]
    challenger = fit(pd.concat([old, new[new["month"] < cut]], ignore_index=True))
    a_old = (champion.predict(feats(hold, fleet)) == hold["part"].values).mean()
    a_new = (challenger.predict(feats(hold, fleet)) == hold["part"].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 validate(df):
    ok = df["codes"].map(lambda cs: len(cs) > 0 and all(c in CODES for c in cs)) & df["veh"].isin(fleet["veh"])
    return df[ok].copy(), df[~ok].copy()


def predict_safe(model, d):
    """모델 오류 시 단일 코드 -> 최다 부품 매핑표로 대체한다."""
    try:
        P = model.predict_proba(feats(d, fleet))
        return model.classes_[P.argmax(axis=1)], P.max(axis=1), "모델"
    except Exception:
        mp = OLD.assign(first=OLD["codes"].str[0]).groupby("first")["part"].agg(lambda s: s.value_counts().index[0])
        return d["codes"].str[0].map(mp).fillna("P01").values, np.full(len(d), 0.5), "매핑표 대체"


# ---------------- 1. 통합 실행 ----------------
champion = fit(OLD)
model, rt = retrain(champion, OLD, NEW)
print(f"[교육용가상자료] 재학습: {rt} -> {'도전자로 교체' if rt['교체'] else '챔피언 유지'}")
ops_all = OPS.copy()
ops_all.at[3, "codes"] = ["ZZ9"]; ops_all.at[40, "codes"] = []             # 손상 로그 2건 주입
ops, manual = validate(ops_all)
ops = ops.reset_index(drop=True)
pred, conf, src = predict_safe(model, ops)
queue = ops[conf < CONFIG["conf_review"]]
alerts = [(i, p) for i, p in enumerate(pred) if PSYS[p] in CRIT_SYS and conf[i] >= CONFIG["conf_review"]]
acc = (pred == ops["part"].values).mean()
hi = conf >= CONFIG["conf_review"]
print(f"운용 정비 건 {len(ops_all)}건: 진단 {len(ops)}건, 수동 확인 {len(manual)}건, 출처 '{src}', 정확도 {acc:.1%} (신뢰도 0.8 이상 {hi.sum()}건은 {(pred[hi] == ops['part'].values[hi]).mean():.1%})")

# 다빈도 품목 월간 소요 예측(마지막 달): 과거 월로 학습한 릿지로 다음 달 건수 예측
D, FE, top = build_monthly(ev, fleet)
a, b = D[D["month"] < MONTHS - 1], D[D["month"] == MONTHS - 1]
fc = np.exp(make_pipeline(StandardScaler(), Ridge(alpha=3.0)).fit(a[FE], np.log(a["y"])).predict(b[FE]))
fmape = float(np.mean(np.abs(b["y"].values - fc) / b["y"].values))
print("다빈도 품목 다음 달 소요 예측: " + ", ".join(f"{PNAME[p]} {f:.0f}건(실제 {y:.0f})" for p, f, y in zip(b["part"].values[:3], fc[:3], b["y"].values[:3])) + f" / 8개 품목 MAPE {fmape:.1%}")
sec = time.perf_counter() - t_start

# ---------------- 2. 자체 점검 ----------------
_, _, bad_src = predict_safe(None, ops.head(3))
checks = {
    "로그 유실 0건": len(ops) + len(manual) == len(ops_all),
    "손상 로그 수동 확인 분리": len(manual) == 2,
    "재학습 파이프라인 실행": "교체" in rt,
    "신뢰도 0~1 범위": bool(((conf >= 0) & (conf <= 1)).all()),
    "모델 장애 시 매핑표 대체": bad_src == "매핑표 대체",
    "모든 진단에 조치 가이드 연결": all(p in GUIDE for p in pred),
    "저신뢰 건 자동 확정 금지(대기)": len(queue) == int((conf < CONFIG["conf_review"]).sum()),
    f"소요 예측 MAPE {CONFIG['mape_target']:.0%} 이하": fmape <= CONFIG["mape_target"],
}
print(f"\n자체 점검 {sum(checks.values())}/{len(checks)} 통과, 자동 재학습 파이프라인 {'정상 실행' if checks['재학습 파이프라인 실행'] else '실패'}, 전체 실행 {sec:.1f}초")
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))
cnt = pd.Series(pred).value_counts().head(8)
ax[0].bar(cnt.index, cnt.values); ax[0].set_title("Diagnosed parts (operating period)")
ax[1].hist(conf, bins=20); ax[1].axvline(CONFIG["conf_review"], ls="--", color="k"); ax[1].set_title("Confidence (line = review threshold)")
plt.tight_layout(); plt.show()

fr = (ev["part"].value_counts() / fleet["hours"].sum() * 1000)
cum = ev["part"].value_counts(normalize=True).cumsum()
a_cnt = int((cum <= 0.8).sum()) + 1
print("\n[정비 담당 장교 브리핑 요약] [교육용가상자료]")
print(f"1. 18개월 정비 {len(ev)}건: 고장률 상위 " + ", ".join(f"{PNAME[p]} {v:.1f}건/1000h" for p, v in fr.sort_values(ascending=False).head(3).items()))
print(f"2. 상위 {a_cnt}개 품목이 전체 고장의 80% 이상: 예비 수리부속 우선 확보 대상")
print(f"3. 운용 기간 진단 {len(ops)}건 중 신뢰도 0.8 이상 {int(hi.sum())}건(정확도 {(pred[hi] == ops['part'].values[hi]).mean():.1%}), 정비관 승인 대기 {len(queue)}건(자동 확정 0건)")
print(f"4. 기동·제동 계통 경보 {len(alerts)}건, 손상 로그 {len(manual)}건 수동 확인")
print(f"5. 진단 모델: {'신규 정비 이력으로 교체' if rt['교체'] else '기존 유지'}, 소요 예측 MAPE {fmape:.1%}")
print("6. 건의: 다빈도 품목 예방정비 주기 재검토, 국방 군수 정보체계와 DTC·정비 이력 표준 필드 연동 인터페이스 정의")

실행 결과 예시

[교육용가상자료] 재학습: {'챔피언': 0.845, '도전자': 0.847, '교체': True} -> 도전자로 교체
운용 정비 건 1554건: 진단 1552건, 수동 확인 2건, 출처 '모델', 정확도 83.2% (신뢰도 0.8 이상 931건은 99.2%)
다빈도 품목 다음 달 소요 예측: 연료분사기 133건(실제 147), 터보차저 97건(실제 109), 축전지 44건(실제 35) / 8개 품목 MAPE 22.7%

자체 점검 8/8 통과, 자동 재학습 파이프라인 정상 실행, 전체 실행 18.1초
운영 문서 7개 장 작성률 100% (목표 100%: 달성) - 목적과 범위, 입력 데이터, 일일 운용 절차, 승인(HITL) 기준, 재학습 절차, 장애 대응, 연동 인터페이스

[정비 담당 장교 브리핑 요약] [교육용가상자료]
1. 18개월 정비 10681건: 고장률 상위 연료분사기 16.3건/1000h, 터보차저 11.6건/1000h, 축전지 11.6건/1000h
2. 상위 5개 품목이 전체 고장의 80% 이상: 예비 수리부속 우선 확보 대상
3. 운용 기간 진단 1552건 중 신뢰도 0.8 이상 931건(정확도 99.2%), 정비관 승인 대기 621건(자동 확정 0건)
4. 기동·제동 계통 경보 710건, 손상 로그 2건 수동 확인
5. 진단 모델: 신규 정비 이력으로 교체, 소요 예측 MAPE 22.7%
6. 건의: 다빈도 품목 예방정비 주기 재검토, 국방 군수 정보체계와 DTC·정비 이력 표준 필드 연동 인터페이스 정의