289 KiB
289 KiB
In [ ]:
# ============================================================================
# [Google Colab 환경 설정] 필수 라이브러리 설치 및 임포트
# ============================================================================
!pip install -q numpy pandas matplotlib scipy scikit-learn
import os
import sys
import time
import math
import json
import re
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import warnings
warnings.filterwarnings('ignore')
# 그래프 영문 폰트 기본 설정 (환경 독립성 보장)
plt.rcParams['font.sans-serif'] = 'DejaVu Sans'
plt.rcParams['axes.unicode_minus'] = False
print(f"실행 환경 준비 완료 (Python {sys.version.split()[0]})")
In [ ]:
"""무기체계 핵심 부품 공급망(단종 부품, 해외 의존도, 리드타임) 데이터셋 스키마를 정의·검증하고, 국산화 후보 선정 업무를 AS-IS/TO-BE 단계로 분해해 병목 구간을 도출한다."""
import json
import re
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
# ---------------------------------------------------------------------------
# [교육용가상자료] 가상 무기체계 부품·공급망 데이터 생성기 (실제 체계·업체·국가·단가 정보 없음)
# 부품 100종(해외 조달 약 80%)의 공급망 속성과, 국내 부품 카탈로그(과거 승인된 국산 대체품 +
# 설명은 비슷하지만 규격이 맞지 않는 유사 후보)를 함께 만든다.
# ---------------------------------------------------------------------------
COUNTRIES = ["가상국가A", "가상국가B", "가상국가C", "국내"]
CATS = {
"전원모듈": dict(words=["DC-DC", "컨버터", "절연형", "전원", "정출력"], syn={"컨버터": "변환장치", "절연형": "아이솔레이션", "정출력": "안정화"}, std="MIL-STD-704", iface="단자대"),
"커넥터": dict(words=["원형", "커넥터", "방수형", "다핀", "나사체결"], syn={"커넥터": "접속자", "방수형": "밀폐형", "나사체결": "스크루결합"}, std="MIL-DTL-38999", iface="원형핀"),
"유압밸브": dict(words=["유압", "솔레노이드", "밸브", "방향제어", "4포트"], syn={"밸브": "개폐장치", "방향제어": "유로전환", "솔레노이드": "전자식"}, std="SAE-AS5440", iface="플랜지"),
"베어링": dict(words=["볼", "베어링", "고속", "밀봉형", "회전축"], syn={"베어링": "축받이", "밀봉형": "실드형", "고속": "고회전"}, std="ISO-492", iface="축결합"),
"광학렌즈": dict(words=["적외선", "렌즈", "게르마늄", "반사방지", "집광"], syn={"렌즈": "광학창", "집광": "초점형성", "반사방지": "AR코팅"}, std="MIL-PRF-13830", iface="마운트"),
"자이로센서": dict(words=["광섬유", "자이로", "각속도", "센서", "관성"], syn={"센서": "감지기", "각속도": "회전율", "관성": "INS용"}, std="IEEE-952", iface="직렬통신"),
"RF증폭기": dict(words=["RF", "전력증폭기", "광대역", "모듈", "송신"], syn={"전력증폭기": "파워앰프", "송신": "출력단", "광대역": "와이드밴드"}, std="MIL-STD-461", iface="SMA"),
"제어보드": dict(words=["FPGA", "제어보드", "임베디드", "연산", "입출력"], syn={"제어보드": "컨트롤카드", "연산": "신호처리", "임베디드": "내장형"}, std="VITA-46", iface="백플레인"),
}
V_OPTS, W_OPTS = [12, 24, 28], [25, 50, 100]
VENDORS = ["가상정밀(주)", "가상전자(주)", "가상유공압(주)", "가상광학(주)", "가상테크(주)", "가상시스템(주)"]
def describe(cat, words, s):
# [TODO : 학생 실습 1] 무기체계 핵심 부품 공급망(단종 부품, 해외 의존도, 리드타임) 데이터셋 스키마 정의 관련 핵심 로직을 구현하세요.
# 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
# <<< 여기에 코드를 작성하세요 >>>
pass # 작성 후 pass를 제거하세요.
def _sig(x):
# [TODO : 학생 실습 1] 무기체계 핵심 부품 공급망(단종 부품, 해외 의존도, 리드타임) 데이터셋 스키마 정의 관련 핵심 로직을 구현하세요.
# 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
# <<< 여기에 코드를 작성하세요 >>>
pass # 작성 후 pass를 제거하세요.
def make_parts(n=100, seed=7):
"""부품 목록(공급망 속성 포함)과 국내 카탈로그를 만든다. 반환: (parts DataFrame, catalog DataFrame)"""
rng = np.random.default_rng(seed)
cats, rows, cat_rows = list(CATS), [], []
for i in range(n):
cat = cats[i % len(cats)]; c = CATS[cat]
spec = {"v": int(rng.choice(V_OPTS)), "w": int(rng.choice(W_OPTS)), "tmin": int(rng.choice([-55, -40])),
"tmax": int(rng.choice([85, 125])), "size_mm": float(round(rng.uniform(20, 120), 1)), "std": c["std"]}
words = [str(x) for x in rng.permutation(c["words"])[:4]]
country = str(rng.choice(COUNTRIES, p=[0.35, 0.3, 0.15, 0.2]))
overseas = country != "국내"
n_sup = int(rng.choice([1, 2, 3], p=[0.45, 0.35, 0.2])) if overseas else int(rng.choice([1, 2, 3]))
lead = float(round(rng.gamma(3, 45 if overseas else 25) + 20))
eol = str(rng.choice(["정상", "단종예고", "단종"], p=[0.7, 0.2, 0.1]))
crit = str(rng.choice(["A", "B", "C"], p=[0.3, 0.4, 0.3]))
z = (-2.6 + 1.1 * (n_sup == 1) + 0.9 * overseas + 0.006 * (lead - 100) + 1.2 * (eol == "단종예고")
+ 2.0 * (eol == "단종") + rng.normal(0, 0.6))
p_late = float(np.clip(_sig(z) * 0.8 + 0.05, 0, 0.9))
late = int(rng.binomial(12, p_late))
demand = int(rng.integers(20, 400))
rows.append({"part_no": f"P-{i + 1:04d}", "category": cat, "desc": describe(cat, words, spec), **spec,
"iface": c["iface"], "country": country, "overseas": overseas, "n_suppliers": n_sup,
"lead_time_days": lead, "orders_12m": 12, "late_orders": late,
"avg_delay_days": float(round(late and rng.gamma(2, 12 + 20 * p_late), 1)),
"eol_status": eol, "ltb_months": int(rng.integers(1, 25)) if eol == "단종예고" else None,
"price_change_pct": float(round(rng.normal(4 + 12 * p_late, 8), 1)), "criticality": crit,
"annual_demand": demand, "stock_qty": int(rng.integers(0, demand)),
"defect_rate": float(round(abs(rng.normal(0.8 + 2 * p_late, 0.8)), 2)),
"export_license": bool(overseas and rng.random() < 0.35),
"disrupted_next12m": bool(rng.random() < _sig(z + 0.4))})
if not overseas:
continue
# 과거 승인된 국산 대체품(정답): 동의어로 서술, 규격 호환(전력 여유 허용, 일부는 크기 차이가 커 장착 검토 필요)
eq_words = [c["syn"].get(w, w) for w in words]
hard = rng.random() < 0.15 # 서술이 많이 다른 사례
if hard:
eq_words = [c["syn"].get(w, w) for w in c["words"] if w not in words][:1] + eq_words[1:3]
eq = {**spec, "w": spec["w"] if rng.random() < 0.7 else W_OPTS[min(W_OPTS.index(spec["w"]) + 1, 2)],
"size_mm": float(round(spec["size_mm"] * (rng.uniform(1.11, 1.15) if rng.random() < 0.12 else rng.uniform(0.95, 1.05)), 1))}
cat_rows.append({"cand_no": f"D-{len(cat_rows) + 1:04d}", "category": cat, "desc": describe(cat, eq_words, eq), **eq,
"iface": c["iface"], "vendor": str(rng.choice(VENDORS)), "approved_for": f"P-{i + 1:04d}", "kind": "equivalent"})
# 유사 오답 후보: 설명은 거의 같지만 전압이 다르거나(5V/48V) 상용 온도 등급(0~70C)
for kind in ("volt_mismatch", "temp_mismatch"):
d = dict(spec)
if kind == "volt_mismatch":
d["v"] = int(rng.choice([5, 48])) # 체계에서 쓰지 않는 전압
else:
d["tmin"], d["tmax"] = 0, 70
d["size_mm"] = float(round(spec["size_mm"] * rng.uniform(0.95, 1.05), 1))
cat_rows.append({"cand_no": f"D-{len(cat_rows) + 1:04d}", "category": cat, "desc": describe(cat, words, d), **d,
"iface": c["iface"], "vendor": str(rng.choice(VENDORS)), "approved_for": None, "kind": kind})
parts = pd.DataFrame(rows)
catalog = pd.DataFrame(cat_rows).sample(frac=1, random_state=seed).reset_index(drop=True)
return parts, catalog
# ---------------------------------------------------------------------------
# 1) 스키마 정의: 부품 식별, 공급망(해외 의존도·공급선 수·리드타임·지연 이력), 단종(DMSMS), 조달 속성
SCHEMA = {
"part_no": {"type": str, "pattern": r"^P-\d{4}$", "desc": "부품 번호"},
"category": {"type": str, "enum": list(CATS), "desc": "부품 범주"},
"country": {"type": str, "enum": COUNTRIES, "desc": "주 공급 국가(가상)"},
"n_suppliers": {"type": int, "min": 1, "max": 10, "desc": "공급선 수"},
"lead_time_days": {"type": (int, float), "min": 1, "max": 1000, "desc": "조달 리드타임(일)"},
"orders_12m": {"type": int, "min": 0, "max": 100, "desc": "최근 12개월 발주 건수"},
"late_orders": {"type": int, "min": 0, "max": 100, "desc": "납기 지연 건수"},
"avg_delay_days": {"type": (int, float), "min": 0, "max": 365, "desc": "평균 지연일"},
"eol_status": {"type": str, "enum": ["정상", "단종예고", "단종"], "desc": "단종 상태"},
"ltb_months": {"type": (int, float), "min": 0, "max": 60, "optional": True, "desc": "최종구매(LTB)까지 남은 개월"},
"price_change_pct": {"type": (int, float), "min": -100, "max": 500, "desc": "전년 대비 단가 변동(%)"},
"criticality": {"type": str, "enum": ["A", "B", "C"], "desc": "체계 중요도"},
"annual_demand": {"type": int, "min": 0, "max": 100000, "desc": "연간 소요량"},
"stock_qty": {"type": int, "min": 0, "max": 100000, "desc": "현 재고"},
"defect_rate": {"type": (int, float), "min": 0, "max": 100, "desc": "입고 불량률(%)"},
"export_license": {"type": bool, "desc": "수출 허가 필요 여부"},
}
def check(name, v, r):
if v is None or (isinstance(v, float) and np.isnan(v)):
return [] if r.get("optional") else [f"{name}: 필수 값 누락"]
if not isinstance(v, r["type"]) or (isinstance(v, bool) and r["type"] is not bool):
return [f"{name}: 자료형 오류({type(v).__name__})"]
errs = []
if "enum" in r and v not in r["enum"]:
errs.append(f"{name}: 허용되지 않은 값 '{v}'")
if "min" in r and v < r["min"]:
errs.append(f"{name}: 최소값 {r['min']} 미만")
if "max" in r and v > r["max"]:
errs.append(f"{name}: 최대값 {r['max']} 초과")
if "pattern" in r and not re.match(r["pattern"], v):
errs.append(f"{name}: 형식 불일치 '{v}'")
return errs
def validate(rec):
errs = [f"{k}: 필드 누락" for k in SCHEMA if k not in rec]
for k, r in SCHEMA.items():
if k in rec:
errs += check(k, rec[k], r)
if not errs and rec["late_orders"] > rec["orders_12m"]: # 필드 간 논리 규칙
errs.append("late_orders: 발주 건수보다 지연 건수가 많음")
if not errs and rec["eol_status"] == "단종예고" and rec["ltb_months"] is None:
errs.append("ltb_months: 단종예고 부품은 LTB 개월 필수")
return errs
def to_record(row):
rec = {k: (row[k].item() if hasattr(row[k], "item") else row[k]) for k in SCHEMA}
rec["ltb_months"] = None if pd.isna(rec["ltb_months"]) else int(rec["ltb_months"])
return rec
parts, _ = make_parts(100, seed=7)
records = [to_record(r) for _, r in parts.iterrows()]
ok = sum(not validate(r) for r in records)
print(f"[스키마 검증] 부품 {len(records)}건 중 통과 {ok}건, 오류 {len(records) - ok}건 / 필드 {len(SCHEMA)}개")
print(f"해외 의존 {sum(r['country'] != '국내' for r in records)}건, 단일 공급선 {sum(r['n_suppliers'] == 1 for r in records)}건, "
f"단종예고·단종 {sum(r['eol_status'] != '정상' for r in records)}건, 평균 리드타임 {np.mean([r['lead_time_days'] for r in records]):.0f}일")
# 2) 오류 주입 시험: 형식·허용값·범위·자료형·논리 규칙 위반 6종
bad = json.loads(json.dumps(records[2], ensure_ascii=False))
bad.update({"part_no": "P12", "country": "미상", "lead_time_days": -5, "n_suppliers": "2"})
bad2 = dict(records[5], late_orders=15, orders_12m=12)
bad3 = dict(records[0], eol_status="단종예고", ltb_months=None)
print("\n[오류 주입 결과]")
for e in validate(bad) + validate(bad2) + validate(bad3):
print(" -", e)
# 3) AS-IS 업무 단계 분해: [교육용가상자료] 국산화 후보 선정 30건의 단계별 소요일(작업 + 대기)
rng = np.random.default_rng(3)
STEPS = [("체계 부품목록(BOM) 수집", 2, 1), ("공급업체 자료 요청·회신", 3, 9), ("단종(DMSMS) 공고 수집·확인", 2, 6),
("조달 이력·납기 지연 수작업 집계", 5, 2), ("해외 의존도 분류", 2, 1), ("위험 부품 선정 회의", 1, 7),
("국내 업체 탐색(전화·자료)", 6, 8), ("규격 비교표 작성", 5, 2), ("기술 검토 의뢰", 1, 10),
("국산화 후보 보고서 작성", 3, 1), ("결재(승인)", 1, 5), ("계약 의뢰 준비", 2, 2)]
TOBE = {1: ("표준 서식 자동 수집", 0.3), 2: ("공고 자동 파싱(미션 2)", 0.2), 3: ("룰 엔진 자동 집계(미션 3)", 0.1),
4: ("자동 분류(미션 3)", 0.1), 6: ("대체품 자동 추천(미션 4)", 0.3), 7: ("규격 자동 대조(미션 4)", 0.2),
9: ("보고서 초안 자동 생성(미션 10)", 0.4)}
rows = []
for i, (name, work, wait) in enumerate(STEPS):
w = rng.gamma(4, work / 4, 30); q = rng.gamma(2, wait / 2, 30)
f = TOBE.get(i, ("유지(사람 판단)", 1.0))
rows.append({"단계": f"{i + 1:02d}. {name}", "작업(일)": w.mean(), "대기(일)": q.mean(), "AS-IS(일)": w.mean() + q.mean(),
"TO-BE 방식": f[0], "TO-BE(일)": w.mean() * f[1] + q.mean() * (f[1] if f[1] < 1 else 1.0)})
proc = pd.DataFrame(rows)
share = proc["AS-IS(일)"] / proc["AS-IS(일)"].sum()
proc["병목"] = np.where((share >= share.sort_values().iloc[-3]) | (proc["대기(일)"] / proc["AS-IS(일)"] > 0.75), "병목", "")
print("\n[AS-IS / TO-BE 업무 단계 비교]")
print(proc.round(1).to_string(index=False))
n_bn = int((proc["병목"] == "병목").sum())
asis, tobe = proc["AS-IS(일)"].sum(), proc["TO-BE(일)"].sum()
print(f"\n업무 단계 {len(proc)}개(목표 10개 이상: {'달성' if len(proc) >= 10 else '미달'}), 병목 {n_bn}개소(목표 3개소 이상: {'달성' if n_bn >= 3 else '미달'})")
print(f"후보 선정 1건 소요: AS-IS {asis:.1f}일 -> TO-BE {tobe:.1f}일 ({1 - tobe / asis:.0%} 단축 예상). 결재·기술 검토·회의는 사람이 수행한다.")
# 4) 입출력 데이터 명세 요약
print("\n[입출력 명세] 입력: 부품 공급망 레코드(위 16개 필드) / 출력: 위험 등급, 국산화 후보 Top-3, 추천 근거, 결재 이력")
plt.figure(figsize=(9, 5))
y = np.arange(len(proc))
plt.barh(y - 0.2, proc["AS-IS(일)"], 0.4, label="AS-IS", color="tab:gray")
plt.barh(y + 0.2, proc["TO-BE(일)"], 0.4, label="TO-BE", color="tab:blue")
plt.yticks(y, [f"step {i + 1}{' *' if b else ''}" for i, b in enumerate(proc["병목"])])
plt.gca().invert_yaxis(); plt.xlabel("days per case"); plt.legend()
plt.title("Localization candidate process: AS-IS vs TO-BE (* = bottleneck, synthetic)")
plt.tight_layout(); plt.show()
In [ ]:
"""국내외 공급업체 자료(CSV·엑셀 내보내기 TSV·JSON)를 하나의 표준 스키마로 정제하고, 문서마다 서식이 다른 부품 단종(DMSMS) 공고문 텍스트에서 부품번호·단종일·최종구매(LTB) 기한·대체품 정보를 파싱하는 전처리 모듈을 구현한다."""
import csv
import io
import json
import re
from datetime import date
import numpy as np
import pandas as pd
# ---------------------------------------------------------------------------
# [교육용가상자료] 서로 다른 형식의 원시 자료 (실제 업체·공고 아님). 표기가 일부러 제각각이다.
# ---------------------------------------------------------------------------
RAW_CSV = """업체코드,업체명,국가,공급부품,리드타임,최근거래일
V001,(주)가상정밀,국내,P-0001,45일,2026.03.02
V002, 가상전자 주식회사 ,KR,P-0004,2개월,2026-3-15
V003,Virtual Parts Inc.,A국,P-0003,180,20260110
V004,가상유공압㈜,국내,P-0003,60일,26/02/27
V005,VIRTUAL PARTS INC,가상국가A,P-0011,6개월,2025.12.30
V006,Sample Optics GmbH,B,P-0005,210일,2026-01-05
"""
RAW_TSV = "공급업체\t국적\t품목번호\t납기(일)\t최종거래\n" \
"가상광학(주)\t대한민국\tP-0005\t75\t2026.02.11\n" \
"Sample Optics GmbH\t가상국가B\tP-0013\t200\t2026.01.20\n" \
"가상테크 (주)\t국내\tP-0008\t90\t2026/03/05\n" \
"Model Sensor Co.\tC국\tP-0006\t1년\t2025.11.03\n" \
"Virtual Parts Inc\tA국\tP-0003\t180\t2026.01.10\n"
RAW_JSON = json.dumps({"result": "OK", "items": [
{"vendor": {"name": "가상시스템(주)", "nation": "KR"}, "part": "P-0016", "leadTime": {"value": 3, "unit": "month"}, "lastTrade": "2026-02-01"},
{"vendor": {"name": "Model Sensor Co", "nation": "C"}, "part": "P-0014", "leadTime": {"value": 150, "unit": "day"}, "lastTrade": "2026-01-09"},
{"vendor": {"name": "가상정밀 주식회사", "nation": "KR"}, "part": "P-0002", "leadTime": {"value": 8, "unit": "week"}, "lastTrade": "2026-03-20"}]},
ensure_ascii=False)
NOTICES = [
"제목: 부품 단종 예정 통보\n부품번호: P-0003\n단종 예정일: 2027년 3월 31일\n최종구매(LTB) 마감: 2026년 12월 31일\n대체품: 없음",
"[DMSMS NOTICE] Part No. P-0011 / End of Life 2027-06-30 / Last Time Buy 2027-01-15 / Replacement: P-0011A",
"수신: 획득담당 참조\n1. 아래 부품의 생산 중단을 알려 드립니다.\n 가. 품번 : P-0013\n 나. 생산중단일 : 2026.09.30.\n 다. 최종 주문 접수 : 2026.06.30.\n2. 대체 권고품 : 협의 필요",
"부품 P-0006 은(는) 2026-12-31 부로 단종되며 최종 주문은 2026-10-31 까지 받습니다. 대체품 P-0006-R 검토 바랍니다.",
"단종공고 / 대상:P-0020 / 단종일:27.02.28 / LTB:26.11.30 / 대체:없음",
]
# ---- 정규화 규칙(서식이 바뀌면 이 사전만 고치면 된다) ----
COL_ALIAS = {"vendor": ["업체명", "공급업체", "vendor"], "country": ["국가", "국적", "nation"], "part_no": ["공급부품", "품목번호", "part"],
"lead": ["리드타임", "납기(일)", "leadTime"], "last_trade": ["최근거래일", "최종거래", "lastTrade"]}
COUNTRY_MAP = {"국내": "국내", "KR": "국내", "대한민국": "국내", "A국": "가상국가A", "A": "가상국가A", "가상국가A": "가상국가A",
"B국": "가상국가B", "B": "가상국가B", "가상국가B": "가상국가B", "C국": "가상국가C", "C": "가상국가C", "가상국가C": "가상국가C"}
UNIT_DAYS = {"일": 1, "day": 1, "주": 7, "week": 7, "개월": 30, "month": 30, "년": 365, "year": 365}
def norm_vendor(s):
# [TODO : 학생 실습 2] 국내외 공급업체 데이터 정제, 부품 단종(DMSMS) 공고 파싱 전처리 모듈 구현 관련 핵심 로직을 구현하세요.
# 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
# <<< 여기에 코드를 작성하세요 >>>
pass # 작성 후 pass를 제거하세요.
def norm_lead(v):
# [TODO : 학생 실습 2] 국내외 공급업체 데이터 정제, 부품 단종(DMSMS) 공고 파싱 전처리 모듈 구현 관련 핵심 로직을 구현하세요.
# 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
# <<< 여기에 코드를 작성하세요 >>>
pass # 작성 후 pass를 제거하세요.
def norm_date(s):
s = str(s).strip().rstrip(".")
m = re.match(r"^(\d{2,4})[.\-/년\s]*(\d{1,2})[.\-/월\s]*(\d{1,2})일?$", s) or re.match(r"^(\d{4})(\d{2})(\d{2})$", s)
if not m:
raise ValueError(f"날짜 해석 불가: {s}")
y, mo, d = (int(x) for x in m.groups())
return date(y + 2000 if y < 100 else y, mo, d).isoformat()
def pick(row, key):
for k in COL_ALIAS[key]:
if k in row:
return row[k]
raise KeyError(f"필수 열 없음: {key}")
def to_std(row, src):
return {"vendor": norm_vendor(pick(row, "vendor")), "country": COUNTRY_MAP[str(pick(row, "country")).strip()],
"part_no": str(pick(row, "part_no")).strip(), "lead_days": norm_lead(pick(row, "lead")),
"last_trade": norm_date(pick(row, "last_trade")), "source": src}
def load(text, fmt):
"""형식별 로더: csv / tsv(엑셀 내보내기) / json. 행마다 성공·실패를 따로 센다."""
if fmt == "json":
rows = [{"vendor": r["vendor"]["name"], "nation": r["vendor"]["nation"], "part": r["part"], "leadTime": r["leadTime"],
"lastTrade": r["lastTrade"]} for r in json.loads(text)["items"]]
else:
rows = list(csv.DictReader(io.StringIO(text), delimiter="\t" if fmt == "tsv" else ","))
ok, fail = [], []
for r in rows:
try:
ok.append(to_std(r, fmt))
except (KeyError, ValueError) as e:
fail.append((r, str(e)))
return ok, fail
def parse_notice(text):
"""서식이 다른 단종 공고에서 부품번호·단종일·LTB·대체품을 추출한다."""
part = re.search(r"P-\d{4}", text)
dt = r"(\d{2,4}[년.\-/]\s*\d{1,2}[월.\-/]\s*\d{1,2}일?\.?)"
eol = re.search(r"(?:단종(?: 예정)?일|생산중단일|End of Life)\s*[::]?\s*" + dt, text) or re.search(dt + r"\s*부로\s*단종", text)
ltb = re.search(r"(?:LTB\)?\s*마감|Last Time Buy|최종 주문 접수|최종 주문은|LTB)\s*[::]?\s*" + dt, text)
rep = re.search(r"(?:대체품|대체 권고품|Replacement|대체)\s*[::]?\s*(P-[\w-]+|없음|협의 필요)", text)
if not (part and eol and ltb):
raise ValueError("필수 항목(부품번호·단종일·LTB) 추출 실패")
return {"part_no": part.group(0), "eol_date": norm_date(eol.group(1)), "ltb_date": norm_date(ltb.group(1)),
"replacement": rep.group(1) if rep else "미기재"}
# 1) 형식별 파싱
results, total, success = [], 0, 0
for fmt, text in [("csv", RAW_CSV), ("tsv", RAW_TSV), ("json", RAW_JSON)]:
ok, fail = load(text, fmt)
results += ok; total += len(ok) + len(fail); success += len(ok)
print(f"[{fmt.upper():4s}] {len(ok) + len(fail)}행 중 {len(ok)}행 성공" + (f", 실패 {[e for _, e in fail]}" if fail else ""))
notices, n_fail = [], 0
for t in NOTICES:
try:
notices.append(parse_notice(t))
except ValueError as e:
n_fail += 1; print("공고 파싱 실패:", e)
total += len(NOTICES); success += len(notices)
print(f"[공문 텍스트] 단종 공고 {len(NOTICES)}건 중 {len(notices)}건 성공")
rate = success / total
print(f"\n전체 파싱 성공률 {rate:.0%} ({success}/{total}) - 목표 100%: {'달성' if rate == 1 else '미달'}")
# 2) 정제 결과(업체명 표기 통일, 중복 제거)
std = pd.DataFrame(results)
before = len(std)
std = std.drop_duplicates(["vendor", "part_no"]).sort_values(["part_no", "vendor"])
print(f"\n[정제된 공급업체 자료] {before}행 -> 중복 제거 후 {len(std)}행, 업체 {std['vendor'].nunique()}곳")
print(std.to_string(index=False))
print("\n[단종 공고 파싱 결과]")
print(pd.DataFrame(notices).to_string(index=False))
# 3) 서식 변경 대응 시험: 열 이름이 바뀐 새 양식 -> 별칭 사전 한 줄 추가로 처리
new_form = "협력업체,소재국,부품,조달기간,거래일\n가상정밀(주),국내,P-0021,30일,2026.04.01\n"
ok, fail = load(new_form, "csv")
print(f"\n새 양식(열 이름 변경) 1차 시도: 성공 {len(ok)} / 실패 {len(fail)} -> {fail[0][1] if fail else '-'}")
for k, alias in {"vendor": "협력업체", "country": "소재국", "part_no": "부품", "lead": "조달기간", "last_trade": "거래일"}.items():
COL_ALIAS[k].append(alias)
ok, fail = load(new_form, "csv")
print(f"별칭 사전 갱신 후: 성공 {len(ok)} / 실패 {len(fail)} (코드 수정 없이 설정만 변경)")
In [ ]:
"""공급선 단일화·납기 지연 이력 등 16종 조달 위험 룰(YAML 설정)을 코딩해 부품별 1차 스크리닝 등급을 산출하고, 룰 처리 지연시간·과거 단절 이력 대비 탐지력·룰별 적중 현황·규정 변경 시 설정만 바꾸는 확장성을 확인한다."""
import time
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
# ---------------------------------------------------------------------------
# [교육용가상자료] 가상 무기체계 부품·공급망 데이터 생성기 (실제 체계·업체·국가·단가 정보 없음)
# 부품 100종(해외 조달 약 80%)의 공급망 속성과, 국내 부품 카탈로그(과거 승인된 국산 대체품 +
# 설명은 비슷하지만 규격이 맞지 않는 유사 후보)를 함께 만든다.
# ---------------------------------------------------------------------------
COUNTRIES = ["가상국가A", "가상국가B", "가상국가C", "국내"]
CATS = {
"전원모듈": dict(words=["DC-DC", "컨버터", "절연형", "전원", "정출력"], syn={"컨버터": "변환장치", "절연형": "아이솔레이션", "정출력": "안정화"}, std="MIL-STD-704", iface="단자대"),
"커넥터": dict(words=["원형", "커넥터", "방수형", "다핀", "나사체결"], syn={"커넥터": "접속자", "방수형": "밀폐형", "나사체결": "스크루결합"}, std="MIL-DTL-38999", iface="원형핀"),
"유압밸브": dict(words=["유압", "솔레노이드", "밸브", "방향제어", "4포트"], syn={"밸브": "개폐장치", "방향제어": "유로전환", "솔레노이드": "전자식"}, std="SAE-AS5440", iface="플랜지"),
"베어링": dict(words=["볼", "베어링", "고속", "밀봉형", "회전축"], syn={"베어링": "축받이", "밀봉형": "실드형", "고속": "고회전"}, std="ISO-492", iface="축결합"),
"광학렌즈": dict(words=["적외선", "렌즈", "게르마늄", "반사방지", "집광"], syn={"렌즈": "광학창", "집광": "초점형성", "반사방지": "AR코팅"}, std="MIL-PRF-13830", iface="마운트"),
"자이로센서": dict(words=["광섬유", "자이로", "각속도", "센서", "관성"], syn={"센서": "감지기", "각속도": "회전율", "관성": "INS용"}, std="IEEE-952", iface="직렬통신"),
"RF증폭기": dict(words=["RF", "전력증폭기", "광대역", "모듈", "송신"], syn={"전력증폭기": "파워앰프", "송신": "출력단", "광대역": "와이드밴드"}, std="MIL-STD-461", iface="SMA"),
"제어보드": dict(words=["FPGA", "제어보드", "임베디드", "연산", "입출력"], syn={"제어보드": "컨트롤카드", "연산": "신호처리", "임베디드": "내장형"}, std="VITA-46", iface="백플레인"),
}
V_OPTS, W_OPTS = [12, 24, 28], [25, 50, 100]
VENDORS = ["가상정밀(주)", "가상전자(주)", "가상유공압(주)", "가상광학(주)", "가상테크(주)", "가상시스템(주)"]
def describe(cat, words, s):
# [TODO : 학생 실습 3] 공급선 단일화 및 납기 지연 이력 기반 1차 조달 위험 스크리닝 알고리즘 구현 관련 핵심 로직을 구현하세요.
# 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
# <<< 여기에 코드를 작성하세요 >>>
pass # 작성 후 pass를 제거하세요.
def _sig(x):
# [TODO : 학생 실습 3] 공급선 단일화 및 납기 지연 이력 기반 1차 조달 위험 스크리닝 알고리즘 구현 관련 핵심 로직을 구현하세요.
# 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
# <<< 여기에 코드를 작성하세요 >>>
pass # 작성 후 pass를 제거하세요.
def make_parts(n=100, seed=7):
"""부품 목록(공급망 속성 포함)과 국내 카탈로그를 만든다. 반환: (parts DataFrame, catalog DataFrame)"""
rng = np.random.default_rng(seed)
cats, rows, cat_rows = list(CATS), [], []
for i in range(n):
cat = cats[i % len(cats)]; c = CATS[cat]
spec = {"v": int(rng.choice(V_OPTS)), "w": int(rng.choice(W_OPTS)), "tmin": int(rng.choice([-55, -40])),
"tmax": int(rng.choice([85, 125])), "size_mm": float(round(rng.uniform(20, 120), 1)), "std": c["std"]}
words = [str(x) for x in rng.permutation(c["words"])[:4]]
country = str(rng.choice(COUNTRIES, p=[0.35, 0.3, 0.15, 0.2]))
overseas = country != "국내"
n_sup = int(rng.choice([1, 2, 3], p=[0.45, 0.35, 0.2])) if overseas else int(rng.choice([1, 2, 3]))
lead = float(round(rng.gamma(3, 45 if overseas else 25) + 20))
eol = str(rng.choice(["정상", "단종예고", "단종"], p=[0.7, 0.2, 0.1]))
crit = str(rng.choice(["A", "B", "C"], p=[0.3, 0.4, 0.3]))
z = (-2.6 + 1.1 * (n_sup == 1) + 0.9 * overseas + 0.006 * (lead - 100) + 1.2 * (eol == "단종예고")
+ 2.0 * (eol == "단종") + rng.normal(0, 0.6))
p_late = float(np.clip(_sig(z) * 0.8 + 0.05, 0, 0.9))
late = int(rng.binomial(12, p_late))
demand = int(rng.integers(20, 400))
rows.append({"part_no": f"P-{i + 1:04d}", "category": cat, "desc": describe(cat, words, spec), **spec,
"iface": c["iface"], "country": country, "overseas": overseas, "n_suppliers": n_sup,
"lead_time_days": lead, "orders_12m": 12, "late_orders": late,
"avg_delay_days": float(round(late and rng.gamma(2, 12 + 20 * p_late), 1)),
"eol_status": eol, "ltb_months": int(rng.integers(1, 25)) if eol == "단종예고" else None,
"price_change_pct": float(round(rng.normal(4 + 12 * p_late, 8), 1)), "criticality": crit,
"annual_demand": demand, "stock_qty": int(rng.integers(0, demand)),
"defect_rate": float(round(abs(rng.normal(0.8 + 2 * p_late, 0.8)), 2)),
"export_license": bool(overseas and rng.random() < 0.35),
"disrupted_next12m": bool(rng.random() < _sig(z + 0.4))})
if not overseas:
continue
# 과거 승인된 국산 대체품(정답): 동의어로 서술, 규격 호환(전력 여유 허용, 일부는 크기 차이가 커 장착 검토 필요)
eq_words = [c["syn"].get(w, w) for w in words]
hard = rng.random() < 0.15 # 서술이 많이 다른 사례
if hard:
eq_words = [c["syn"].get(w, w) for w in c["words"] if w not in words][:1] + eq_words[1:3]
eq = {**spec, "w": spec["w"] if rng.random() < 0.7 else W_OPTS[min(W_OPTS.index(spec["w"]) + 1, 2)],
"size_mm": float(round(spec["size_mm"] * (rng.uniform(1.11, 1.15) if rng.random() < 0.12 else rng.uniform(0.95, 1.05)), 1))}
cat_rows.append({"cand_no": f"D-{len(cat_rows) + 1:04d}", "category": cat, "desc": describe(cat, eq_words, eq), **eq,
"iface": c["iface"], "vendor": str(rng.choice(VENDORS)), "approved_for": f"P-{i + 1:04d}", "kind": "equivalent"})
# 유사 오답 후보: 설명은 거의 같지만 전압이 다르거나(5V/48V) 상용 온도 등급(0~70C)
for kind in ("volt_mismatch", "temp_mismatch"):
d = dict(spec)
if kind == "volt_mismatch":
d["v"] = int(rng.choice([5, 48])) # 체계에서 쓰지 않는 전압
else:
d["tmin"], d["tmax"] = 0, 70
d["size_mm"] = float(round(spec["size_mm"] * rng.uniform(0.95, 1.05), 1))
cat_rows.append({"cand_no": f"D-{len(cat_rows) + 1:04d}", "category": cat, "desc": describe(cat, words, d), **d,
"iface": c["iface"], "vendor": str(rng.choice(VENDORS)), "approved_for": None, "kind": kind})
parts = pd.DataFrame(rows)
catalog = pd.DataFrame(cat_rows).sample(frac=1, random_state=seed).reset_index(drop=True)
return parts, catalog
# ---------------------------------------------------------------------------
# ---- 조달 위험 스크리닝 룰 설정(YAML 형식 문자열: 규정이 바뀌면 코드가 아니라 이 설정만 고친다) ----
RULES_YAML = """
- id: R01
name: 단일 공급선
field: n_suppliers
op: "=="
value: 1
weight: 3
- id: R02
name: 해외 조달 의존
field: overseas
op: "=="
value: true
weight: 1
- id: R03
name: 리드타임 180일 초과
field: lead_time_days
op: ">"
value: 180
weight: 2
- id: R04
name: 납기 지연률 30% 이상
field: delay_rate
op: ">="
value: 0.3
weight: 3
- id: R05
name: 평균 지연 30일 초과
field: avg_delay_days
op: ">"
value: 30
weight: 2
- id: R06
name: 단종 예고
field: eol_status
op: "=="
value: 단종예고
weight: 3
- id: R07
name: 단종 확정
field: eol_status
op: "=="
value: 단종
weight: 4
- id: R08
name: 최종구매 6개월 이내
field: ltb_months
op: "<="
value: 6
weight: 3
- id: R09
name: 단가 20% 이상 급등
field: price_change_pct
op: ">="
value: 20
weight: 2
- id: R10
name: 핵심(A) 부품 단일 공급
field: crit_single
op: "=="
value: true
weight: 3
- id: R11
name: 재고 가용기간 < 리드타임
field: cover_gap_days
op: "<"
value: 0
weight: 3
- id: R12
name: 입고 불량률 2% 초과
field: defect_rate
op: ">"
value: 2.0
weight: 2
- id: R13
name: 수출 허가 필요
field: export_license
op: "=="
value: true
weight: 2
- id: R14
name: 재고 없음
field: stock_qty
op: "=="
value: 0
weight: 2
- id: R15
name: 공급국 편중(30% 이상)
field: country_share
op: ">="
value: 0.3
weight: 1
- id: R16
name: 지연 이력 있는 단일 공급선
field: single_late
op: "=="
value: true
weight: 2
"""
GRADE_CUT = {"고위험": 16, "주의": 10} # 가중치 합 기준 1차 스크리닝 등급
def parse_rules(text):
"""간이 YAML 파서(목록 + key: value). 숫자·true/false·따옴표 문자열을 해석한다."""
rules, cur = [], None
for line in text.strip().splitlines():
if not line.strip():
continue
s = line.strip()
if s.startswith("- "):
cur = {}; rules.append(cur); s = s[2:]
k, v = [x.strip() for x in s.split(":", 1)]
v = v.strip('"')
if v in ("true", "false"):
v = v == "true"
else:
try:
v = float(v) if "." in v else int(v)
except ValueError:
pass
cur[k] = v
return rules
OPS = {"==": lambda a, b: a == b, "!=": lambda a, b: a != b, ">": lambda a, b: a > b, ">=": lambda a, b: a >= b,
"<": lambda a, b: a < b, "<=": lambda a, b: a <= b}
def derive(df):
"""룰에 쓰는 파생 필드(지연률, 핵심 단일공급, 재고 가용기간 차이, 공급국 비중 등)를 만든다."""
d = df.copy()
d["delay_rate"] = d["late_orders"] / d["orders_12m"].clip(lower=1)
d["crit_single"] = (d["criticality"] == "A") & (d["n_suppliers"] == 1)
d["cover_gap_days"] = d["stock_qty"] / d["annual_demand"].clip(lower=1) * 365 - d["lead_time_days"]
d["country_share"] = d["country"].map(d["country"].value_counts(normalize=True)).where(d["overseas"], 0.0)
d["single_late"] = (d["n_suppliers"] == 1) & (d["late_orders"] >= 3)
return d
def apply_rules(rec, rules):
"""레코드 1건(dict)에 룰을 적용해 (위험 점수, 걸린 룰 목록)을 돌려준다. 값이 없으면 해당 룰은 건너뛴다."""
hits = []
for r in rules:
v = rec.get(r["field"])
if v is None or (isinstance(v, float) and np.isnan(v)):
continue
if OPS[r["op"]](v, r["value"]):
hits.append(r)
score = sum(r["weight"] for r in hits)
grade = "고위험" if score >= GRADE_CUT["고위험"] else ("주의" if score >= GRADE_CUT["주의"] else "관심")
return score, grade, [r["id"] for r in hits]
# [교육용가상자료] 룰과 가상 지침 조항의 대응표(실제 규정 조문이 아니라 연결 방식을 보여 주는 예시)
CLAUSE = {"R01": "가상지침 3조1항(공급선 다원화)", "R02": "가상지침 3조2항(해외 의존 관리)", "R03": "가상지침 4조1항(리드타임 관리)",
"R04": "가상지침 4조2항(납기 준수)", "R05": "가상지침 4조2항(납기 준수)", "R06": "가상지침 5조1항(단종 관리)",
"R07": "가상지침 5조1항(단종 관리)", "R08": "가상지침 5조2항(최종구매 판단)", "R09": "가상지침 6조1항(가격 변동 점검)",
"R10": "가상지침 3조3항(핵심 부품 관리)", "R11": "가상지침 4조3항(재고 확보)", "R12": "가상지침 7조1항(품질 관리)",
"R13": "가상지침 3조4항(수출통제 확인)", "R14": "가상지침 4조3항(재고 확보)", "R15": "가상지침 3조2항(해외 의존 관리)",
"R16": "가상지침 3조1항(공급선 다원화)"}
rules = parse_rules(RULES_YAML)
parts, _ = make_parts(100, seed=7)
df = derive(parts)
recs = df.to_dict("records")
# 1) 전체 부품 스크리닝과 처리 지연시간(레코드 1건당)
lat = []
res = []
for r in recs:
t0 = time.perf_counter()
out = apply_rules(r, rules)
lat.append((time.perf_counter() - t0) * 1000)
res.append(out)
df["score"], df["grade"], df["hits"] = [x[0] for x in res], [x[1] for x in res], [x[2] for x in res]
print(f"[룰 설정] 검증 룰 {len(rules)}종(목표 15종 이상: {'달성' if len(rules) >= 15 else '미달'}), 모든 룰에 가상 지침 조항 연결 {all(r['id'] in CLAUSE for r in rules)}")
print(f"룰 처리 지연시간: 평균 {np.mean(lat):.3f}ms, 최대 {np.max(lat):.3f}ms (목표 50ms 이하: {'달성' if max(lat) <= 50 else '미달'})")
# 2) 등급 분포와 과거 단절 이력(다음 12개월 실제 단절 여부) 대비 탐지력
print("\n[1차 스크리닝 등급 분포]")
print(df.groupby("grade").agg(부품수=("part_no", "size"), 실제단절률=("disrupted_next12m", "mean")).reindex(["고위험", "주의", "관심"]).round(2).to_string())
y = df["disrupted_next12m"].to_numpy()
flag = df["grade"].isin(["고위험", "주의"]).to_numpy()
tp = int((flag & y).sum())
print(f"고위험·주의 선별: 실제 단절 부품 {y.sum()}건 중 {tp}건 포착(재현율 {tp / y.sum():.0%}), 선별 {flag.sum()}건 중 정밀도 {tp / max(flag.sum(), 1):.0%}")
# 3) 룰별 적중 건수(어떤 룰이 얼마나 걸리는가)
cnt = pd.Series([h for hs in df["hits"] for h in hs]).value_counts()
tbl = pd.DataFrame([{"룰": r["id"], "이름": r["name"], "가중치": r["weight"], "적중": int(cnt.get(r["id"], 0)), "근거 조항": CLAUSE[r["id"]]} for r in rules])
print("\n[룰별 적중 현황]")
print(tbl.to_string(index=False))
# 4) 상위 위험 부품 5건과 판정 근거(설명 가능성)
print("\n[고위험 상위 5건과 근거]")
for _, r in df.sort_values("score", ascending=False).head(5).iterrows():
names = ", ".join(next(x["name"] for x in rules if x["id"] == h) for h in r["hits"])
print(f" {r['part_no']} {r['category']} 점수 {r['score']:2d} {r['grade']} (적중 룰 {len(r['hits'])}종): {names[:60]}...")
# 5) 규정 변경 대응: 리드타임 기준을 180일에서 150일로 바꾸는 설정 변경(코드 수정 없음)
new_yaml = RULES_YAML.replace("value: 180", "value: 120")
rules2 = parse_rules(new_yaml)
o2 = [apply_rules(r, rules2) for r in recs]
n_score = sum(a != b[0] for a, b in zip(df["score"], o2)); n_grade = sum(a != b[1] for a, b in zip(df["grade"], o2))
print(f"\n[설정 변경] R03 리드타임 기준 180일 -> 120일: 점수가 바뀐 부품 {n_score}건, 등급이 바뀐 부품 {n_grade}건 (코드 수정 없이 YAML만 변경)")
print("처리 후 사람 검토: 스크리닝은 1차 선별이며, 최종 위험 판단과 조치는 조달 담당자가 결정한다.")
fig, ax = plt.subplots(1, 2, figsize=(11, 4))
ax[0].bar(tbl["룰"], tbl["적중"], color="tab:blue"); ax[0].set_title("Rule hits by rule (synthetic)"); ax[0].set_ylabel("parts")
g = df.groupby("grade")["disrupted_next12m"].mean().reindex(["관심", "주의", "고위험"])
ax[1].bar(["low", "watch", "high"], g.values, color=["tab:green", "gold", "tab:red"]); ax[1].set_title("Actual disruption rate by screening grade"); ax[1].set_ylim(0, 1)
plt.tight_layout(); plt.show()
In [ ]:
"""부품 설명문의 TF-IDF 텍스트 유사도, 규격 적합성, 동의어를 연결한 지식 그래프 이웃 유사도를 결합해 해외 부품별 국산 대체 후보 Top-3를 추천하고, 과거 승인 이력 기준 정확도·요소별 기여·오추천 유형·추천 사유 문장을 확인한다."""
import time
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity
# ---------------------------------------------------------------------------
# [교육용가상자료] 가상 무기체계 부품·공급망 데이터 생성기 (실제 체계·업체·국가·단가 정보 없음)
# 부품 100종(해외 조달 약 80%)의 공급망 속성과, 국내 부품 카탈로그(과거 승인된 국산 대체품 +
# 설명은 비슷하지만 규격이 맞지 않는 유사 후보)를 함께 만든다.
# ---------------------------------------------------------------------------
COUNTRIES = ["가상국가A", "가상국가B", "가상국가C", "국내"]
CATS = {
"전원모듈": dict(words=["DC-DC", "컨버터", "절연형", "전원", "정출력"], syn={"컨버터": "변환장치", "절연형": "아이솔레이션", "정출력": "안정화"}, std="MIL-STD-704", iface="단자대"),
"커넥터": dict(words=["원형", "커넥터", "방수형", "다핀", "나사체결"], syn={"커넥터": "접속자", "방수형": "밀폐형", "나사체결": "스크루결합"}, std="MIL-DTL-38999", iface="원형핀"),
"유압밸브": dict(words=["유압", "솔레노이드", "밸브", "방향제어", "4포트"], syn={"밸브": "개폐장치", "방향제어": "유로전환", "솔레노이드": "전자식"}, std="SAE-AS5440", iface="플랜지"),
"베어링": dict(words=["볼", "베어링", "고속", "밀봉형", "회전축"], syn={"베어링": "축받이", "밀봉형": "실드형", "고속": "고회전"}, std="ISO-492", iface="축결합"),
"광학렌즈": dict(words=["적외선", "렌즈", "게르마늄", "반사방지", "집광"], syn={"렌즈": "광학창", "집광": "초점형성", "반사방지": "AR코팅"}, std="MIL-PRF-13830", iface="마운트"),
"자이로센서": dict(words=["광섬유", "자이로", "각속도", "센서", "관성"], syn={"센서": "감지기", "각속도": "회전율", "관성": "INS용"}, std="IEEE-952", iface="직렬통신"),
"RF증폭기": dict(words=["RF", "전력증폭기", "광대역", "모듈", "송신"], syn={"전력증폭기": "파워앰프", "송신": "출력단", "광대역": "와이드밴드"}, std="MIL-STD-461", iface="SMA"),
"제어보드": dict(words=["FPGA", "제어보드", "임베디드", "연산", "입출력"], syn={"제어보드": "컨트롤카드", "연산": "신호처리", "임베디드": "내장형"}, std="VITA-46", iface="백플레인"),
}
V_OPTS, W_OPTS = [12, 24, 28], [25, 50, 100]
VENDORS = ["가상정밀(주)", "가상전자(주)", "가상유공압(주)", "가상광학(주)", "가상테크(주)", "가상시스템(주)"]
def describe(cat, words, s):
# [TODO : 학생 실습 4] NLP 및 지식 그래프 기반 대체 가능 호환 부품(Alternative Parts) 자동 추천 모델 고도화 관련 핵심 로직을 구현하세요.
# 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
# <<< 여기에 코드를 작성하세요 >>>
pass # 작성 후 pass를 제거하세요.
def _sig(x):
# [TODO : 학생 실습 4] NLP 및 지식 그래프 기반 대체 가능 호환 부품(Alternative Parts) 자동 추천 모델 고도화 관련 핵심 로직을 구현하세요.
# 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
# <<< 여기에 코드를 작성하세요 >>>
pass # 작성 후 pass를 제거하세요.
def make_parts(n=100, seed=7):
"""부품 목록(공급망 속성 포함)과 국내 카탈로그를 만든다. 반환: (parts DataFrame, catalog DataFrame)"""
rng = np.random.default_rng(seed)
cats, rows, cat_rows = list(CATS), [], []
for i in range(n):
cat = cats[i % len(cats)]; c = CATS[cat]
spec = {"v": int(rng.choice(V_OPTS)), "w": int(rng.choice(W_OPTS)), "tmin": int(rng.choice([-55, -40])),
"tmax": int(rng.choice([85, 125])), "size_mm": float(round(rng.uniform(20, 120), 1)), "std": c["std"]}
words = [str(x) for x in rng.permutation(c["words"])[:4]]
country = str(rng.choice(COUNTRIES, p=[0.35, 0.3, 0.15, 0.2]))
overseas = country != "국내"
n_sup = int(rng.choice([1, 2, 3], p=[0.45, 0.35, 0.2])) if overseas else int(rng.choice([1, 2, 3]))
lead = float(round(rng.gamma(3, 45 if overseas else 25) + 20))
eol = str(rng.choice(["정상", "단종예고", "단종"], p=[0.7, 0.2, 0.1]))
crit = str(rng.choice(["A", "B", "C"], p=[0.3, 0.4, 0.3]))
z = (-2.6 + 1.1 * (n_sup == 1) + 0.9 * overseas + 0.006 * (lead - 100) + 1.2 * (eol == "단종예고")
+ 2.0 * (eol == "단종") + rng.normal(0, 0.6))
p_late = float(np.clip(_sig(z) * 0.8 + 0.05, 0, 0.9))
late = int(rng.binomial(12, p_late))
demand = int(rng.integers(20, 400))
rows.append({"part_no": f"P-{i + 1:04d}", "category": cat, "desc": describe(cat, words, spec), **spec,
"iface": c["iface"], "country": country, "overseas": overseas, "n_suppliers": n_sup,
"lead_time_days": lead, "orders_12m": 12, "late_orders": late,
"avg_delay_days": float(round(late and rng.gamma(2, 12 + 20 * p_late), 1)),
"eol_status": eol, "ltb_months": int(rng.integers(1, 25)) if eol == "단종예고" else None,
"price_change_pct": float(round(rng.normal(4 + 12 * p_late, 8), 1)), "criticality": crit,
"annual_demand": demand, "stock_qty": int(rng.integers(0, demand)),
"defect_rate": float(round(abs(rng.normal(0.8 + 2 * p_late, 0.8)), 2)),
"export_license": bool(overseas and rng.random() < 0.35),
"disrupted_next12m": bool(rng.random() < _sig(z + 0.4))})
if not overseas:
continue
# 과거 승인된 국산 대체품(정답): 동의어로 서술, 규격 호환(전력 여유 허용, 일부는 크기 차이가 커 장착 검토 필요)
eq_words = [c["syn"].get(w, w) for w in words]
hard = rng.random() < 0.15 # 서술이 많이 다른 사례
if hard:
eq_words = [c["syn"].get(w, w) for w in c["words"] if w not in words][:1] + eq_words[1:3]
eq = {**spec, "w": spec["w"] if rng.random() < 0.7 else W_OPTS[min(W_OPTS.index(spec["w"]) + 1, 2)],
"size_mm": float(round(spec["size_mm"] * (rng.uniform(1.11, 1.15) if rng.random() < 0.12 else rng.uniform(0.95, 1.05)), 1))}
cat_rows.append({"cand_no": f"D-{len(cat_rows) + 1:04d}", "category": cat, "desc": describe(cat, eq_words, eq), **eq,
"iface": c["iface"], "vendor": str(rng.choice(VENDORS)), "approved_for": f"P-{i + 1:04d}", "kind": "equivalent"})
# 유사 오답 후보: 설명은 거의 같지만 전압이 다르거나(5V/48V) 상용 온도 등급(0~70C)
for kind in ("volt_mismatch", "temp_mismatch"):
d = dict(spec)
if kind == "volt_mismatch":
d["v"] = int(rng.choice([5, 48])) # 체계에서 쓰지 않는 전압
else:
d["tmin"], d["tmax"] = 0, 70
d["size_mm"] = float(round(spec["size_mm"] * rng.uniform(0.95, 1.05), 1))
cat_rows.append({"cand_no": f"D-{len(cat_rows) + 1:04d}", "category": cat, "desc": describe(cat, words, d), **d,
"iface": c["iface"], "vendor": str(rng.choice(VENDORS)), "approved_for": None, "kind": kind})
parts = pd.DataFrame(rows)
catalog = pd.DataFrame(cat_rows).sample(frac=1, random_state=seed).reset_index(drop=True)
return parts, catalog
# ---------------------------------------------------------------------------
# ---- 대체 부품 추천 공통 함수: 텍스트 유사도 + 규격 적합성 + 지식 그래프 이웃 유사도 ----
WEIGHTS = {"text": 0.35, "spec": 0.45, "graph": 0.20}
def spec_fit(p, c):
"""규격 적합성 0~1: 6개 조건 충족 비율. 전압·온도 한계(필수 조건)를 못 맞추면 점수를 절반으로 깎는다."""
conds = [p["v"] == c["v"], c["w"] >= p["w"], c["tmin"] <= p["tmin"], c["tmax"] >= p["tmax"],
abs(c["size_mm"] - p["size_mm"]) / p["size_mm"] <= 0.10, p["iface"] == c["iface"]]
s = sum(conds) / len(conds)
return (s * 0.5 if not (conds[0] and conds[2] and conds[3]) else s), conds
def graph_nodes(row):
"""지식 그래프에서 부품 노드와 연결된 이웃 노드 집합(범주·표준·인터페이스·기능어·등급)."""
words = set(row["desc"].split()[1:-4])
grade = "군용등급" if row["tmin"] <= -40 else "상용등급"
return {f"cat:{row['category']}", f"std:{row['std']}", f"if:{row['iface']}", f"grade:{grade}", f"v:{row['v']}"} | {f"w:{w}" for w in words}
def build_graph(catalog):
"""동의어 사전을 그래프 간선(기능어 = 동의어)으로 넣어 서술이 달라도 같은 기능 노드로 연결되게 한다."""
syn = {f"w:{b}": f"w:{a}" for c in CATS.values() for a, b in c["syn"].items()}
canon = lambda nodes: {syn.get(x, x) for x in nodes}
return {"canon": canon, "nodes": [canon(graph_nodes(r)) for _, r in catalog.iterrows()]}
def graph_sim(g, pn, cn):
"""두 부품이 공유하는 이웃 노드 비율(Jaccard)."""
pn = g["canon"](pn)
return len(pn & cn) / max(len(pn | cn), 1)
def score_table(parts_df, catalog):
"""해외 부품 x 같은 범주 국내 후보 쌍마다 텍스트·규격·그래프 점수를 계산한 표(가중치 적용 전)."""
vec = TfidfVectorizer(token_pattern=r"[^ ]+").fit(list(parts_df["desc"]) + list(catalog["desc"]))
text = cosine_similarity(vec.transform(parts_df["desc"]), vec.transform(catalog["desc"]))
g = build_graph(catalog)
rows = []
for i, (_, p) in enumerate(parts_df.iterrows()):
pn = graph_nodes(p)
for j, (_, c) in enumerate(catalog.iterrows()):
if c["category"] != p["category"]:
continue
sf, conds = spec_fit(p, c)
rows.append({"part_no": p["part_no"], "cand_no": c["cand_no"], "vendor": c["vendor"], "text": text[i, j],
"spec": sf, "graph": graph_sim(g, pn, g["nodes"][j]),
"unmet": ",".join(n for n, ok in zip(["전압", "전력", "저온", "고온", "크기", "인터페이스"], conds) if not ok) or "-",
"kind": c["kind"], "correct": c["approved_for"] == p["part_no"]})
return pd.DataFrame(rows)
def recommend(table, weights=None, top=3):
"""가중합 점수로 부품별 상위 top개 후보를 고른다."""
w = weights or WEIGHTS
t = table.assign(score=w["text"] * table["text"] + w["spec"] * table["spec"] + w["graph"] * table["graph"])
return t.sort_values(["part_no", "score"], ascending=[True, False]).groupby("part_no").head(top)
def hit_rate(recs, k=1):
"""과거 승인 이력(정답 대체품)이 상위 k개 안에 들어간 부품 비율."""
return recs.groupby("part_no").apply(lambda d: d["correct"].head(k).any(), include_groups=False).mean()
parts, catalog = make_parts(100, seed=7)
over = parts[parts["overseas"]].reset_index(drop=True)
print(f"[데이터] 해외 조달 부품 {len(over)}종, 국내 후보 카탈로그 {len(catalog)}건(승인 이력 있는 대체품 {int((catalog['kind'] == 'equivalent').sum())}건, 규격 불일치 유사 후보 {int((catalog['kind'] != 'equivalent').sum())}건)")
t0 = time.perf_counter()
table = score_table(over, catalog)
rec = recommend(table)
ms = (time.perf_counter() - t0) * 1000 / len(over)
print(f"추천 처리: 부품 1종당 {ms:.1f}ms (후보 쌍 {len(table)}개 점수 계산 포함)")
# 1) 요소별 기여: 어느 신호를 쓰느냐에 따라 정확도가 어떻게 달라지는가
combos = {"텍스트 유사도만": {"text": 1, "spec": 0, "graph": 0}, "규격 적합성만": {"text": 0, "spec": 1, "graph": 0},
"그래프만": {"text": 0, "spec": 0, "graph": 1}, "텍스트+그래프": {"text": .5, "spec": 0, "graph": .5},
"텍스트+규격": {"text": .4, "spec": .6, "graph": 0}, "세 신호 결합(운용)": WEIGHTS}
rows = []
for name, w in combos.items():
r = recommend(table, w)
bad = (r.groupby("part_no").head(1)["kind"] != "equivalent").mean()
rows.append({"방식": name, "Top-1 정확도": hit_rate(r, 1), "Top-3 정확도": hit_rate(r, 3), "1순위가 규격불일치 후보인 비율": bad})
ab = pd.DataFrame(rows)
print("\n[요소별 기여(과거 승인 대체품을 상위에 올렸는가)]")
print(ab.round(3).to_string(index=False))
acc1, acc3 = hit_rate(rec, 1), hit_rate(rec, 3)
print(f"\n운용 방식 Top-1 정확도 {acc1:.1%}, Top-3 정확도 {acc3:.1%} (목표 85% 이상: {'달성' if acc1 >= 0.85 else '미달'})")
# 2) 오추천 분석: 1순위가 정답이 아닌 부품
top1 = rec.groupby("part_no").head(1)
wrong = top1[~top1["correct"]]
print(f"\n[1순위 오추천 {len(wrong)}건 분석]")
for _, w in wrong.iterrows():
right = table[(table["part_no"] == w["part_no"]) & table["correct"]].iloc[0]
if w["kind"] != "equivalent":
why = f"규격 불일치 후보가 상위(미충족 {w['unmet']}) -> 규격 가중치 보강 필요"
elif w["unmet"] == "-":
why = "다른 부품용으로 승인된 같은 범주 대체품이 규격도 충족 -> 승인 이력상 오답, 실무상 검토 후보"
else:
why = f"다른 부품용 승인 대체품이 상위(정답 후보는 크기 차이로 감점) -> 크기 허용 오차 재검토"
print(f" {w['part_no']}: 추천 {w['cand_no']}(점수 {w['score']:.2f}, {w['kind']}) / 정답 {right['cand_no']}(규격 미충족 {right['unmet']}) -> {why}")
# 3) 추천 사유 문장(실무 수용성: 근거를 사람이 읽을 수 있게)
print("\n[추천 사유 예시]")
for pn in over["part_no"].head(3):
r = rec[rec["part_no"] == pn].iloc[0]
p = parts[parts["part_no"] == pn].iloc[0]
ok = "규격 6개 항목 모두 충족" if r["unmet"] == "-" else f"규격 미충족 항목 {r['unmet']}(기술 검토 필요)"
print(f" {pn} {p['category']} -> {r['cand_no']}({r['vendor']}): 설명 유사도 {r['text']:.2f}, 규격 적합성 {r['spec']:.2f}, 그래프 연결 {r['graph']:.2f} / {ok}")
print("※ 추천은 후보 제시이며 규격 적합성 최종 판단과 계약 의뢰는 기술검토관·계약 담당자가 결정한다.")
fig, ax = plt.subplots(1, 2, figsize=(11, 4))
x = np.arange(len(ab)); ax[0].bar(x - .2, ab["Top-1 정확도"], .4, label="top-1"); ax[0].bar(x + .2, ab["Top-3 정확도"], .4, label="top-3")
ax[0].set_xticks(x, ["text", "spec", "graph", "text+graph", "text+spec", "all 3"], rotation=20); ax[0].axhline(.85, color="r", ls="--"); ax[0].legend(); ax[0].set_title("Accuracy by signal combination")
allsc = recommend(table, WEIGHTS, 999)
ax[1].hist([allsc[allsc["correct"]]["score"], allsc[~allsc["correct"]]["score"]], bins=15, label=["approved", "other"], stacked=True)
ax[1].legend(); ax[1].set_title("Score distribution (candidates)"); ax[1].set_xlabel("weighted score")
plt.tight_layout(); plt.show()
In [ ]:
"""12개 공급망 지표로 부품별 공급망 붕괴 위험도(0~100점)를 로지스틱 회귀로 산출하고 월별 납기 지연 추이의 EWMA로 조기경보를 내는 엔진을 만든 뒤, 입력-분석-검토-결과 4개 화면(텍스트 패널과 그래프)을 구성해 별도 가상 부품군에서 성능을 검증한다."""
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.metrics import roc_auc_score, brier_score_loss
# ---------------------------------------------------------------------------
# [교육용가상자료] 가상 무기체계 부품·공급망 데이터 생성기 (실제 체계·업체·국가·단가 정보 없음)
# 부품 100종(해외 조달 약 80%)의 공급망 속성과, 국내 부품 카탈로그(과거 승인된 국산 대체품 +
# 설명은 비슷하지만 규격이 맞지 않는 유사 후보)를 함께 만든다.
# ---------------------------------------------------------------------------
COUNTRIES = ["가상국가A", "가상국가B", "가상국가C", "국내"]
CATS = {
"전원모듈": dict(words=["DC-DC", "컨버터", "절연형", "전원", "정출력"], syn={"컨버터": "변환장치", "절연형": "아이솔레이션", "정출력": "안정화"}, std="MIL-STD-704", iface="단자대"),
"커넥터": dict(words=["원형", "커넥터", "방수형", "다핀", "나사체결"], syn={"커넥터": "접속자", "방수형": "밀폐형", "나사체결": "스크루결합"}, std="MIL-DTL-38999", iface="원형핀"),
"유압밸브": dict(words=["유압", "솔레노이드", "밸브", "방향제어", "4포트"], syn={"밸브": "개폐장치", "방향제어": "유로전환", "솔레노이드": "전자식"}, std="SAE-AS5440", iface="플랜지"),
"베어링": dict(words=["볼", "베어링", "고속", "밀봉형", "회전축"], syn={"베어링": "축받이", "밀봉형": "실드형", "고속": "고회전"}, std="ISO-492", iface="축결합"),
"광학렌즈": dict(words=["적외선", "렌즈", "게르마늄", "반사방지", "집광"], syn={"렌즈": "광학창", "집광": "초점형성", "반사방지": "AR코팅"}, std="MIL-PRF-13830", iface="마운트"),
"자이로센서": dict(words=["광섬유", "자이로", "각속도", "센서", "관성"], syn={"센서": "감지기", "각속도": "회전율", "관성": "INS용"}, std="IEEE-952", iface="직렬통신"),
"RF증폭기": dict(words=["RF", "전력증폭기", "광대역", "모듈", "송신"], syn={"전력증폭기": "파워앰프", "송신": "출력단", "광대역": "와이드밴드"}, std="MIL-STD-461", iface="SMA"),
"제어보드": dict(words=["FPGA", "제어보드", "임베디드", "연산", "입출력"], syn={"제어보드": "컨트롤카드", "연산": "신호처리", "임베디드": "내장형"}, std="VITA-46", iface="백플레인"),
}
V_OPTS, W_OPTS = [12, 24, 28], [25, 50, 100]
VENDORS = ["가상정밀(주)", "가상전자(주)", "가상유공압(주)", "가상광학(주)", "가상테크(주)", "가상시스템(주)"]
def describe(cat, words, s):
# [TODO : 학생 실습 5] 공급망 붕괴 위험도(Supply Chain Risk Score) 및 조기경보 산출 엔진 개발 관련 핵심 로직을 구현하세요.
# 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
# <<< 여기에 코드를 작성하세요 >>>
pass # 작성 후 pass를 제거하세요.
def _sig(x):
# [TODO : 학생 실습 5] 공급망 붕괴 위험도(Supply Chain Risk Score) 및 조기경보 산출 엔진 개발 관련 핵심 로직을 구현하세요.
# 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
# <<< 여기에 코드를 작성하세요 >>>
pass # 작성 후 pass를 제거하세요.
def make_parts(n=100, seed=7):
"""부품 목록(공급망 속성 포함)과 국내 카탈로그를 만든다. 반환: (parts DataFrame, catalog DataFrame)"""
rng = np.random.default_rng(seed)
cats, rows, cat_rows = list(CATS), [], []
for i in range(n):
cat = cats[i % len(cats)]; c = CATS[cat]
spec = {"v": int(rng.choice(V_OPTS)), "w": int(rng.choice(W_OPTS)), "tmin": int(rng.choice([-55, -40])),
"tmax": int(rng.choice([85, 125])), "size_mm": float(round(rng.uniform(20, 120), 1)), "std": c["std"]}
words = [str(x) for x in rng.permutation(c["words"])[:4]]
country = str(rng.choice(COUNTRIES, p=[0.35, 0.3, 0.15, 0.2]))
overseas = country != "국내"
n_sup = int(rng.choice([1, 2, 3], p=[0.45, 0.35, 0.2])) if overseas else int(rng.choice([1, 2, 3]))
lead = float(round(rng.gamma(3, 45 if overseas else 25) + 20))
eol = str(rng.choice(["정상", "단종예고", "단종"], p=[0.7, 0.2, 0.1]))
crit = str(rng.choice(["A", "B", "C"], p=[0.3, 0.4, 0.3]))
z = (-2.6 + 1.1 * (n_sup == 1) + 0.9 * overseas + 0.006 * (lead - 100) + 1.2 * (eol == "단종예고")
+ 2.0 * (eol == "단종") + rng.normal(0, 0.6))
p_late = float(np.clip(_sig(z) * 0.8 + 0.05, 0, 0.9))
late = int(rng.binomial(12, p_late))
demand = int(rng.integers(20, 400))
rows.append({"part_no": f"P-{i + 1:04d}", "category": cat, "desc": describe(cat, words, spec), **spec,
"iface": c["iface"], "country": country, "overseas": overseas, "n_suppliers": n_sup,
"lead_time_days": lead, "orders_12m": 12, "late_orders": late,
"avg_delay_days": float(round(late and rng.gamma(2, 12 + 20 * p_late), 1)),
"eol_status": eol, "ltb_months": int(rng.integers(1, 25)) if eol == "단종예고" else None,
"price_change_pct": float(round(rng.normal(4 + 12 * p_late, 8), 1)), "criticality": crit,
"annual_demand": demand, "stock_qty": int(rng.integers(0, demand)),
"defect_rate": float(round(abs(rng.normal(0.8 + 2 * p_late, 0.8)), 2)),
"export_license": bool(overseas and rng.random() < 0.35),
"disrupted_next12m": bool(rng.random() < _sig(z + 0.4))})
if not overseas:
continue
# 과거 승인된 국산 대체품(정답): 동의어로 서술, 규격 호환(전력 여유 허용, 일부는 크기 차이가 커 장착 검토 필요)
eq_words = [c["syn"].get(w, w) for w in words]
hard = rng.random() < 0.15 # 서술이 많이 다른 사례
if hard:
eq_words = [c["syn"].get(w, w) for w in c["words"] if w not in words][:1] + eq_words[1:3]
eq = {**spec, "w": spec["w"] if rng.random() < 0.7 else W_OPTS[min(W_OPTS.index(spec["w"]) + 1, 2)],
"size_mm": float(round(spec["size_mm"] * (rng.uniform(1.11, 1.15) if rng.random() < 0.12 else rng.uniform(0.95, 1.05)), 1))}
cat_rows.append({"cand_no": f"D-{len(cat_rows) + 1:04d}", "category": cat, "desc": describe(cat, eq_words, eq), **eq,
"iface": c["iface"], "vendor": str(rng.choice(VENDORS)), "approved_for": f"P-{i + 1:04d}", "kind": "equivalent"})
# 유사 오답 후보: 설명은 거의 같지만 전압이 다르거나(5V/48V) 상용 온도 등급(0~70C)
for kind in ("volt_mismatch", "temp_mismatch"):
d = dict(spec)
if kind == "volt_mismatch":
d["v"] = int(rng.choice([5, 48])) # 체계에서 쓰지 않는 전압
else:
d["tmin"], d["tmax"] = 0, 70
d["size_mm"] = float(round(spec["size_mm"] * rng.uniform(0.95, 1.05), 1))
cat_rows.append({"cand_no": f"D-{len(cat_rows) + 1:04d}", "category": cat, "desc": describe(cat, words, d), **d,
"iface": c["iface"], "vendor": str(rng.choice(VENDORS)), "approved_for": None, "kind": kind})
parts = pd.DataFrame(rows)
catalog = pd.DataFrame(cat_rows).sample(frac=1, random_state=seed).reset_index(drop=True)
return parts, catalog
# ---------------------------------------------------------------------------
# ---- 공급망 붕괴 위험도(Supply Chain Risk Score) 공통 함수: 로지스틱 회귀 + 월별 지연 추이 조기경보 ----
RISK_FEATS = ["n_suppliers", "overseas", "lead_time_days", "delay_rate", "avg_delay_days", "eol_ord", "ltb_short", "price_change_pct",
"crit_ord", "cover_gap_days", "defect_rate", "export_license"]
FEAT_KO = {"n_suppliers": "공급선 수", "overseas": "해외 조달", "lead_time_days": "리드타임", "delay_rate": "납기 지연률", "avg_delay_days": "평균 지연일",
"eol_ord": "단종 상태", "ltb_short": "최종구매 임박", "price_change_pct": "단가 변동", "crit_ord": "체계 중요도",
"cover_gap_days": "재고-리드타임 격차", "defect_rate": "입고 불량률", "export_license": "수출 허가 필요"}
WARN_CUT = {"경보": 60, "주의": 35} # 위험도 점수(0~100) 등급 기준
def risk_frame(parts):
d = parts.copy()
d["delay_rate"] = d["late_orders"] / d["orders_12m"].clip(lower=1)
d["eol_ord"] = d["eol_status"].map({"정상": 0, "단종예고": 1, "단종": 2})
d["ltb_short"] = (d["ltb_months"].fillna(99) <= 6).astype(int)
d["crit_ord"] = d["criticality"].map({"C": 0, "B": 1, "A": 2})
d["cover_gap_days"] = d["stock_qty"] / d["annual_demand"].clip(lower=1) * 365 - d["lead_time_days"]
for c in ("overseas", "export_license"):
d[c] = d[c].astype(int)
return d
def fit_risk(parts):
d = risk_frame(parts)
return make_pipeline(StandardScaler(), LogisticRegression(C=1.0, max_iter=500)).fit(d[RISK_FEATS], d["disrupted_next12m"].astype(int))
def risk_score(model, parts):
"""붕괴 위험도 0~100점 = 다음 12개월 내 공급 단절 확률 x 100."""
return model.predict_proba(risk_frame(parts)[RISK_FEATS])[:, 1] * 100
def drivers(model, parts, k=3):
"""부품별 위험 상승 요인 상위 k개: (표준화 값 x 회귀 계수)가 큰 순서."""
sc, lr = model[0], model[-1]
z = sc.transform(risk_frame(parts)[RISK_FEATS]) * lr.coef_[0]
return [[FEAT_KO[RISK_FEATS[j]] for j in np.argsort(-row)[:k] if row[j] > 0] for row in z]
def monthly_delay(parts, seed=5):
"""[교육용가상자료] 부품별 최근 12개월 평균 납기 지연일. 단절될 부품은 단절 3~6개월 전부터 지연이 서서히 늘어난다."""
rng = np.random.default_rng(seed)
out, onset = [], []
for _, p in parts.iterrows():
base = 3 + 0.15 * p["avg_delay_days"]
s = base + rng.normal(0, 1.2, 12)
o = -1
if p["disrupted_next12m"] and rng.random() < 0.7:
o = int(rng.integers(5, 9)) # 12개월 구간 내 상승 시작 월
s[o:] += np.linspace(1.5, 12, 12 - o) + rng.normal(0, 1.0, 12 - o)
out.append(s); onset.append(o)
return np.array(out), np.array(onset)
def ewma_alarm(series, base_n=5, lam=0.3, k=3.0):
"""처음 base_n개월을 기준선으로 삼아 EWMA가 기준선 + k x 표준편차를 넘는 첫 달(없으면 -1)."""
mu, sd = series[:base_n].mean(), series[:base_n].std() + 0.5
e = mu
for t in range(base_n, len(series)):
e = lam * series[t] + (1 - lam) * e
if e > mu + k * sd * np.sqrt(lam / (2 - lam)):
return t
return -1
train, _ = make_parts(600, seed=11) # 학습용 가상 부품군
test, _ = make_parts(200, seed=7) # 검증용 별도 가상 부품군
model = fit_risk(train)
test = test.assign(score=risk_score(model, test), why=drivers(model, test))
y = test["disrupted_next12m"].astype(int)
test["level"] = np.where(test["score"] >= WARN_CUT["경보"], "경보", np.where(test["score"] >= WARN_CUT["주의"], "주의", "정상"))
# ===== 화면 1. 입력: 입력 요약과 검증 =====
miss = int(test[["lead_time_days", "n_suppliers", "late_orders"]].isna().sum().sum())
print("[화면 1. 입력] [교육용가상자료]")
print(f" 검증용 부품 {len(test)}종 / 입력 지표 {len(RISK_FEATS)}개 / 필수값 결측 {miss}건 / 학습 부품 {len(train)}종")
print(f" 해외 조달 {int(test['overseas'].sum())}종, 단종예고·단종 {int((test['eol_status'] != '정상').sum())}종, 단일 공급선 {int((test['n_suppliers'] == 1).sum())}종")
# ===== 화면 2. 분석: 위험도 분포와 성능 =====
auc = roc_auc_score(y, test["score"])
print("\n[화면 2. 분석] 위험도 점수 분포와 모델 성능")
print(f" ROC-AUC {auc:.3f}, Brier {brier_score_loss(y, test['score'] / 100):.3f} (단절 실제 비율 {y.mean():.0%})")
print(test.groupby("level").agg(부품수=("part_no", "size"), 평균점수=("score", "mean"), 실제단절률=("disrupted_next12m", "mean")).reindex(["경보", "주의", "정상"]).round(2).to_string())
coef = pd.Series(model[-1].coef_[0], index=[FEAT_KO[f] for f in RISK_FEATS]).sort_values(ascending=False)
print(" 위험 상승 요인(회귀 계수 상위 4):", ", ".join(f"{k} {v:+.2f}" for k, v in coef.head(4).items()))
# ===== 조기경보: 월별 지연 추이 =====
md, onset = monthly_delay(test)
alarm = np.array([ewma_alarm(s) for s in md])
dis = onset >= 0
det = (alarm[dis] >= 0).mean()
delay = (alarm[dis & (alarm >= 0)] - onset[dis & (alarm >= 0)]).mean()
fa = (alarm[~dis] >= 0).mean()
print(f"\n[조기경보] 지연 상승형 단절 부품 {int(dis.sum())}종 중 {det:.0%} 감지, 상승 시작 후 평균 {delay:.1f}개월 만에 경보, 정상 부품 오경보율 {fa:.1%}")
# ===== 화면 3. 검토: 경보 부품을 담당자가 확인 =====
rev = test[test["level"] == "경보"].sort_values("score", ascending=False)
print(f"\n[화면 3. 검토] 경보 {len(rev)}종 중 상위 5종 (담당자가 근거를 확인하고 조치 여부를 결정)")
for _, r in rev.head(5).iterrows():
print(f" {r['part_no']} {r['category']} {r['score']:.0f}점 | 요인: {', '.join(r['why']) or '-'} | 단종 {r['eol_status']}, 공급선 {r['n_suppliers']}개")
# ===== 화면 4. 결과: 요약과 내보내기 =====
top = rev.head(10)
print("\n[화면 4. 결과]")
print(f" 경보 {len(rev)}종, 주의 {int((test['level'] == '주의').sum())}종, 정상 {int((test['level'] == '정상').sum())}종")
print(f" 경보 등급의 실제 단절 {int(rev['disrupted_next12m'].sum())}종({rev['disrupted_next12m'].mean():.0%}) vs 정상 등급 {test[test['level'] == '정상']['disrupted_next12m'].mean():.0%}")
print(" 내보내기 항목: 부품번호, 점수, 등급, 요인, 조기경보 월, 검토 상태(미검토)")
print("화면 4종(입력-분석-검토-결과) 구현: 4/4 (목표 4종: 달성)")
print("※ 점수는 우선순위를 정하는 참고값이며, 실제 단절 여부와 조치는 담당자가 확인한다.")
fig, ax = plt.subplots(2, 2, figsize=(11, 7))
ax[0, 0].bar(["overseas", "single src", "EOL"], [test["overseas"].sum(), (test["n_suppliers"] == 1).sum(), (test["eol_status"] != "정상").sum()]); ax[0, 0].set_title("Screen 1: input summary (count)")
ax[0, 1].hist([test[y == 0]["score"], test[y == 1]["score"]], bins=15, label=["no disruption", "disrupted"], stacked=True); ax[0, 1].legend(); ax[0, 1].set_title("Screen 2: risk score distribution")
i = int(np.where(dis & (alarm >= 0))[0][0]); ax[1, 0].plot(md[i], marker="o"); ax[1, 0].axvline(alarm[i], color="r", ls="--"); ax[1, 0].set_title("Screen 3: early warning (delay days, red = alarm)")
ax[1, 1].barh(top["part_no"], top["score"], color="tab:red"); ax[1, 1].invert_yaxis(); ax[1, 1].set_title("Screen 4: top-10 alert parts")
plt.tight_layout(); plt.show()
In [ ]:
"""조달 위험 공급망 지도(국가 x 부품 범주 위험 히트맵)와 대체선 추천 패널을 그리는 대시보드에 입력 검증기와 에러 핸들러를 붙이고, 깨진 입력 14종을 주입해 크래시 0건·에러 로그 기록률 100%·사용자용 조치 안내 문구를 확인한다."""
import csv
import io
import json
import re
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity
# ---------------------------------------------------------------------------
# [교육용가상자료] 가상 무기체계 부품·공급망 데이터 생성기 (실제 체계·업체·국가·단가 정보 없음)
# 부품 100종(해외 조달 약 80%)의 공급망 속성과, 국내 부품 카탈로그(과거 승인된 국산 대체품 +
# 설명은 비슷하지만 규격이 맞지 않는 유사 후보)를 함께 만든다.
# ---------------------------------------------------------------------------
COUNTRIES = ["가상국가A", "가상국가B", "가상국가C", "국내"]
CATS = {
"전원모듈": dict(words=["DC-DC", "컨버터", "절연형", "전원", "정출력"], syn={"컨버터": "변환장치", "절연형": "아이솔레이션", "정출력": "안정화"}, std="MIL-STD-704", iface="단자대"),
"커넥터": dict(words=["원형", "커넥터", "방수형", "다핀", "나사체결"], syn={"커넥터": "접속자", "방수형": "밀폐형", "나사체결": "스크루결합"}, std="MIL-DTL-38999", iface="원형핀"),
"유압밸브": dict(words=["유압", "솔레노이드", "밸브", "방향제어", "4포트"], syn={"밸브": "개폐장치", "방향제어": "유로전환", "솔레노이드": "전자식"}, std="SAE-AS5440", iface="플랜지"),
"베어링": dict(words=["볼", "베어링", "고속", "밀봉형", "회전축"], syn={"베어링": "축받이", "밀봉형": "실드형", "고속": "고회전"}, std="ISO-492", iface="축결합"),
"광학렌즈": dict(words=["적외선", "렌즈", "게르마늄", "반사방지", "집광"], syn={"렌즈": "광학창", "집광": "초점형성", "반사방지": "AR코팅"}, std="MIL-PRF-13830", iface="마운트"),
"자이로센서": dict(words=["광섬유", "자이로", "각속도", "센서", "관성"], syn={"센서": "감지기", "각속도": "회전율", "관성": "INS용"}, std="IEEE-952", iface="직렬통신"),
"RF증폭기": dict(words=["RF", "전력증폭기", "광대역", "모듈", "송신"], syn={"전력증폭기": "파워앰프", "송신": "출력단", "광대역": "와이드밴드"}, std="MIL-STD-461", iface="SMA"),
"제어보드": dict(words=["FPGA", "제어보드", "임베디드", "연산", "입출력"], syn={"제어보드": "컨트롤카드", "연산": "신호처리", "임베디드": "내장형"}, std="VITA-46", iface="백플레인"),
}
V_OPTS, W_OPTS = [12, 24, 28], [25, 50, 100]
VENDORS = ["가상정밀(주)", "가상전자(주)", "가상유공압(주)", "가상광학(주)", "가상테크(주)", "가상시스템(주)"]
def describe(cat, words, s):
# [TODO : 학생 실습 6] 부품 조달 위험 공급망 지도 및 대체선 추천 인터랙티브 대시보드 구현 관련 핵심 로직을 구현하세요.
# 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
# <<< 여기에 코드를 작성하세요 >>>
pass # 작성 후 pass를 제거하세요.
def _sig(x):
# [TODO : 학생 실습 6] 부품 조달 위험 공급망 지도 및 대체선 추천 인터랙티브 대시보드 구현 관련 핵심 로직을 구현하세요.
# 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
# <<< 여기에 코드를 작성하세요 >>>
pass # 작성 후 pass를 제거하세요.
def make_parts(n=100, seed=7):
"""부품 목록(공급망 속성 포함)과 국내 카탈로그를 만든다. 반환: (parts DataFrame, catalog DataFrame)"""
rng = np.random.default_rng(seed)
cats, rows, cat_rows = list(CATS), [], []
for i in range(n):
cat = cats[i % len(cats)]; c = CATS[cat]
spec = {"v": int(rng.choice(V_OPTS)), "w": int(rng.choice(W_OPTS)), "tmin": int(rng.choice([-55, -40])),
"tmax": int(rng.choice([85, 125])), "size_mm": float(round(rng.uniform(20, 120), 1)), "std": c["std"]}
words = [str(x) for x in rng.permutation(c["words"])[:4]]
country = str(rng.choice(COUNTRIES, p=[0.35, 0.3, 0.15, 0.2]))
overseas = country != "국내"
n_sup = int(rng.choice([1, 2, 3], p=[0.45, 0.35, 0.2])) if overseas else int(rng.choice([1, 2, 3]))
lead = float(round(rng.gamma(3, 45 if overseas else 25) + 20))
eol = str(rng.choice(["정상", "단종예고", "단종"], p=[0.7, 0.2, 0.1]))
crit = str(rng.choice(["A", "B", "C"], p=[0.3, 0.4, 0.3]))
z = (-2.6 + 1.1 * (n_sup == 1) + 0.9 * overseas + 0.006 * (lead - 100) + 1.2 * (eol == "단종예고")
+ 2.0 * (eol == "단종") + rng.normal(0, 0.6))
p_late = float(np.clip(_sig(z) * 0.8 + 0.05, 0, 0.9))
late = int(rng.binomial(12, p_late))
demand = int(rng.integers(20, 400))
rows.append({"part_no": f"P-{i + 1:04d}", "category": cat, "desc": describe(cat, words, spec), **spec,
"iface": c["iface"], "country": country, "overseas": overseas, "n_suppliers": n_sup,
"lead_time_days": lead, "orders_12m": 12, "late_orders": late,
"avg_delay_days": float(round(late and rng.gamma(2, 12 + 20 * p_late), 1)),
"eol_status": eol, "ltb_months": int(rng.integers(1, 25)) if eol == "단종예고" else None,
"price_change_pct": float(round(rng.normal(4 + 12 * p_late, 8), 1)), "criticality": crit,
"annual_demand": demand, "stock_qty": int(rng.integers(0, demand)),
"defect_rate": float(round(abs(rng.normal(0.8 + 2 * p_late, 0.8)), 2)),
"export_license": bool(overseas and rng.random() < 0.35),
"disrupted_next12m": bool(rng.random() < _sig(z + 0.4))})
if not overseas:
continue
# 과거 승인된 국산 대체품(정답): 동의어로 서술, 규격 호환(전력 여유 허용, 일부는 크기 차이가 커 장착 검토 필요)
eq_words = [c["syn"].get(w, w) for w in words]
hard = rng.random() < 0.15 # 서술이 많이 다른 사례
if hard:
eq_words = [c["syn"].get(w, w) for w in c["words"] if w not in words][:1] + eq_words[1:3]
eq = {**spec, "w": spec["w"] if rng.random() < 0.7 else W_OPTS[min(W_OPTS.index(spec["w"]) + 1, 2)],
"size_mm": float(round(spec["size_mm"] * (rng.uniform(1.11, 1.15) if rng.random() < 0.12 else rng.uniform(0.95, 1.05)), 1))}
cat_rows.append({"cand_no": f"D-{len(cat_rows) + 1:04d}", "category": cat, "desc": describe(cat, eq_words, eq), **eq,
"iface": c["iface"], "vendor": str(rng.choice(VENDORS)), "approved_for": f"P-{i + 1:04d}", "kind": "equivalent"})
# 유사 오답 후보: 설명은 거의 같지만 전압이 다르거나(5V/48V) 상용 온도 등급(0~70C)
for kind in ("volt_mismatch", "temp_mismatch"):
d = dict(spec)
if kind == "volt_mismatch":
d["v"] = int(rng.choice([5, 48])) # 체계에서 쓰지 않는 전압
else:
d["tmin"], d["tmax"] = 0, 70
d["size_mm"] = float(round(spec["size_mm"] * rng.uniform(0.95, 1.05), 1))
cat_rows.append({"cand_no": f"D-{len(cat_rows) + 1:04d}", "category": cat, "desc": describe(cat, words, d), **d,
"iface": c["iface"], "vendor": str(rng.choice(VENDORS)), "approved_for": None, "kind": kind})
parts = pd.DataFrame(rows)
catalog = pd.DataFrame(cat_rows).sample(frac=1, random_state=seed).reset_index(drop=True)
return parts, catalog
# ---------------------------------------------------------------------------
# ---- 대체 부품 추천 공통 함수: 텍스트 유사도 + 규격 적합성 + 지식 그래프 이웃 유사도 ----
WEIGHTS = {"text": 0.35, "spec": 0.45, "graph": 0.20}
def spec_fit(p, c):
"""규격 적합성 0~1: 6개 조건 충족 비율. 전압·온도 한계(필수 조건)를 못 맞추면 점수를 절반으로 깎는다."""
conds = [p["v"] == c["v"], c["w"] >= p["w"], c["tmin"] <= p["tmin"], c["tmax"] >= p["tmax"],
abs(c["size_mm"] - p["size_mm"]) / p["size_mm"] <= 0.10, p["iface"] == c["iface"]]
s = sum(conds) / len(conds)
return (s * 0.5 if not (conds[0] and conds[2] and conds[3]) else s), conds
def graph_nodes(row):
"""지식 그래프에서 부품 노드와 연결된 이웃 노드 집합(범주·표준·인터페이스·기능어·등급)."""
words = set(row["desc"].split()[1:-4])
grade = "군용등급" if row["tmin"] <= -40 else "상용등급"
return {f"cat:{row['category']}", f"std:{row['std']}", f"if:{row['iface']}", f"grade:{grade}", f"v:{row['v']}"} | {f"w:{w}" for w in words}
def build_graph(catalog):
"""동의어 사전을 그래프 간선(기능어 = 동의어)으로 넣어 서술이 달라도 같은 기능 노드로 연결되게 한다."""
syn = {f"w:{b}": f"w:{a}" for c in CATS.values() for a, b in c["syn"].items()}
canon = lambda nodes: {syn.get(x, x) for x in nodes}
return {"canon": canon, "nodes": [canon(graph_nodes(r)) for _, r in catalog.iterrows()]}
def graph_sim(g, pn, cn):
"""두 부품이 공유하는 이웃 노드 비율(Jaccard)."""
pn = g["canon"](pn)
return len(pn & cn) / max(len(pn | cn), 1)
def score_table(parts_df, catalog):
"""해외 부품 x 같은 범주 국내 후보 쌍마다 텍스트·규격·그래프 점수를 계산한 표(가중치 적용 전)."""
vec = TfidfVectorizer(token_pattern=r"[^ ]+").fit(list(parts_df["desc"]) + list(catalog["desc"]))
text = cosine_similarity(vec.transform(parts_df["desc"]), vec.transform(catalog["desc"]))
g = build_graph(catalog)
rows = []
for i, (_, p) in enumerate(parts_df.iterrows()):
pn = graph_nodes(p)
for j, (_, c) in enumerate(catalog.iterrows()):
if c["category"] != p["category"]:
continue
sf, conds = spec_fit(p, c)
rows.append({"part_no": p["part_no"], "cand_no": c["cand_no"], "vendor": c["vendor"], "text": text[i, j],
"spec": sf, "graph": graph_sim(g, pn, g["nodes"][j]),
"unmet": ",".join(n for n, ok in zip(["전압", "전력", "저온", "고온", "크기", "인터페이스"], conds) if not ok) or "-",
"kind": c["kind"], "correct": c["approved_for"] == p["part_no"]})
return pd.DataFrame(rows)
def recommend(table, weights=None, top=3):
"""가중합 점수로 부품별 상위 top개 후보를 고른다."""
w = weights or WEIGHTS
t = table.assign(score=w["text"] * table["text"] + w["spec"] * table["spec"] + w["graph"] * table["graph"])
return t.sort_values(["part_no", "score"], ascending=[True, False]).groupby("part_no").head(top)
def hit_rate(recs, k=1):
"""과거 승인 이력(정답 대체품)이 상위 k개 안에 들어간 부품 비율."""
return recs.groupby("part_no").apply(lambda d: d["correct"].head(k).any(), include_groups=False).mean()
NEED = {"part_no": str, "category": str, "country": str, "n_suppliers": float, "lead_time_days": float, "late_orders": float,
"orders_12m": float, "eol_status": str, "annual_demand": float, "stock_qty": float, "desc": str, "v": float, "w": float,
"tmin": float, "tmax": float, "size_mm": float, "std": str, "iface": str}
RANGE = {"n_suppliers": (1, 10), "lead_time_days": (1, 1000), "late_orders": (0, 100), "orders_12m": (1, 100), "annual_demand": (1, 100000), "stock_qty": (0, 100000)}
LOG = []
class UserError(Exception):
"""사용자에게 보여 줄 메시지와 조치 가이드를 함께 가진 예외."""
def __init__(self, code, msg, action):
super().__init__(msg); self.code, self.msg, self.action = code, msg, action
def log(level, code, detail):
LOG.append({"seq": len(LOG) + 1, "level": level, "code": code, "detail": str(detail)[:80]})
def read_input(src):
"""입력(CSV 문자열 / JSON 문자열 / DataFrame)을 DataFrame으로 읽는다. 실패하면 UserError."""
if src is None:
raise UserError("E01", "입력이 비어 있습니다.", "파일을 다시 선택해 주세요.")
if isinstance(src, pd.DataFrame):
df = src.copy()
elif isinstance(src, str):
if not src.strip():
raise UserError("E02", "빈 파일입니다.", "내용이 있는 파일인지 확인해 주세요.")
try:
df = pd.DataFrame(json.loads(src)) if src.lstrip()[0] in "[{" else pd.DataFrame(list(csv.DictReader(io.StringIO(src))))
except Exception as e:
raise UserError("E03", "파일 형식을 읽을 수 없습니다.", "CSV(쉼표 구분) 또는 JSON(목록) 형식으로 저장했는지 확인해 주세요.") from e
else:
raise UserError("E04", f"지원하지 않는 입력 형식입니다({type(src).__name__}).", "CSV 또는 JSON 파일을 사용해 주세요.")
if df.empty:
raise UserError("E02", "읽은 행이 0건입니다.", "머리글만 있는 파일이 아닌지 확인해 주세요.")
return df
def validate_table(df):
"""열 점검 -> 행 단위 점검. 문제 행은 격리하고 나머지로 계속 진행한다. 반환: (정상 행, 격리 내역)"""
miss = [c for c in NEED if c not in df.columns]
if miss:
raise UserError("E10", f"필수 열이 없습니다: {', '.join(miss[:4])}{' 외' if len(miss) > 4 else ''}", "열 이름이 양식(데이터 딕셔너리)과 같은지 확인해 주세요.")
d = df.copy(); bad = {}
for c, t in NEED.items():
if t is float:
conv = pd.to_numeric(d[c], errors="coerce")
for i in d.index[conv.isna()]:
bad.setdefault(i, f"{c}: 숫자가 아님/누락")
d[c] = conv
for c, (lo, hi) in RANGE.items():
for i in d.index[(d[c] < lo) | (d[c] > hi)]:
bad.setdefault(i, f"{c}: 허용 범위({lo}~{hi}) 밖")
for i in d.index[d["country"].isin(COUNTRIES) == False]:
bad.setdefault(i, "country: 허용되지 않은 값")
for i in d.index[d["part_no"].duplicated(keep="first")]:
bad.setdefault(i, "part_no: 중복")
for i in d.index[~d["part_no"].astype(str).str.match(r"^P-\d{4}$")]:
bad.setdefault(i, "part_no: 형식 오류")
if len(bad) == len(d):
raise UserError("E11", "사용할 수 있는 행이 없습니다.", "격리 사유를 확인해 값을 고친 뒤 다시 올려 주세요.")
return d.drop(index=list(bad)), bad
def risk_points(d):
rate = d["late_orders"] / d["orders_12m"]
cover = d["stock_qty"] / d["annual_demand"] * 365 - d["lead_time_days"]
return (30 * (d["n_suppliers"] == 1) + 25 * np.clip(rate, 0, 1) + 20 * (d["eol_status"] != "정상") + 15 * (cover < 0) + 10 * (d["country"] != "국내")).round(1)
def dashboard(df, catalog, draw=True):
d, bad = validate_table(read_input(df))
d = d.assign(risk=risk_points(d))
heat = d.pivot_table(index="country", columns="category", values="risk", aggfunc="mean").round(1)
over = d[d["country"] != "국내"].sort_values("risk", ascending=False).head(6)
vec = TfidfVectorizer(token_pattern=r"[^ ]+").fit(list(d["desc"]) + list(catalog["desc"]))
alt = []
for _, p in over.iterrows():
cs = catalog[catalog["category"] == p["category"]]
if cs.empty:
alt.append((p["part_no"], "후보 없음", 0.0)); continue
fit = np.array([spec_fit(p, c)[0] for _, c in cs.iterrows()])
sc = 0.45 * fit + 0.35 * cosine_similarity(vec.transform([p["desc"]]), vec.transform(cs["desc"]))[0]
j = int(np.argmax(sc)); alt.append((p["part_no"], cs.iloc[j]["cand_no"], float(fit[j])))
if draw:
fig, ax = plt.subplots(1, 3, figsize=(13, 4))
im = ax[0].imshow(heat.fillna(0).to_numpy(), cmap="Reds"); ax[0].set_xticks(range(heat.shape[1]), [f"c{i + 1}" for i in range(heat.shape[1])]); ax[0].set_yticks(range(heat.shape[0]), ["A", "B", "C", "domestic"][:heat.shape[0]])
ax[0].set_title("Supply risk map (country x category)"); plt.colorbar(im, ax=ax[0])
ax[1].scatter(d["lead_time_days"], d["late_orders"] / d["orders_12m"], c=d["risk"], cmap="Reds", s=25); ax[1].set_xlabel("lead time (days)"); ax[1].set_ylabel("late order rate"); ax[1].set_title("Lead time vs delay")
ax[2].barh([a[0] for a in alt], [a[2] for a in alt], color="tab:blue"); ax[2].invert_yaxis(); ax[2].set_xlim(0, 1); ax[2].set_title("Alternative spec fit (top-6 risk)")
plt.tight_layout(); plt.show()
return heat, alt, bad
def safe_dashboard(case, src, catalog):
"""모든 예외를 잡아 크래시 없이 사용자 메시지·조치 안내를 돌려주고 에러 로그를 남긴다."""
try:
heat, alt, bad = dashboard(src, catalog, draw=False)
for i, why in bad.items():
log("WARN", "W01", f"{case}: 행 {i} 격리 - {why}")
return {"case": case, "ok": True, "msg": f"표시 완료(격리 {len(bad)}행)" if bad else "표시 완료", "action": "-", "quarantined": len(bad)}
except UserError as e:
log("ERROR", e.code, f"{case}: {e.msg}")
return {"case": case, "ok": False, "msg": e.msg, "action": e.action, "quarantined": 0}
except Exception as e: # 예상하지 못한 오류도 크래시 대신 안내로 변환
log("ERROR", "E99", f"{case}: {type(e).__name__} {e}")
return {"case": case, "ok": False, "msg": "예상하지 못한 오류가 발생했습니다.", "action": "오류 코드 E99와 입력 파일을 담당자에게 전달해 주세요.", "quarantined": 0}
parts, catalog = make_parts(100, seed=7)
base = parts.copy()
csv_ok = base.to_csv(index=False)
def mut(**kw):
d = base.copy()
for k, v in kw.items():
d[k] = d[k].astype(object); d.loc[d.index[:3], k] = v
return d
cases = [
("정상 입력", base), ("정상 CSV 문자열", csv_ok), ("입력 없음(None)", None), ("빈 문자열", " "), ("깨진 JSON", '[{"part_no": "P-0001", '),
("지원하지 않는 형식(정수)", 12345), ("머리글만 있는 CSV", ",".join(base.columns) + "\n"), ("필수 열 누락", base.drop(columns=["lead_time_days", "desc"])),
("숫자 열에 문자", mut(lead_time_days="미정")), ("음수 리드타임", mut(lead_time_days=-5)), ("공급선 수 결측", mut(n_suppliers=np.nan)),
("허용되지 않은 국가", mut(country="미상")), ("부품번호 중복·형식 오류", mut(part_no="P-9")), ("연간 소요량 0(나눗셈 위험)", mut(annual_demand=0)),
("극단값(1e12)", mut(stock_qty=1e12)), ("전부 불량 행", base.assign(lead_time_days=-1)),
]
res = [safe_dashboard(n, s, catalog) for n, s in cases]
print(f"[에러 핸들러 시험] 입력 {len(res)}종 실행")
for r in res:
print(f" {'OK ' if r['ok'] else '안내'} {r['case']:22s} -> {r['msg']}" + (f" | 조치: {r['action']}" if r["action"] != "-" else ""))
crash = 0 # safe_dashboard 밖으로 예외가 새 나간 건수(위에서 모두 포착)
n_err = sum(not r["ok"] for r in res)
logged_err = sum(l["level"] == "ERROR" for l in LOG)
print(f"\n크래시 {crash}건(목표 0건: {'달성' if crash == 0 else '미달'}), 오류 {n_err}건 중 로그 기록 {logged_err}건 -> 기록률 {logged_err / max(n_err, 1):.0%}(목표 100%: {'달성' if logged_err == n_err else '미달'})")
print(f"행 단위 격리 경고 {sum(l['level'] == 'WARN' for l in LOG)}건 기록, 안내 문구에 조치 가이드 포함 {sum(r['action'] != '-' for r in res if not r['ok'])}/{n_err}건")
print("\n[에러 로그 앞 5건]")
print(pd.DataFrame(LOG).head(5).to_string(index=False))
# 정상 입력으로 대시보드 그리기
heat, alt, bad = dashboard(base, catalog)
print("\n[공급망 지도] 국가 x 범주 평균 위험 점수(상위 위험 국가 행)")
print(heat.loc[[c for c in COUNTRIES if c in heat.index]].fillna(0).round(0).astype(int).to_string())
print("\n[대체선 추천 패널] 위험 상위 해외 부품별 국산 후보와 규격 적합성")
for a in alt:
print(f" {a[0]} -> {a[1]} (규격 적합성 {a[2]:.2f})")
In [ ]:
"""위험 상위 부품의 대체 부품 추천 결과를 조달담당 상신 -> 기술검토관 규격 검토 -> 보안담당 검토 -> 승인권자 결재의 HITL 워크플로우로 처리하되, 시스템의 자동 확정과 권한 우회 시도를 차단하고 모든 행위를 해시 체인 감사 로그에 남겨 이력 무결성과 위변조 탐지를 검증한다."""
import hashlib
import json
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity
# ---------------------------------------------------------------------------
# [교육용가상자료] 가상 무기체계 부품·공급망 데이터 생성기 (실제 체계·업체·국가·단가 정보 없음)
# 부품 100종(해외 조달 약 80%)의 공급망 속성과, 국내 부품 카탈로그(과거 승인된 국산 대체품 +
# 설명은 비슷하지만 규격이 맞지 않는 유사 후보)를 함께 만든다.
# ---------------------------------------------------------------------------
COUNTRIES = ["가상국가A", "가상국가B", "가상국가C", "국내"]
CATS = {
"전원모듈": dict(words=["DC-DC", "컨버터", "절연형", "전원", "정출력"], syn={"컨버터": "변환장치", "절연형": "아이솔레이션", "정출력": "안정화"}, std="MIL-STD-704", iface="단자대"),
"커넥터": dict(words=["원형", "커넥터", "방수형", "다핀", "나사체결"], syn={"커넥터": "접속자", "방수형": "밀폐형", "나사체결": "스크루결합"}, std="MIL-DTL-38999", iface="원형핀"),
"유압밸브": dict(words=["유압", "솔레노이드", "밸브", "방향제어", "4포트"], syn={"밸브": "개폐장치", "방향제어": "유로전환", "솔레노이드": "전자식"}, std="SAE-AS5440", iface="플랜지"),
"베어링": dict(words=["볼", "베어링", "고속", "밀봉형", "회전축"], syn={"베어링": "축받이", "밀봉형": "실드형", "고속": "고회전"}, std="ISO-492", iface="축결합"),
"광학렌즈": dict(words=["적외선", "렌즈", "게르마늄", "반사방지", "집광"], syn={"렌즈": "광학창", "집광": "초점형성", "반사방지": "AR코팅"}, std="MIL-PRF-13830", iface="마운트"),
"자이로센서": dict(words=["광섬유", "자이로", "각속도", "센서", "관성"], syn={"센서": "감지기", "각속도": "회전율", "관성": "INS용"}, std="IEEE-952", iface="직렬통신"),
"RF증폭기": dict(words=["RF", "전력증폭기", "광대역", "모듈", "송신"], syn={"전력증폭기": "파워앰프", "송신": "출력단", "광대역": "와이드밴드"}, std="MIL-STD-461", iface="SMA"),
"제어보드": dict(words=["FPGA", "제어보드", "임베디드", "연산", "입출력"], syn={"제어보드": "컨트롤카드", "연산": "신호처리", "임베디드": "내장형"}, std="VITA-46", iface="백플레인"),
}
V_OPTS, W_OPTS = [12, 24, 28], [25, 50, 100]
VENDORS = ["가상정밀(주)", "가상전자(주)", "가상유공압(주)", "가상광학(주)", "가상테크(주)", "가상시스템(주)"]
def describe(cat, words, s):
# [TODO : 학생 실습 7] 추천된 대체 부품의 규격 적합성 검토 및 조달 승인을 위한 HITL 워크플로우 설계 관련 핵심 로직을 구현하세요.
# 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
# <<< 여기에 코드를 작성하세요 >>>
pass # 작성 후 pass를 제거하세요.
def _sig(x):
# [TODO : 학생 실습 7] 추천된 대체 부품의 규격 적합성 검토 및 조달 승인을 위한 HITL 워크플로우 설계 관련 핵심 로직을 구현하세요.
# 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
# <<< 여기에 코드를 작성하세요 >>>
pass # 작성 후 pass를 제거하세요.
def make_parts(n=100, seed=7):
"""부품 목록(공급망 속성 포함)과 국내 카탈로그를 만든다. 반환: (parts DataFrame, catalog DataFrame)"""
rng = np.random.default_rng(seed)
cats, rows, cat_rows = list(CATS), [], []
for i in range(n):
cat = cats[i % len(cats)]; c = CATS[cat]
spec = {"v": int(rng.choice(V_OPTS)), "w": int(rng.choice(W_OPTS)), "tmin": int(rng.choice([-55, -40])),
"tmax": int(rng.choice([85, 125])), "size_mm": float(round(rng.uniform(20, 120), 1)), "std": c["std"]}
words = [str(x) for x in rng.permutation(c["words"])[:4]]
country = str(rng.choice(COUNTRIES, p=[0.35, 0.3, 0.15, 0.2]))
overseas = country != "국내"
n_sup = int(rng.choice([1, 2, 3], p=[0.45, 0.35, 0.2])) if overseas else int(rng.choice([1, 2, 3]))
lead = float(round(rng.gamma(3, 45 if overseas else 25) + 20))
eol = str(rng.choice(["정상", "단종예고", "단종"], p=[0.7, 0.2, 0.1]))
crit = str(rng.choice(["A", "B", "C"], p=[0.3, 0.4, 0.3]))
z = (-2.6 + 1.1 * (n_sup == 1) + 0.9 * overseas + 0.006 * (lead - 100) + 1.2 * (eol == "단종예고")
+ 2.0 * (eol == "단종") + rng.normal(0, 0.6))
p_late = float(np.clip(_sig(z) * 0.8 + 0.05, 0, 0.9))
late = int(rng.binomial(12, p_late))
demand = int(rng.integers(20, 400))
rows.append({"part_no": f"P-{i + 1:04d}", "category": cat, "desc": describe(cat, words, spec), **spec,
"iface": c["iface"], "country": country, "overseas": overseas, "n_suppliers": n_sup,
"lead_time_days": lead, "orders_12m": 12, "late_orders": late,
"avg_delay_days": float(round(late and rng.gamma(2, 12 + 20 * p_late), 1)),
"eol_status": eol, "ltb_months": int(rng.integers(1, 25)) if eol == "단종예고" else None,
"price_change_pct": float(round(rng.normal(4 + 12 * p_late, 8), 1)), "criticality": crit,
"annual_demand": demand, "stock_qty": int(rng.integers(0, demand)),
"defect_rate": float(round(abs(rng.normal(0.8 + 2 * p_late, 0.8)), 2)),
"export_license": bool(overseas and rng.random() < 0.35),
"disrupted_next12m": bool(rng.random() < _sig(z + 0.4))})
if not overseas:
continue
# 과거 승인된 국산 대체품(정답): 동의어로 서술, 규격 호환(전력 여유 허용, 일부는 크기 차이가 커 장착 검토 필요)
eq_words = [c["syn"].get(w, w) for w in words]
hard = rng.random() < 0.15 # 서술이 많이 다른 사례
if hard:
eq_words = [c["syn"].get(w, w) for w in c["words"] if w not in words][:1] + eq_words[1:3]
eq = {**spec, "w": spec["w"] if rng.random() < 0.7 else W_OPTS[min(W_OPTS.index(spec["w"]) + 1, 2)],
"size_mm": float(round(spec["size_mm"] * (rng.uniform(1.11, 1.15) if rng.random() < 0.12 else rng.uniform(0.95, 1.05)), 1))}
cat_rows.append({"cand_no": f"D-{len(cat_rows) + 1:04d}", "category": cat, "desc": describe(cat, eq_words, eq), **eq,
"iface": c["iface"], "vendor": str(rng.choice(VENDORS)), "approved_for": f"P-{i + 1:04d}", "kind": "equivalent"})
# 유사 오답 후보: 설명은 거의 같지만 전압이 다르거나(5V/48V) 상용 온도 등급(0~70C)
for kind in ("volt_mismatch", "temp_mismatch"):
d = dict(spec)
if kind == "volt_mismatch":
d["v"] = int(rng.choice([5, 48])) # 체계에서 쓰지 않는 전압
else:
d["tmin"], d["tmax"] = 0, 70
d["size_mm"] = float(round(spec["size_mm"] * rng.uniform(0.95, 1.05), 1))
cat_rows.append({"cand_no": f"D-{len(cat_rows) + 1:04d}", "category": cat, "desc": describe(cat, words, d), **d,
"iface": c["iface"], "vendor": str(rng.choice(VENDORS)), "approved_for": None, "kind": kind})
parts = pd.DataFrame(rows)
catalog = pd.DataFrame(cat_rows).sample(frac=1, random_state=seed).reset_index(drop=True)
return parts, catalog
# ---------------------------------------------------------------------------
# ---- 대체 부품 추천 공통 함수: 텍스트 유사도 + 규격 적합성 + 지식 그래프 이웃 유사도 ----
WEIGHTS = {"text": 0.35, "spec": 0.45, "graph": 0.20}
def spec_fit(p, c):
"""규격 적합성 0~1: 6개 조건 충족 비율. 전압·온도 한계(필수 조건)를 못 맞추면 점수를 절반으로 깎는다."""
conds = [p["v"] == c["v"], c["w"] >= p["w"], c["tmin"] <= p["tmin"], c["tmax"] >= p["tmax"],
abs(c["size_mm"] - p["size_mm"]) / p["size_mm"] <= 0.10, p["iface"] == c["iface"]]
s = sum(conds) / len(conds)
return (s * 0.5 if not (conds[0] and conds[2] and conds[3]) else s), conds
def graph_nodes(row):
"""지식 그래프에서 부품 노드와 연결된 이웃 노드 집합(범주·표준·인터페이스·기능어·등급)."""
words = set(row["desc"].split()[1:-4])
grade = "군용등급" if row["tmin"] <= -40 else "상용등급"
return {f"cat:{row['category']}", f"std:{row['std']}", f"if:{row['iface']}", f"grade:{grade}", f"v:{row['v']}"} | {f"w:{w}" for w in words}
def build_graph(catalog):
"""동의어 사전을 그래프 간선(기능어 = 동의어)으로 넣어 서술이 달라도 같은 기능 노드로 연결되게 한다."""
syn = {f"w:{b}": f"w:{a}" for c in CATS.values() for a, b in c["syn"].items()}
canon = lambda nodes: {syn.get(x, x) for x in nodes}
return {"canon": canon, "nodes": [canon(graph_nodes(r)) for _, r in catalog.iterrows()]}
def graph_sim(g, pn, cn):
"""두 부품이 공유하는 이웃 노드 비율(Jaccard)."""
pn = g["canon"](pn)
return len(pn & cn) / max(len(pn | cn), 1)
def score_table(parts_df, catalog):
"""해외 부품 x 같은 범주 국내 후보 쌍마다 텍스트·규격·그래프 점수를 계산한 표(가중치 적용 전)."""
vec = TfidfVectorizer(token_pattern=r"[^ ]+").fit(list(parts_df["desc"]) + list(catalog["desc"]))
text = cosine_similarity(vec.transform(parts_df["desc"]), vec.transform(catalog["desc"]))
g = build_graph(catalog)
rows = []
for i, (_, p) in enumerate(parts_df.iterrows()):
pn = graph_nodes(p)
for j, (_, c) in enumerate(catalog.iterrows()):
if c["category"] != p["category"]:
continue
sf, conds = spec_fit(p, c)
rows.append({"part_no": p["part_no"], "cand_no": c["cand_no"], "vendor": c["vendor"], "text": text[i, j],
"spec": sf, "graph": graph_sim(g, pn, g["nodes"][j]),
"unmet": ",".join(n for n, ok in zip(["전압", "전력", "저온", "고온", "크기", "인터페이스"], conds) if not ok) or "-",
"kind": c["kind"], "correct": c["approved_for"] == p["part_no"]})
return pd.DataFrame(rows)
def recommend(table, weights=None, top=3):
"""가중합 점수로 부품별 상위 top개 후보를 고른다."""
w = weights or WEIGHTS
t = table.assign(score=w["text"] * table["text"] + w["spec"] * table["spec"] + w["graph"] * table["graph"])
return t.sort_values(["part_no", "score"], ascending=[True, False]).groupby("part_no").head(top)
def hit_rate(recs, k=1):
"""과거 승인 이력(정답 대체품)이 상위 k개 안에 들어간 부품 비율."""
return recs.groupby("part_no").apply(lambda d: d["correct"].head(k).any(), include_groups=False).mean()
# ---- 역할과 권한(최소 권한 원칙): 시스템은 '제안'만 할 수 있고 확정 권한이 없다 ----
PERM = {"시스템": {"propose"}, "조달담당": {"submit", "view"}, "기술검토관": {"review_spec", "view"}, "보안담당": {"review_sec", "view"},
"승인권자": {"approve", "view"}, "감사관": {"view", "audit"},
"부서장": {"submit", "approve", "view"}} # 소규모 부서는 상신과 결재를 겸할 수 있으나 같은 건은 분리
NEXT = {"제안": ("submit", "상신"), "상신": ("review_spec", "규격검토"), "규격검토": ("review_sec", "보안검토"), "보안검토": ("approve", "승인")}
USERS = {"sys": "시스템", "kim": "조달담당", "lee": "기술검토관", "park": "보안담당", "choi": "승인권자", "jung": "감사관", "oh": "부서장"}
FLAGGED_VENDORS = {"가상시스템(주)"} # [교육용가상자료] 보안 검토에서 추가 확인이 필요한 업체 표기
class Chain:
"""해시 체인 감사 로그: 각 기록이 직전 기록의 해시를 포함해 중간 수정이 드러난다."""
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
class Workflow:
def __init__(self):
self.audit, self.cases, self.blocked = Chain(), {}, []
def _deny(self, who, case, action, why):
self.audit.add(event="DENIED", who=who, case=case, action=action, reason=why)
self.blocked.append((who, case, action, why)); return False, why
def act(self, who, case, action, reason="", evidence=""):
role = USERS.get(who)
if role is None:
return self._deny(who, case, action, "미등록 사용자")
if action == "delete_log":
return self._deny(who, case, action, "감사 로그 삭제·수정 권한은 누구에게도 없음")
if action not in PERM[role]:
return self._deny(who, case, action, f"권한 없음({role} 역할에 {action} 권한 없음)")
if action == "propose":
self.cases[case] = {"state": "제안", "submitter": None}
self.audit.add(event="PROPOSE", who=who, case=case, reason=reason); return True, "제안 등록"
c = self.cases.get(case)
if c is None:
return self._deny(who, case, action, "존재하지 않는 건")
if c["state"] in ("승인", "반려"):
return self._deny(who, case, action, f"이미 종결된 건({c['state']})")
need, nxt = NEXT[c["state"]]
if action != need:
return self._deny(who, case, action, f"단계 위반(현재 {c['state']}, 필요 행위 {need})")
if not reason.strip() or not evidence.strip():
return self._deny(who, case, action, "사유·근거 입력 필수")
if action == "submit":
c["submitter"] = who
if action == "approve" and who == c["submitter"]:
return self._deny(who, case, action, "상신자와 승인자 분리 원칙 위반")
c["state"] = nxt
self.audit.add(event=action.upper(), who=who, role=role, case=case, to=nxt, reason=reason, evidence=evidence); return True, nxt
def reject(self, who, case, reason, evidence):
"""반려: 검토자·승인권자가 어느 단계에서든 사유·근거와 함께 할 수 있다."""
c = self.cases[case]
role = USERS.get(who)
if role not in ("승인권자", "기술검토관", "보안담당", "부서장") or c["state"] in ("승인", "반려"):
return self._deny(who, case, "reject", "반려 권한 없음 또는 종결된 건")
if not reason.strip() or not evidence.strip():
return self._deny(who, case, "reject", "사유·근거 입력 필수")
c["state"] = "반려"
self.audit.add(event="REJECTED", who=who, role=role, case=case, reason=reason, evidence=evidence); return True, "반려"
# ---- 상신 대상: 위험 상위 해외 부품과 추천 1순위 후보 ----
parts, catalog = make_parts(100, seed=7)
over = parts[parts["overseas"]].reset_index(drop=True)
table = score_table(over, catalog)
top = recommend(table).groupby("part_no").head(1).set_index("part_no")
risk = (30 * (over["n_suppliers"] == 1) + 25 * (over["late_orders"] / 12) + 20 * (over["eol_status"] != "정상") + 10 * over["criticality"].eq("A")).to_numpy()
picked = over.assign(risk=risk).sort_values("risk", ascending=False).head(12)
rng = np.random.default_rng(4)
wf = Workflow()
rows = []
for k, (_, p) in enumerate(picked.iterrows(), 1):
case, r = f"C{k:02d}", top.loc[p["part_no"]]
wf.act("sys", case, "propose", reason=f"{p['part_no']} 위험 점수 {p['risk']:.0f}, 추천 {r['cand_no']}")
wf.act("kim", case, "submit", "단종·단일 공급선 위험 부품 대체 검토 요청", f"위험 점수 {p['risk']:.0f}점, 후보 점수 {r['score']:.2f}")
hard = [u for u in r["unmet"].split(",") if u in ("전압", "저온", "고온")]
if rng.random() < 0.15:
rows.append((case, p["part_no"], r["cand_no"], "무응답 -> 보류", "자동 확정 없음: 기술검토관 응답 대기")); continue
if hard:
wf.reject("lee", case, f"필수 규격 미충족({','.join(hard)})", f"규격 적합성 {r['spec']:.2f}")
rows.append((case, p["part_no"], r["cand_no"], wf.cases[case]["state"], f"규격 반려: {','.join(hard)}")); continue
wf.act("lee", case, "review_spec", "필수 규격(전압·온도) 충족 확인" + (f", 미충족 {r['unmet']}는 시험 조건부" if r["unmet"] != "-" else ""), f"규격 적합성 {r['spec']:.2f}")
if r["vendor"] in FLAGGED_VENDORS:
wf.reject("park", case, "업체 보안 확인 미완료", f"업체 {r['vendor']} 추가 확인 필요")
rows.append((case, p["part_no"], r["cand_no"], wf.cases[case]["state"], "보안 반려: 업체 확인 필요")); continue
wf.act("park", case, "review_sec", "업체·공급 경로 보안 확인", f"업체 {r['vendor']} 확인 완료(가상)")
ok = r["score"] >= 0.80 and p["risk"] >= 40
if ok:
wf.act("choi", case, "approve", "승인: 위험 부품 국산화 시험 착수", f"후보 점수 {r['score']:.2f}, 위험 {p['risk']:.0f}")
else:
wf.reject("choi", case, "반려: 추천 점수 또는 위험도 기준 미달(추가 시험 후 재상신)", f"후보 점수 {r['score']:.2f}, 위험 {p['risk']:.0f}")
rows.append((case, p["part_no"], r["cand_no"], wf.cases[case]["state"], "승인권자 결정" if ok else "점수 미달 반려"))
print("[결재 결과] 12건")
print(pd.DataFrame(rows, columns=["건", "부품", "후보", "최종 상태", "비고"]).to_string(index=False))
st = pd.Series([r[3] for r in rows]).value_counts().to_dict()
print("상태 집계:", st, "(모의 결재자 기준, 실제 결재는 사람이 한다)")
# ---- 권한 우회·절차 위반 시도 ----
wf.act("sys", "T01", "propose", reason="시험 건")
wf.act("kim", "T01", "submit", "시험 상신", "근거")
attempts = [
("시스템 자동 승인", lambda: wf.act("sys", "T01", "approve", "자동", "자동")),
("조달담당이 규격 검토 대행", lambda: wf.act("kim", "T01", "review_spec", "대행", "근거")),
("검토 단계 건너뛰고 승인", lambda: wf.act("choi", "T01", "approve", "바로 승인", "근거")),
("미등록 사용자 접근", lambda: wf.act("guest", "T01", "review_spec", "열람", "근거")),
("사유 없는 검토", lambda: wf.act("lee", "T01", "review_spec", "", "")),
("감사 로그 삭제", lambda: wf.act("kim", "T01", "delete_log", "정리", "근거")),
("감사관이 승인 시도", lambda: wf.act("jung", "T01", "approve", "승인", "근거")),
("종결된 건 재승인", lambda: wf.act("choi", "C01", "approve", "재승인", "근거")),
]
wf.act("sys", "T02", "propose", reason="시험"); wf.act("oh", "T02", "submit", "상신", "근거")
wf.act("lee", "T02", "review_spec", "규격 확인", "적합성 1.00"); wf.act("park", "T02", "review_sec", "보안 확인", "확인 완료")
attempts.append(("상신자가 본인 건 승인(분리 원칙)", lambda: wf.act("oh", "T02", "approve", "본인 승인", "근거")))
print("\n[권한 우회·절차 위반 시도]")
blocked = 0
for name, fn in attempts:
ok, why = fn()
blocked += (not ok)
print(f" {name:22s}-> {'차단' if not ok else '통과(결함)'}: {why}")
print(f"우회·위반 시도 차단 {blocked}/{len(attempts)} ({blocked / len(attempts):.0%}) (목표 100%: {'달성' if blocked == len(attempts) else '미달'})")
# ---- 이력 무결성 ----
bad = wf.audit.verify()
n_cases_logged = sum(1 for e in wf.audit.log if e["event"] == "PROPOSE")
print(f"\n감사 로그 {len(wf.audit.log)}건, 해시 체인 검증: {'정상' if bad is None else f'seq {bad}에서 불일치'} -> 이력 무결성 {'100%' if bad is None else '훼손'}(목표 100%: {'달성' if bad is None else '미달'})")
saved = json.dumps(wf.audit.log[10], ensure_ascii=False)
wf.audit.log[10]["reason"] = "사후 수정"
print(f"10번째 기록 임의 수정 후 검증: {'위변조 탐지(seq ' + str(wf.audit.verify()) + ')' if wf.audit.verify() is not None else '탐지 실패'}")
wf.audit.log[10] = json.loads(saved)
print("승인된 건의 마지막 기록 예시:", {k: v for k, v in next((e for e in reversed(wf.audit.log) if e["event"] == "APPROVE" and e["case"].startswith("C")), {}).items() if k in ("seq", "who", "case", "reason", "evidence")})
print("※ 대체 부품의 규격 적합성 최종 판단과 조달 승인은 사람(기술검토관·보안담당·승인권자)이 하며, 시스템은 추천과 근거만 제공한다.")
plt.figure(figsize=(6, 3.5)); EN = {"승인": "approved", "반려": "rejected", "무응답 -> 보류": "on hold"}; plt.bar([EN.get(k, k) for k in st], list(st.values()), color="tab:blue"); plt.title("HITL workflow outcomes (12 cases)"); plt.ylabel("cases"); plt.tight_layout(); plt.show()
In [ ]:
"""가상 공급국 전면 차단(지정학적 위기 모의) 시나리오 4종을 부품 2,000종 규모 배치 파이프라인으로 실행하고, 레코드 손상 2%·일시 오류 주입 상황에서 실행 시간(5분 이내)·자동화 성공률(99% 이상)·수작업 대비 시간 절감(80% 이상)과 시간 초과 시 기능 축소(Fail-safe) 동작을 검증한다."""
import time
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
# ---------------------------------------------------------------------------
# [교육용가상자료] 가상 무기체계 부품·공급망 데이터 생성기 (실제 체계·업체·국가·단가 정보 없음)
# 부품 100종(해외 조달 약 80%)의 공급망 속성과, 국내 부품 카탈로그(과거 승인된 국산 대체품 +
# 설명은 비슷하지만 규격이 맞지 않는 유사 후보)를 함께 만든다.
# ---------------------------------------------------------------------------
COUNTRIES = ["가상국가A", "가상국가B", "가상국가C", "국내"]
CATS = {
"전원모듈": dict(words=["DC-DC", "컨버터", "절연형", "전원", "정출력"], syn={"컨버터": "변환장치", "절연형": "아이솔레이션", "정출력": "안정화"}, std="MIL-STD-704", iface="단자대"),
"커넥터": dict(words=["원형", "커넥터", "방수형", "다핀", "나사체결"], syn={"커넥터": "접속자", "방수형": "밀폐형", "나사체결": "스크루결합"}, std="MIL-DTL-38999", iface="원형핀"),
"유압밸브": dict(words=["유압", "솔레노이드", "밸브", "방향제어", "4포트"], syn={"밸브": "개폐장치", "방향제어": "유로전환", "솔레노이드": "전자식"}, std="SAE-AS5440", iface="플랜지"),
"베어링": dict(words=["볼", "베어링", "고속", "밀봉형", "회전축"], syn={"베어링": "축받이", "밀봉형": "실드형", "고속": "고회전"}, std="ISO-492", iface="축결합"),
"광학렌즈": dict(words=["적외선", "렌즈", "게르마늄", "반사방지", "집광"], syn={"렌즈": "광학창", "집광": "초점형성", "반사방지": "AR코팅"}, std="MIL-PRF-13830", iface="마운트"),
"자이로센서": dict(words=["광섬유", "자이로", "각속도", "센서", "관성"], syn={"센서": "감지기", "각속도": "회전율", "관성": "INS용"}, std="IEEE-952", iface="직렬통신"),
"RF증폭기": dict(words=["RF", "전력증폭기", "광대역", "모듈", "송신"], syn={"전력증폭기": "파워앰프", "송신": "출력단", "광대역": "와이드밴드"}, std="MIL-STD-461", iface="SMA"),
"제어보드": dict(words=["FPGA", "제어보드", "임베디드", "연산", "입출력"], syn={"제어보드": "컨트롤카드", "연산": "신호처리", "임베디드": "내장형"}, std="VITA-46", iface="백플레인"),
}
V_OPTS, W_OPTS = [12, 24, 28], [25, 50, 100]
VENDORS = ["가상정밀(주)", "가상전자(주)", "가상유공압(주)", "가상광학(주)", "가상테크(주)", "가상시스템(주)"]
def describe(cat, words, s):
# [TODO : 학생 실습 8] 지정학적 위기 모의 글로벌 공급망 전면 차단 스트레스 시뮬레이션 관련 핵심 로직을 구현하세요.
# 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
# <<< 여기에 코드를 작성하세요 >>>
pass # 작성 후 pass를 제거하세요.
def _sig(x):
# [TODO : 학생 실습 8] 지정학적 위기 모의 글로벌 공급망 전면 차단 스트레스 시뮬레이션 관련 핵심 로직을 구현하세요.
# 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
# <<< 여기에 코드를 작성하세요 >>>
pass # 작성 후 pass를 제거하세요.
def make_parts(n=100, seed=7):
"""부품 목록(공급망 속성 포함)과 국내 카탈로그를 만든다. 반환: (parts DataFrame, catalog DataFrame)"""
rng = np.random.default_rng(seed)
cats, rows, cat_rows = list(CATS), [], []
for i in range(n):
cat = cats[i % len(cats)]; c = CATS[cat]
spec = {"v": int(rng.choice(V_OPTS)), "w": int(rng.choice(W_OPTS)), "tmin": int(rng.choice([-55, -40])),
"tmax": int(rng.choice([85, 125])), "size_mm": float(round(rng.uniform(20, 120), 1)), "std": c["std"]}
words = [str(x) for x in rng.permutation(c["words"])[:4]]
country = str(rng.choice(COUNTRIES, p=[0.35, 0.3, 0.15, 0.2]))
overseas = country != "국내"
n_sup = int(rng.choice([1, 2, 3], p=[0.45, 0.35, 0.2])) if overseas else int(rng.choice([1, 2, 3]))
lead = float(round(rng.gamma(3, 45 if overseas else 25) + 20))
eol = str(rng.choice(["정상", "단종예고", "단종"], p=[0.7, 0.2, 0.1]))
crit = str(rng.choice(["A", "B", "C"], p=[0.3, 0.4, 0.3]))
z = (-2.6 + 1.1 * (n_sup == 1) + 0.9 * overseas + 0.006 * (lead - 100) + 1.2 * (eol == "단종예고")
+ 2.0 * (eol == "단종") + rng.normal(0, 0.6))
p_late = float(np.clip(_sig(z) * 0.8 + 0.05, 0, 0.9))
late = int(rng.binomial(12, p_late))
demand = int(rng.integers(20, 400))
rows.append({"part_no": f"P-{i + 1:04d}", "category": cat, "desc": describe(cat, words, spec), **spec,
"iface": c["iface"], "country": country, "overseas": overseas, "n_suppliers": n_sup,
"lead_time_days": lead, "orders_12m": 12, "late_orders": late,
"avg_delay_days": float(round(late and rng.gamma(2, 12 + 20 * p_late), 1)),
"eol_status": eol, "ltb_months": int(rng.integers(1, 25)) if eol == "단종예고" else None,
"price_change_pct": float(round(rng.normal(4 + 12 * p_late, 8), 1)), "criticality": crit,
"annual_demand": demand, "stock_qty": int(rng.integers(0, demand)),
"defect_rate": float(round(abs(rng.normal(0.8 + 2 * p_late, 0.8)), 2)),
"export_license": bool(overseas and rng.random() < 0.35),
"disrupted_next12m": bool(rng.random() < _sig(z + 0.4))})
if not overseas:
continue
# 과거 승인된 국산 대체품(정답): 동의어로 서술, 규격 호환(전력 여유 허용, 일부는 크기 차이가 커 장착 검토 필요)
eq_words = [c["syn"].get(w, w) for w in words]
hard = rng.random() < 0.15 # 서술이 많이 다른 사례
if hard:
eq_words = [c["syn"].get(w, w) for w in c["words"] if w not in words][:1] + eq_words[1:3]
eq = {**spec, "w": spec["w"] if rng.random() < 0.7 else W_OPTS[min(W_OPTS.index(spec["w"]) + 1, 2)],
"size_mm": float(round(spec["size_mm"] * (rng.uniform(1.11, 1.15) if rng.random() < 0.12 else rng.uniform(0.95, 1.05)), 1))}
cat_rows.append({"cand_no": f"D-{len(cat_rows) + 1:04d}", "category": cat, "desc": describe(cat, eq_words, eq), **eq,
"iface": c["iface"], "vendor": str(rng.choice(VENDORS)), "approved_for": f"P-{i + 1:04d}", "kind": "equivalent"})
# 유사 오답 후보: 설명은 거의 같지만 전압이 다르거나(5V/48V) 상용 온도 등급(0~70C)
for kind in ("volt_mismatch", "temp_mismatch"):
d = dict(spec)
if kind == "volt_mismatch":
d["v"] = int(rng.choice([5, 48])) # 체계에서 쓰지 않는 전압
else:
d["tmin"], d["tmax"] = 0, 70
d["size_mm"] = float(round(spec["size_mm"] * rng.uniform(0.95, 1.05), 1))
cat_rows.append({"cand_no": f"D-{len(cat_rows) + 1:04d}", "category": cat, "desc": describe(cat, words, d), **d,
"iface": c["iface"], "vendor": str(rng.choice(VENDORS)), "approved_for": None, "kind": kind})
parts = pd.DataFrame(rows)
catalog = pd.DataFrame(cat_rows).sample(frac=1, random_state=seed).reset_index(drop=True)
return parts, catalog
# ---------------------------------------------------------------------------
# ---- 조달 위험 스크리닝 룰 설정(YAML 형식 문자열: 규정이 바뀌면 코드가 아니라 이 설정만 고친다) ----
RULES_YAML = """
- id: R01
name: 단일 공급선
field: n_suppliers
op: "=="
value: 1
weight: 3
- id: R02
name: 해외 조달 의존
field: overseas
op: "=="
value: true
weight: 1
- id: R03
name: 리드타임 180일 초과
field: lead_time_days
op: ">"
value: 180
weight: 2
- id: R04
name: 납기 지연률 30% 이상
field: delay_rate
op: ">="
value: 0.3
weight: 3
- id: R05
name: 평균 지연 30일 초과
field: avg_delay_days
op: ">"
value: 30
weight: 2
- id: R06
name: 단종 예고
field: eol_status
op: "=="
value: 단종예고
weight: 3
- id: R07
name: 단종 확정
field: eol_status
op: "=="
value: 단종
weight: 4
- id: R08
name: 최종구매 6개월 이내
field: ltb_months
op: "<="
value: 6
weight: 3
- id: R09
name: 단가 20% 이상 급등
field: price_change_pct
op: ">="
value: 20
weight: 2
- id: R10
name: 핵심(A) 부품 단일 공급
field: crit_single
op: "=="
value: true
weight: 3
- id: R11
name: 재고 가용기간 < 리드타임
field: cover_gap_days
op: "<"
value: 0
weight: 3
- id: R12
name: 입고 불량률 2% 초과
field: defect_rate
op: ">"
value: 2.0
weight: 2
- id: R13
name: 수출 허가 필요
field: export_license
op: "=="
value: true
weight: 2
- id: R14
name: 재고 없음
field: stock_qty
op: "=="
value: 0
weight: 2
- id: R15
name: 공급국 편중(30% 이상)
field: country_share
op: ">="
value: 0.3
weight: 1
- id: R16
name: 지연 이력 있는 단일 공급선
field: single_late
op: "=="
value: true
weight: 2
"""
GRADE_CUT = {"고위험": 16, "주의": 10} # 가중치 합 기준 1차 스크리닝 등급
def parse_rules(text):
"""간이 YAML 파서(목록 + key: value). 숫자·true/false·따옴표 문자열을 해석한다."""
rules, cur = [], None
for line in text.strip().splitlines():
if not line.strip():
continue
s = line.strip()
if s.startswith("- "):
cur = {}; rules.append(cur); s = s[2:]
k, v = [x.strip() for x in s.split(":", 1)]
v = v.strip('"')
if v in ("true", "false"):
v = v == "true"
else:
try:
v = float(v) if "." in v else int(v)
except ValueError:
pass
cur[k] = v
return rules
OPS = {"==": lambda a, b: a == b, "!=": lambda a, b: a != b, ">": lambda a, b: a > b, ">=": lambda a, b: a >= b,
"<": lambda a, b: a < b, "<=": lambda a, b: a <= b}
def derive(df):
"""룰에 쓰는 파생 필드(지연률, 핵심 단일공급, 재고 가용기간 차이, 공급국 비중 등)를 만든다."""
d = df.copy()
d["delay_rate"] = d["late_orders"] / d["orders_12m"].clip(lower=1)
d["crit_single"] = (d["criticality"] == "A") & (d["n_suppliers"] == 1)
d["cover_gap_days"] = d["stock_qty"] / d["annual_demand"].clip(lower=1) * 365 - d["lead_time_days"]
d["country_share"] = d["country"].map(d["country"].value_counts(normalize=True)).where(d["overseas"], 0.0)
d["single_late"] = (d["n_suppliers"] == 1) & (d["late_orders"] >= 3)
return d
def apply_rules(rec, rules):
"""레코드 1건(dict)에 룰을 적용해 (위험 점수, 걸린 룰 목록)을 돌려준다. 값이 없으면 해당 룰은 건너뛴다."""
hits = []
for r in rules:
v = rec.get(r["field"])
if v is None or (isinstance(v, float) and np.isnan(v)):
continue
if OPS[r["op"]](v, r["value"]):
hits.append(r)
score = sum(r["weight"] for r in hits)
grade = "고위험" if score >= GRADE_CUT["고위험"] else ("주의" if score >= GRADE_CUT["주의"] else "관심")
return score, grade, [r["id"] for r in hits]
rules = parse_rules(RULES_YAML)
SCEN = {"평시": [], "가상국가A 차단": ["가상국가A"], "가상국가A+B 차단": ["가상국가A", "가상국가B"], "해외 전면 차단": ["가상국가A", "가상국가B", "가상국가C"]}
MANUAL_MIN = 32.0 # [가정] 수작업 1건: 위험 확인 12분 + 국내 후보 탐색·규격 대조 20분
VENDOR_CAP = 70 # [가정] 국내 업체 1곳이 한 분기에 착수할 수 있는 신규 국산화 개발 건수
REVIEW_MIN = 4.0 # [가정] 자동 처리 후 담당자가 확인하는 건당 시간(고위험·단절 건만)
def fit_vec(P, C):
"""규격 적합성(0~1)을 부품 x 후보 행렬로 한 번에 계산: 전압·전력·저온·고온·크기·인터페이스 6조건."""
cond = np.stack([P["v"].to_numpy()[:, None] == C["v"].to_numpy()[None], C["w"].to_numpy()[None] >= P["w"].to_numpy()[:, None],
C["tmin"].to_numpy()[None] <= P["tmin"].to_numpy()[:, None], C["tmax"].to_numpy()[None] >= P["tmax"].to_numpy()[:, None],
np.abs(C["size_mm"].to_numpy()[None] - P["size_mm"].to_numpy()[:, None]) / P["size_mm"].to_numpy()[:, None] <= 0.10,
P["iface"].to_numpy()[:, None] == C["iface"].to_numpy()[None]])
s = cond.mean(axis=0)
return np.where(cond[0] & cond[2] & cond[3], s, s * 0.5)
def pipeline(parts, catalog, blocked, mode="full", fail_p=0.0, rng=None, budget_s=None):
"""검증 -> 룰 스크리닝 -> 차단 영향 -> (full 모드) 국산 대체 후보 탐색. 오류는 재시도 3회, 실패 건은 격리하고 계속한다."""
t0 = time.perf_counter()
stats = {"ok": 0, "quarantine": 0, "retry": 0, "failed": 0, "degraded": False}
d = derive(parts)
hit = np.zeros(len(d), bool); score = np.zeros(len(d))
recs = d.to_dict("records")
for i, r in enumerate(recs):
if budget_s and time.perf_counter() - t0 > budget_s: # 시간 예산 초과: 후보 탐색을 생략하고 룰 스크리닝만 계속(기능 축소)
stats["degraded"] = True; mode = "rules_only"
if r.get("lead_time_days") is None or pd.isna(r.get("lead_time_days")):
stats["quarantine"] += 1; continue # 손상 레코드는 격리하고 계속 진행
for attempt in range(4):
if rng is not None and rng.random() < fail_p: # 일시 오류(모의): 최대 3회 재시도
stats["retry"] += 1; continue
score[i] = apply_rules(r, rules)[0]; hit[i] = r["country"] in blocked; stats["ok"] += 1; break
else:
stats["failed"] += 1
d = d.assign(score=score, blocked=hit)
cover = pd.Series(False, index=d.index); vend = pd.Series("-", index=d.index)
if mode == "full" and hit.any():
for cat, g in d[d["blocked"]].groupby("category"):
C = catalog[catalog["category"] == cat]
if len(C):
f = fit_vec(g, C)
cover.loc[g.index] = (f.max(axis=1) >= 0.83) # 규격 5/6 이상 충족 후보가 있으면 '대체 검토 가능'
vend.loc[g.index] = C["vendor"].to_numpy()[f.argmax(axis=1)]
d["alt"], d["vendor"] = cover, vend
return d, stats, time.perf_counter() - t0
rng = np.random.default_rng(8)
parts, catalog = make_parts(2000, seed=7)
parts.loc[rng.choice(len(parts), 40, replace=False), "lead_time_days"] = np.nan # 레코드 손상 2%
print(f"[교육용가상자료] 국내 업체 수용량 가정: 업체당 분기 {VENDOR_CAP}건")
print(f"[교육용가상자료] 부품 {len(parts)}종, 국내 후보 {len(catalog)}건, 손상 레코드 {int(parts['lead_time_days'].isna().sum())}건(2%) 주입, 일시 오류 확률 3%/시도")
res = []
for name, blk in SCEN.items():
d, st, sec = pipeline(parts, catalog, blk, fail_p=0.03, rng=rng)
b = d[d["blocked"]].sort_values(["criticality", "score"], ascending=[True, False]) # 핵심(A) 먼저, 위험 점수 높은 순으로 업체 수용량 배정
b = b.assign(start=b[b["alt"]].groupby("vendor").cumcount() < VENDOR_CAP)
b["start"] = b["start"].fillna(False).astype(bool)
crit = b[b["criticality"] == "A"]
days = (b["stock_qty"] / b["annual_demand"].clip(lower=1) * 365)
res.append({"시나리오": name, "단절 부품": len(b), "대체 후보 있음": int(b["alt"].sum()), "분기 내 착수 가능": int(b["start"].sum()), "핵심(A) 착수 대기": int((~crit["start"]).sum()),
"재고 90일 미만 단절": int((days < 90).sum()), "실행(초)": sec, "성공": st["ok"], "재시도": st["retry"], "격리": st["quarantine"], "실패": st["failed"]})
R = pd.DataFrame(res)
print("\n[차단 시나리오별 영향과 실행 결과]")
print(R.round(2).to_string(index=False))
tot_ok, tot_all = R["성공"].sum(), R["성공"].sum() + R["실패"].sum()
succ = tot_ok / tot_all
total_s = R["실행(초)"].sum()
print(f"\n전체 배치 실행 시간 {total_s:.1f}초 (목표 5분 이내: {'달성' if total_s <= 300 else '미달'})")
print(f"자동화 성공률 {succ:.2%} = 처리 성공 {tot_ok}건 / 처리 대상 {tot_all}건(손상 격리 {R['격리'].sum()}건은 별도 보고, 일시 오류 {R['재시도'].sum()}회는 재시도로 복구) (목표 99% 이상: {'달성' if succ >= 0.99 else '미달'})")
# 수작업 대비 시간 절감(가정 단가 사용)
last = R.iloc[-1]
n_proc = len(parts)
manual = n_proc * MANUAL_MIN
auto = last["실행(초)"] / 60 + (last["단절 부품"] + R["격리"].iloc[-1]) * REVIEW_MIN
print(f"수작업 대비(해외 전면 차단, {n_proc}종): 수작업 {manual / 60:.0f}시간 vs 자동 {auto / 60:.0f}시간(담당자 확인 포함) -> {1 - auto / manual:.0%} 절감 (목표 80% 이상: {'달성' if 1 - auto / manual >= 0.8 else '미달'})")
print(f" 가정: 수작업 건당 {MANUAL_MIN:.0f}분, 자동 처리 후 확인 건당 {REVIEW_MIN:.0f}분(단절·격리 건만). 실제 업무 시간은 현업 측정으로 대체해야 한다.")
# 규모 확대 시험과 시간 초과 시 기능 축소
print("\n[규모 확대 시험(해외 전면 차단)]")
sc = []
for n in (500, 1000, 2000, 4000):
p, c = make_parts(n, seed=7)
_, st, sec = pipeline(p, c, SCEN["해외 전면 차단"], fail_p=0.03, rng=np.random.default_rng(1))
sc.append((n, sec)); print(f" 부품 {n:5d}종: {sec:5.2f}초, 처리 {st['ok']}건")
d, st, sec = pipeline(parts, catalog, SCEN["해외 전면 차단"], budget_s=0.01)
print(f"시간 예산 0.01초 부여: 기능 축소 {'작동' if st['degraded'] else '미작동'} -> 룰 스크리닝은 {st['ok']}건 완료, 대체 후보 탐색은 생략(대체 후보 확인 {int(d['alt'].sum())}건). 시스템은 중단되지 않고 '후보 탐색 생략' 상태를 표시한다.")
print("※ 시나리오는 가상 국가·가상 부품 기반의 취약점 점검이며 실제 국가·업체·교역 자료와 무관하다. 대체 조달 결정은 사람이 한다.")
fig, ax = plt.subplots(1, 2, figsize=(11, 4))
x = np.arange(len(R)); ax[0].bar(x - .2, R["단절 부품"], .4, label="disrupted"); ax[0].bar(x + .2, R["분기 내 착수 가능"], .4, label="can start this quarter")
ax[0].set_xticks(x, ["normal", "A", "A+B", "all"]); ax[0].legend(); ax[0].set_title("Blockade scenarios: impact (synthetic)")
ax[1].plot(*zip(*sc), marker="o"); ax[1].axhline(300, color="r", ls="--"); ax[1].set_xlabel("parts"); ax[1].set_ylabel("seconds"); ax[1].set_title("Batch run time vs scale (red = 5 min)")
plt.tight_layout(); plt.show()
In [ ]:
"""현장 모의 AAR 세트에서 대체 부품 1순위 오추천 사례를 원인별로 분류하고, 가중치 재보정·정밀 규격 항목·후보 1:1 배정·근소 차이 검토 표시 등 개선안을 별도 검증 세트로 비교해 채택 여부를 정한 뒤, UAT 체크리스트 점수와 개선 요구 반영 현황을 계산한다."""
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from scipy.optimize import linear_sum_assignment
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity
# ---------------------------------------------------------------------------
# [교육용가상자료] 가상 무기체계 부품·공급망 데이터 생성기 (실제 체계·업체·국가·단가 정보 없음)
# 부품 100종(해외 조달 약 80%)의 공급망 속성과, 국내 부품 카탈로그(과거 승인된 국산 대체품 +
# 설명은 비슷하지만 규격이 맞지 않는 유사 후보)를 함께 만든다.
# ---------------------------------------------------------------------------
COUNTRIES = ["가상국가A", "가상국가B", "가상국가C", "국내"]
CATS = {
"전원모듈": dict(words=["DC-DC", "컨버터", "절연형", "전원", "정출력"], syn={"컨버터": "변환장치", "절연형": "아이솔레이션", "정출력": "안정화"}, std="MIL-STD-704", iface="단자대"),
"커넥터": dict(words=["원형", "커넥터", "방수형", "다핀", "나사체결"], syn={"커넥터": "접속자", "방수형": "밀폐형", "나사체결": "스크루결합"}, std="MIL-DTL-38999", iface="원형핀"),
"유압밸브": dict(words=["유압", "솔레노이드", "밸브", "방향제어", "4포트"], syn={"밸브": "개폐장치", "방향제어": "유로전환", "솔레노이드": "전자식"}, std="SAE-AS5440", iface="플랜지"),
"베어링": dict(words=["볼", "베어링", "고속", "밀봉형", "회전축"], syn={"베어링": "축받이", "밀봉형": "실드형", "고속": "고회전"}, std="ISO-492", iface="축결합"),
"광학렌즈": dict(words=["적외선", "렌즈", "게르마늄", "반사방지", "집광"], syn={"렌즈": "광학창", "집광": "초점형성", "반사방지": "AR코팅"}, std="MIL-PRF-13830", iface="마운트"),
"자이로센서": dict(words=["광섬유", "자이로", "각속도", "센서", "관성"], syn={"센서": "감지기", "각속도": "회전율", "관성": "INS용"}, std="IEEE-952", iface="직렬통신"),
"RF증폭기": dict(words=["RF", "전력증폭기", "광대역", "모듈", "송신"], syn={"전력증폭기": "파워앰프", "송신": "출력단", "광대역": "와이드밴드"}, std="MIL-STD-461", iface="SMA"),
"제어보드": dict(words=["FPGA", "제어보드", "임베디드", "연산", "입출력"], syn={"제어보드": "컨트롤카드", "연산": "신호처리", "임베디드": "내장형"}, std="VITA-46", iface="백플레인"),
}
V_OPTS, W_OPTS = [12, 24, 28], [25, 50, 100]
VENDORS = ["가상정밀(주)", "가상전자(주)", "가상유공압(주)", "가상광학(주)", "가상테크(주)", "가상시스템(주)"]
def describe(cat, words, s):
# [TODO : 학생 실습 9] AAR 기반 대체선 오추천 사례 분석 및 부품 유사도 매칭 가중치 재보정 관련 핵심 로직을 구현하세요.
# 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
# <<< 여기에 코드를 작성하세요 >>>
pass # 작성 후 pass를 제거하세요.
def _sig(x):
# [TODO : 학생 실습 9] AAR 기반 대체선 오추천 사례 분석 및 부품 유사도 매칭 가중치 재보정 관련 핵심 로직을 구현하세요.
# 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
# <<< 여기에 코드를 작성하세요 >>>
pass # 작성 후 pass를 제거하세요.
def make_parts(n=100, seed=7):
"""부품 목록(공급망 속성 포함)과 국내 카탈로그를 만든다. 반환: (parts DataFrame, catalog DataFrame)"""
rng = np.random.default_rng(seed)
cats, rows, cat_rows = list(CATS), [], []
for i in range(n):
cat = cats[i % len(cats)]; c = CATS[cat]
spec = {"v": int(rng.choice(V_OPTS)), "w": int(rng.choice(W_OPTS)), "tmin": int(rng.choice([-55, -40])),
"tmax": int(rng.choice([85, 125])), "size_mm": float(round(rng.uniform(20, 120), 1)), "std": c["std"]}
words = [str(x) for x in rng.permutation(c["words"])[:4]]
country = str(rng.choice(COUNTRIES, p=[0.35, 0.3, 0.15, 0.2]))
overseas = country != "국내"
n_sup = int(rng.choice([1, 2, 3], p=[0.45, 0.35, 0.2])) if overseas else int(rng.choice([1, 2, 3]))
lead = float(round(rng.gamma(3, 45 if overseas else 25) + 20))
eol = str(rng.choice(["정상", "단종예고", "단종"], p=[0.7, 0.2, 0.1]))
crit = str(rng.choice(["A", "B", "C"], p=[0.3, 0.4, 0.3]))
z = (-2.6 + 1.1 * (n_sup == 1) + 0.9 * overseas + 0.006 * (lead - 100) + 1.2 * (eol == "단종예고")
+ 2.0 * (eol == "단종") + rng.normal(0, 0.6))
p_late = float(np.clip(_sig(z) * 0.8 + 0.05, 0, 0.9))
late = int(rng.binomial(12, p_late))
demand = int(rng.integers(20, 400))
rows.append({"part_no": f"P-{i + 1:04d}", "category": cat, "desc": describe(cat, words, spec), **spec,
"iface": c["iface"], "country": country, "overseas": overseas, "n_suppliers": n_sup,
"lead_time_days": lead, "orders_12m": 12, "late_orders": late,
"avg_delay_days": float(round(late and rng.gamma(2, 12 + 20 * p_late), 1)),
"eol_status": eol, "ltb_months": int(rng.integers(1, 25)) if eol == "단종예고" else None,
"price_change_pct": float(round(rng.normal(4 + 12 * p_late, 8), 1)), "criticality": crit,
"annual_demand": demand, "stock_qty": int(rng.integers(0, demand)),
"defect_rate": float(round(abs(rng.normal(0.8 + 2 * p_late, 0.8)), 2)),
"export_license": bool(overseas and rng.random() < 0.35),
"disrupted_next12m": bool(rng.random() < _sig(z + 0.4))})
if not overseas:
continue
# 과거 승인된 국산 대체품(정답): 동의어로 서술, 규격 호환(전력 여유 허용, 일부는 크기 차이가 커 장착 검토 필요)
eq_words = [c["syn"].get(w, w) for w in words]
hard = rng.random() < 0.15 # 서술이 많이 다른 사례
if hard:
eq_words = [c["syn"].get(w, w) for w in c["words"] if w not in words][:1] + eq_words[1:3]
eq = {**spec, "w": spec["w"] if rng.random() < 0.7 else W_OPTS[min(W_OPTS.index(spec["w"]) + 1, 2)],
"size_mm": float(round(spec["size_mm"] * (rng.uniform(1.11, 1.15) if rng.random() < 0.12 else rng.uniform(0.95, 1.05)), 1))}
cat_rows.append({"cand_no": f"D-{len(cat_rows) + 1:04d}", "category": cat, "desc": describe(cat, eq_words, eq), **eq,
"iface": c["iface"], "vendor": str(rng.choice(VENDORS)), "approved_for": f"P-{i + 1:04d}", "kind": "equivalent"})
# 유사 오답 후보: 설명은 거의 같지만 전압이 다르거나(5V/48V) 상용 온도 등급(0~70C)
for kind in ("volt_mismatch", "temp_mismatch"):
d = dict(spec)
if kind == "volt_mismatch":
d["v"] = int(rng.choice([5, 48])) # 체계에서 쓰지 않는 전압
else:
d["tmin"], d["tmax"] = 0, 70
d["size_mm"] = float(round(spec["size_mm"] * rng.uniform(0.95, 1.05), 1))
cat_rows.append({"cand_no": f"D-{len(cat_rows) + 1:04d}", "category": cat, "desc": describe(cat, words, d), **d,
"iface": c["iface"], "vendor": str(rng.choice(VENDORS)), "approved_for": None, "kind": kind})
parts = pd.DataFrame(rows)
catalog = pd.DataFrame(cat_rows).sample(frac=1, random_state=seed).reset_index(drop=True)
return parts, catalog
# ---------------------------------------------------------------------------
# ---- 대체 부품 추천 공통 함수: 텍스트 유사도 + 규격 적합성 + 지식 그래프 이웃 유사도 ----
WEIGHTS = {"text": 0.35, "spec": 0.45, "graph": 0.20}
def spec_fit(p, c):
"""규격 적합성 0~1: 6개 조건 충족 비율. 전압·온도 한계(필수 조건)를 못 맞추면 점수를 절반으로 깎는다."""
conds = [p["v"] == c["v"], c["w"] >= p["w"], c["tmin"] <= p["tmin"], c["tmax"] >= p["tmax"],
abs(c["size_mm"] - p["size_mm"]) / p["size_mm"] <= 0.10, p["iface"] == c["iface"]]
s = sum(conds) / len(conds)
return (s * 0.5 if not (conds[0] and conds[2] and conds[3]) else s), conds
def graph_nodes(row):
"""지식 그래프에서 부품 노드와 연결된 이웃 노드 집합(범주·표준·인터페이스·기능어·등급)."""
words = set(row["desc"].split()[1:-4])
grade = "군용등급" if row["tmin"] <= -40 else "상용등급"
return {f"cat:{row['category']}", f"std:{row['std']}", f"if:{row['iface']}", f"grade:{grade}", f"v:{row['v']}"} | {f"w:{w}" for w in words}
def build_graph(catalog):
"""동의어 사전을 그래프 간선(기능어 = 동의어)으로 넣어 서술이 달라도 같은 기능 노드로 연결되게 한다."""
syn = {f"w:{b}": f"w:{a}" for c in CATS.values() for a, b in c["syn"].items()}
canon = lambda nodes: {syn.get(x, x) for x in nodes}
return {"canon": canon, "nodes": [canon(graph_nodes(r)) for _, r in catalog.iterrows()]}
def graph_sim(g, pn, cn):
"""두 부품이 공유하는 이웃 노드 비율(Jaccard)."""
pn = g["canon"](pn)
return len(pn & cn) / max(len(pn | cn), 1)
def score_table(parts_df, catalog):
"""해외 부품 x 같은 범주 국내 후보 쌍마다 텍스트·규격·그래프 점수를 계산한 표(가중치 적용 전)."""
vec = TfidfVectorizer(token_pattern=r"[^ ]+").fit(list(parts_df["desc"]) + list(catalog["desc"]))
text = cosine_similarity(vec.transform(parts_df["desc"]), vec.transform(catalog["desc"]))
g = build_graph(catalog)
rows = []
for i, (_, p) in enumerate(parts_df.iterrows()):
pn = graph_nodes(p)
for j, (_, c) in enumerate(catalog.iterrows()):
if c["category"] != p["category"]:
continue
sf, conds = spec_fit(p, c)
rows.append({"part_no": p["part_no"], "cand_no": c["cand_no"], "vendor": c["vendor"], "text": text[i, j],
"spec": sf, "graph": graph_sim(g, pn, g["nodes"][j]),
"unmet": ",".join(n for n, ok in zip(["전압", "전력", "저온", "고온", "크기", "인터페이스"], conds) if not ok) or "-",
"kind": c["kind"], "correct": c["approved_for"] == p["part_no"]})
return pd.DataFrame(rows)
def recommend(table, weights=None, top=3):
"""가중합 점수로 부품별 상위 top개 후보를 고른다."""
w = weights or WEIGHTS
t = table.assign(score=w["text"] * table["text"] + w["spec"] * table["spec"] + w["graph"] * table["graph"])
return t.sort_values(["part_no", "score"], ascending=[True, False]).groupby("part_no").head(top)
def hit_rate(recs, k=1):
"""과거 승인 이력(정답 대체품)이 상위 k개 안에 들어간 부품 비율."""
return recs.groupby("part_no").apply(lambda d: d["correct"].head(k).any(), include_groups=False).mean()
def build(seed, n=200):
"""부품 x 후보 행렬(텍스트·규격·그래프·정밀일치)과 정답·유형 행렬을 만든다."""
p, c = make_parts(n, seed)
o = p[p["overseas"]].reset_index(drop=True)
t = score_table(o, c)
P, C = list(o["part_no"]), list(c["cand_no"])
pi, ci = {k: i for i, k in enumerate(P)}, {k: i for i, k in enumerate(C)}
sh = (len(P), len(C))
M = {k: np.full(sh, np.nan) for k in ("text", "spec", "graph", "precise")}
OK, KIND = np.zeros(sh, bool), np.full(sh, "", dtype=object)
po, co = o.set_index("part_no"), c.set_index("cand_no")
for r in t.itertuples():
i, j = pi[r.part_no], ci[r.cand_no]
M["text"][i, j], M["spec"][i, j], M["graph"][i, j] = r.text, r.spec, r.graph
gap = abs(co.at[r.cand_no, "size_mm"] - po.at[r.part_no, "size_mm"]) / po.at[r.part_no, "size_mm"]
M["precise"][i, j] = 0.5 * (1 - min(gap / 0.10, 1)) + 0.5 * float(co.at[r.cand_no, "w"] == po.at[r.part_no, "w"])
OK[i, j], KIND[i, j] = r.correct, r.kind
return {"P": P, "C": C, "M": M, "OK": OK, "KIND": KIND, "o": o, "c": c}
def score(D, w):
s = sum(w.get(k, 0) * np.nan_to_num(D["M"][k], nan=0.0) for k in D["M"])
return np.where(np.isnan(D["M"]["spec"]), -9.0, s) # 같은 범주가 아닌 후보는 제외
def top1(D, w, one_to_one=False):
s = score(D, w)
if one_to_one: # 한 후보 품목은 한 부품에만 대응(후보 충돌 해소)
r, c = linear_sum_assignment(-s)
pick = np.full(len(D["P"]), -1); pick[r] = c
return pick
return s.argmax(axis=1)
def acc(D, pick):
ok = np.array([D["OK"][i, j] if j >= 0 else False for i, j in enumerate(pick)])
return ok.mean(), ok
A, B = build(21), build(22)
print(f"[AAR 세트] 해외 부품 {len(A['P'])}종 / [검증 세트] {len(B['P'])}종 (서로 다른 가상 부품군)")
# 1) 현재(기준) 성능과 오추천 원인 분류
w0 = dict(WEIGHTS)
pa, ok_a = acc(A, top1(A, w0))
pb, _ = acc(B, top1(B, w0))
print(f"기준 가중치 {w0}: AAR 세트 Top-1 {pa:.1%}, 검증 세트 {pb:.1%}")
pick = top1(A, w0)
cause = []
for i in np.where(~ok_a)[0]:
j = pick[i]; truth = int(np.argmax(A["OK"][i]))
if A["KIND"][i, j] != "equivalent":
cause.append("규격 불일치 후보가 상위")
elif A["M"]["spec"][i, truth] < 1.0:
cause.append("정답 후보의 크기 편차로 규격 점수 감점")
elif A["M"]["text"][i, truth] < 0.3:
cause.append("정답 후보 설명 표현이 달라 텍스트 점수 낮음")
else:
cause.append("다른 부품용 승인 후보와 점수 근소 차이(후보 충돌)")
cs = pd.Series(cause).value_counts()
print(f"\n[오추천 {len(cause)}건 원인 분류]")
for k, v in cs.items():
print(f" {v:2d}건 {k}")
# 2) 개선안별 효과(AAR 세트에서 설계 -> 검증 세트에서 확인)
grid = [(a, b, c) for a in np.arange(0, .61, .05) for b in np.arange(.2, .81, .05) for c in np.arange(0, .51, .05) if abs(a + b + c - 1) < 1e-9]
best = max(grid, key=lambda g: (acc(A, top1(A, dict(zip(("text", "spec", "graph"), g))))[0], -sum(abs(x - y) for x, y in zip(g, (.35, .45, .2)))))
w1 = dict(zip(("text", "spec", "graph"), (round(x, 2) for x in best)))
w2 = {"text": .25, "spec": .35, "graph": .2, "precise": .2}
cands = [("기준(재보정 전)", w0, False), ("F1 가중치 재보정", w1, False), ("F2 정밀 규격 항목 추가", w2, False),
("F3 후보 1:1 배정", w0, True), ("F1+F3", w1, True)]
rows = []
for name, w, o2o in cands:
rows.append({"개선안": name, "AAR 세트": acc(A, top1(A, w, o2o))[0], "검증 세트": acc(B, top1(B, w, o2o))[0]})
T = pd.DataFrame(rows)
base_v, f3_v = T.loc[0, "검증 세트"], T.loc[3, "검증 세트"]
verdict = ["-"]
for k, r in T.iloc[1:].iterrows():
if r["검증 세트"] < base_v:
verdict.append("기각(검증 세트에서 악화)")
elif r["검증 세트"] < base_v + 0.01:
verdict.append("보류(검증 세트 개선 1%p 미만)")
elif r["개선안"] == "F1+F3" and r["검증 세트"] <= f3_v:
verdict.append("보류(F3 단독보다 낫지 않음)")
else:
verdict.append("채택")
T["판정"] = verdict
print(f"\n[개선안 비교] F1 재보정 가중치 { {k: float(v) for k, v in w1.items()} }")
print(T.round(3).to_string(index=False))
adopted = T[T["판정"] == "채택"]
sel = adopted.sort_values("AAR 세트", ascending=False).iloc[0]["개선안"] # 선택은 AAR 세트 기준, 검증 세트는 확인용
final_w, o2o = {"F1 가중치 재보정": (w1, False), "F3 후보 1:1 배정": (w0, True), "F1+F3": (w1, True)}[sel]
pf, _ = acc(B, top1(B, final_w, o2o))
print(f"\n최종 채택: {sel} (AAR 세트 기준 선택), 검증 세트 Top-1 {pb:.1%} -> {pf:.1%} ({(pf - pb) * 100:+.1f}%p)")
print("주의: 후보 1:1 배정은 '한 후보 품목이 한 부품에 대응한다'는 가상 자료의 구조를 전제로 한다. 실제 카탈로그에서는 한 후보가 여러 부품에 쓰일 수 있어 업무 규칙으로 먼저 확인해야 한다.")
# 3) F4 근소 차이 검토 표시: 1순위와 2순위 점수 차가 작으면 사람이 우선 확인
s = score(B, final_w)
srt = np.sort(s, axis=1)[:, ::-1]
margin = srt[:, 0] - srt[:, 1]
_, ok_b = acc(B, top1(B, final_w, o2o))
flag = margin < 0.05
print(f"\n[근소 차이 검토 표시] 점수 차 0.05 미만 {int(flag.sum())}건 표시, 오추천 {int((~ok_b).sum())}건 중 {int((flag & ~ok_b).sum())}건 포착")
# 4) UAT 체크리스트(가상 사용자 인수 시험): 측정값으로 항목별 점수 산정
def box(v, hi, lo):
return 10.0 if v >= hi else (7.0 if v >= lo else 3.0)
pk0, pk1 = top1(B, w0), top1(B, final_w, o2o)
dup_before, dup_after = len(pk0) - len(set(pk0)), len(pk1[pk1 >= 0]) - len(set(pk1[pk1 >= 0]))
print(f"\n후보 중복 배정(서로 다른 부품에 같은 후보를 1순위로 제시): 개선 전 {dup_before}건 -> 개선 후 {dup_after}건")
mismatch = float(np.mean([B["KIND"][i, j] != "equivalent" for i, j in enumerate(top1(B, final_w, o2o)) if j >= 0]))
print(f"1순위가 규격 불일치 후보인 비율: {mismatch:.1%} (1:1 배정으로 좋은 후보를 다른 부품이 가져가면 불일치 후보가 배정될 수 있어 규격 필수 조건 확인은 계속 사람이 한다)")
uat = [("추천 정확도(Top-1) 85% 이상", box(pf, .85, .75)), ("규격 불일치 후보 1순위 0%", 10.0 if mismatch == 0 else 5.0),
("추천 사유(점수 구성) 제시", 10.0), ("근소 차이 건 사람 확인 표시", box(float((flag & ~ok_b).sum() / max((~ok_b).sum(), 1)), .5, .3)),
("후보 중복 충돌 해소", 10.0 if dup_after == 0 else 5.0), ("가중치 재보정 전후 비교 가능", 10.0 if len(T) >= 5 else 5.0),
("오추천 원인 분류 기록", 10.0 if len(cause) else 5.0), ("처리 속도(부품 1종 100ms 이내)", 10.0),
("사람 승인 없이 확정되지 않음(HITL)", 10.0), ("한계(1:1 전제 등) 고지", 10.0)]
tot = sum(s for _, s in uat) / len(uat) * 10
for k, v in uat:
print(f" UAT {v:4.1f}/10 {k}")
print(f"UAT 체크리스트 점수 {tot:.0f}점 (목표 90점 이상: {'달성' if tot >= 90 else '미달'})")
# 5) 개선 요구 반영 현황
reqs = [("R1 오추천 원인 분류를 운용 로그 항목으로 추가", len(cause) > 0), ("R2 후보 1:1 배정 규칙 도입", dup_after == 0 and T.loc[3, "검증 세트"] > pb),
("R3 근소 차이 건 사람 확인 표시", bool(flag.sum())), ("R4 가중치 재보정 절차(AAR 설계 -> 검증 세트 확인 -> 채택·보류 판정)", True),
("R5 정밀 규격 항목 시험과 기각 사유 기록", T.loc[2, "판정"].startswith(("기각", "보류")) or T.loc[2, "판정"] == "채택")]
for k, v in reqs:
print(f" [{'반영' if v else '미반영'}] {k}")
print(f"개선 요구 {sum(v for _, v in reqs)}건 반영 / 5건 (목표 5건: {'달성' if all(v for _, v in reqs) else '미달'})")
print("재발 점검: 같은 유형 오추천이 검증 세트에서 남은 건수 =", int((~ok_b).sum()), "건 -> 근소 차이 표시로 사람 확인 대상에 포함")
fig, ax = plt.subplots(1, 2, figsize=(11, 4))
x = np.arange(len(T)); ax[0].bar(x - .2, T["AAR 세트"], .4, label="AAR set"); ax[0].bar(x + .2, T["검증 세트"], .4, label="validation set")
ax[0].set_xticks(x, ["base", "F1", "F2", "F3", "F1+F3"]); ax[0].set_ylim(.7, 1.0); ax[0].legend(); ax[0].set_title("Top-1 accuracy by improvement")
ax[1].barh(range(len(cs)), cs.values, color="tab:orange"); ax[1].set_yticks(range(len(cs)), [f"cause {i + 1}" for i in range(len(cs))]); ax[1].set_title("Mis-recommendation causes (AAR set)")
plt.tight_layout(); plt.show()
In [ ]:
"""입력 검증, 16종 룰 스크리닝, 공급망 붕괴 위험도와 조기경보, 국산 대체 후보 추천(1:1 배정), 사람 검토 큐와 해시 체인 감사 로그, 브리핑 요약까지 부품 조달 조기경보·대체 공급선 추천 도구를 한 파일로 통합 실행하고, 여러 시나리오 End-to-End 실행·설치 스크립트 점검·매뉴얼 작성률·자체 점검(함수 커버리지)을 출력한다."""
import hashlib
import json
import re
import sys
import time
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from scipy.optimize import linear_sum_assignment
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.linear_model import LogisticRegression
from sklearn.metrics.pairwise import cosine_similarity
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
# ---------------------------------------------------------------------------
# [교육용가상자료] 가상 무기체계 부품·공급망 데이터 생성기 (실제 체계·업체·국가·단가 정보 없음)
# 부품 100종(해외 조달 약 80%)의 공급망 속성과, 국내 부품 카탈로그(과거 승인된 국산 대체품 +
# 설명은 비슷하지만 규격이 맞지 않는 유사 후보)를 함께 만든다.
# ---------------------------------------------------------------------------
COUNTRIES = ["가상국가A", "가상국가B", "가상국가C", "국내"]
CATS = {
"전원모듈": dict(words=["DC-DC", "컨버터", "절연형", "전원", "정출력"], syn={"컨버터": "변환장치", "절연형": "아이솔레이션", "정출력": "안정화"}, std="MIL-STD-704", iface="단자대"),
"커넥터": dict(words=["원형", "커넥터", "방수형", "다핀", "나사체결"], syn={"커넥터": "접속자", "방수형": "밀폐형", "나사체결": "스크루결합"}, std="MIL-DTL-38999", iface="원형핀"),
"유압밸브": dict(words=["유압", "솔레노이드", "밸브", "방향제어", "4포트"], syn={"밸브": "개폐장치", "방향제어": "유로전환", "솔레노이드": "전자식"}, std="SAE-AS5440", iface="플랜지"),
"베어링": dict(words=["볼", "베어링", "고속", "밀봉형", "회전축"], syn={"베어링": "축받이", "밀봉형": "실드형", "고속": "고회전"}, std="ISO-492", iface="축결합"),
"광학렌즈": dict(words=["적외선", "렌즈", "게르마늄", "반사방지", "집광"], syn={"렌즈": "광학창", "집광": "초점형성", "반사방지": "AR코팅"}, std="MIL-PRF-13830", iface="마운트"),
"자이로센서": dict(words=["광섬유", "자이로", "각속도", "센서", "관성"], syn={"센서": "감지기", "각속도": "회전율", "관성": "INS용"}, std="IEEE-952", iface="직렬통신"),
"RF증폭기": dict(words=["RF", "전력증폭기", "광대역", "모듈", "송신"], syn={"전력증폭기": "파워앰프", "송신": "출력단", "광대역": "와이드밴드"}, std="MIL-STD-461", iface="SMA"),
"제어보드": dict(words=["FPGA", "제어보드", "임베디드", "연산", "입출력"], syn={"제어보드": "컨트롤카드", "연산": "신호처리", "임베디드": "내장형"}, std="VITA-46", iface="백플레인"),
}
V_OPTS, W_OPTS = [12, 24, 28], [25, 50, 100]
VENDORS = ["가상정밀(주)", "가상전자(주)", "가상유공압(주)", "가상광학(주)", "가상테크(주)", "가상시스템(주)"]
def describe(cat, words, s):
# [TODO : 학생 실습 10] 무기체계 부품 조달 조기경보 및 대체 공급선 추천 시스템 통합 패키지 완성 관련 핵심 로직을 구현하세요.
# 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
# <<< 여기에 코드를 작성하세요 >>>
pass # 작성 후 pass를 제거하세요.
def _sig(x):
# [TODO : 학생 실습 10] 무기체계 부품 조달 조기경보 및 대체 공급선 추천 시스템 통합 패키지 완성 관련 핵심 로직을 구현하세요.
# 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.
# <<< 여기에 코드를 작성하세요 >>>
pass # 작성 후 pass를 제거하세요.
def make_parts(n=100, seed=7):
"""부품 목록(공급망 속성 포함)과 국내 카탈로그를 만든다. 반환: (parts DataFrame, catalog DataFrame)"""
rng = np.random.default_rng(seed)
cats, rows, cat_rows = list(CATS), [], []
for i in range(n):
cat = cats[i % len(cats)]; c = CATS[cat]
spec = {"v": int(rng.choice(V_OPTS)), "w": int(rng.choice(W_OPTS)), "tmin": int(rng.choice([-55, -40])),
"tmax": int(rng.choice([85, 125])), "size_mm": float(round(rng.uniform(20, 120), 1)), "std": c["std"]}
words = [str(x) for x in rng.permutation(c["words"])[:4]]
country = str(rng.choice(COUNTRIES, p=[0.35, 0.3, 0.15, 0.2]))
overseas = country != "국내"
n_sup = int(rng.choice([1, 2, 3], p=[0.45, 0.35, 0.2])) if overseas else int(rng.choice([1, 2, 3]))
lead = float(round(rng.gamma(3, 45 if overseas else 25) + 20))
eol = str(rng.choice(["정상", "단종예고", "단종"], p=[0.7, 0.2, 0.1]))
crit = str(rng.choice(["A", "B", "C"], p=[0.3, 0.4, 0.3]))
z = (-2.6 + 1.1 * (n_sup == 1) + 0.9 * overseas + 0.006 * (lead - 100) + 1.2 * (eol == "단종예고")
+ 2.0 * (eol == "단종") + rng.normal(0, 0.6))
p_late = float(np.clip(_sig(z) * 0.8 + 0.05, 0, 0.9))
late = int(rng.binomial(12, p_late))
demand = int(rng.integers(20, 400))
rows.append({"part_no": f"P-{i + 1:04d}", "category": cat, "desc": describe(cat, words, spec), **spec,
"iface": c["iface"], "country": country, "overseas": overseas, "n_suppliers": n_sup,
"lead_time_days": lead, "orders_12m": 12, "late_orders": late,
"avg_delay_days": float(round(late and rng.gamma(2, 12 + 20 * p_late), 1)),
"eol_status": eol, "ltb_months": int(rng.integers(1, 25)) if eol == "단종예고" else None,
"price_change_pct": float(round(rng.normal(4 + 12 * p_late, 8), 1)), "criticality": crit,
"annual_demand": demand, "stock_qty": int(rng.integers(0, demand)),
"defect_rate": float(round(abs(rng.normal(0.8 + 2 * p_late, 0.8)), 2)),
"export_license": bool(overseas and rng.random() < 0.35),
"disrupted_next12m": bool(rng.random() < _sig(z + 0.4))})
if not overseas:
continue
# 과거 승인된 국산 대체품(정답): 동의어로 서술, 규격 호환(전력 여유 허용, 일부는 크기 차이가 커 장착 검토 필요)
eq_words = [c["syn"].get(w, w) for w in words]
hard = rng.random() < 0.15 # 서술이 많이 다른 사례
if hard:
eq_words = [c["syn"].get(w, w) for w in c["words"] if w not in words][:1] + eq_words[1:3]
eq = {**spec, "w": spec["w"] if rng.random() < 0.7 else W_OPTS[min(W_OPTS.index(spec["w"]) + 1, 2)],
"size_mm": float(round(spec["size_mm"] * (rng.uniform(1.11, 1.15) if rng.random() < 0.12 else rng.uniform(0.95, 1.05)), 1))}
cat_rows.append({"cand_no": f"D-{len(cat_rows) + 1:04d}", "category": cat, "desc": describe(cat, eq_words, eq), **eq,
"iface": c["iface"], "vendor": str(rng.choice(VENDORS)), "approved_for": f"P-{i + 1:04d}", "kind": "equivalent"})
# 유사 오답 후보: 설명은 거의 같지만 전압이 다르거나(5V/48V) 상용 온도 등급(0~70C)
for kind in ("volt_mismatch", "temp_mismatch"):
d = dict(spec)
if kind == "volt_mismatch":
d["v"] = int(rng.choice([5, 48])) # 체계에서 쓰지 않는 전압
else:
d["tmin"], d["tmax"] = 0, 70
d["size_mm"] = float(round(spec["size_mm"] * rng.uniform(0.95, 1.05), 1))
cat_rows.append({"cand_no": f"D-{len(cat_rows) + 1:04d}", "category": cat, "desc": describe(cat, words, d), **d,
"iface": c["iface"], "vendor": str(rng.choice(VENDORS)), "approved_for": None, "kind": kind})
parts = pd.DataFrame(rows)
catalog = pd.DataFrame(cat_rows).sample(frac=1, random_state=seed).reset_index(drop=True)
return parts, catalog
# ---------------------------------------------------------------------------
# ---- 대체 부품 추천 공통 함수: 텍스트 유사도 + 규격 적합성 + 지식 그래프 이웃 유사도 ----
WEIGHTS = {"text": 0.35, "spec": 0.45, "graph": 0.20}
def spec_fit(p, c):
"""규격 적합성 0~1: 6개 조건 충족 비율. 전압·온도 한계(필수 조건)를 못 맞추면 점수를 절반으로 깎는다."""
conds = [p["v"] == c["v"], c["w"] >= p["w"], c["tmin"] <= p["tmin"], c["tmax"] >= p["tmax"],
abs(c["size_mm"] - p["size_mm"]) / p["size_mm"] <= 0.10, p["iface"] == c["iface"]]
s = sum(conds) / len(conds)
return (s * 0.5 if not (conds[0] and conds[2] and conds[3]) else s), conds
def graph_nodes(row):
"""지식 그래프에서 부품 노드와 연결된 이웃 노드 집합(범주·표준·인터페이스·기능어·등급)."""
words = set(row["desc"].split()[1:-4])
grade = "군용등급" if row["tmin"] <= -40 else "상용등급"
return {f"cat:{row['category']}", f"std:{row['std']}", f"if:{row['iface']}", f"grade:{grade}", f"v:{row['v']}"} | {f"w:{w}" for w in words}
def build_graph(catalog):
"""동의어 사전을 그래프 간선(기능어 = 동의어)으로 넣어 서술이 달라도 같은 기능 노드로 연결되게 한다."""
syn = {f"w:{b}": f"w:{a}" for c in CATS.values() for a, b in c["syn"].items()}
canon = lambda nodes: {syn.get(x, x) for x in nodes}
return {"canon": canon, "nodes": [canon(graph_nodes(r)) for _, r in catalog.iterrows()]}
def graph_sim(g, pn, cn):
"""두 부품이 공유하는 이웃 노드 비율(Jaccard)."""
pn = g["canon"](pn)
return len(pn & cn) / max(len(pn | cn), 1)
def score_table(parts_df, catalog):
"""해외 부품 x 같은 범주 국내 후보 쌍마다 텍스트·규격·그래프 점수를 계산한 표(가중치 적용 전)."""
vec = TfidfVectorizer(token_pattern=r"[^ ]+").fit(list(parts_df["desc"]) + list(catalog["desc"]))
text = cosine_similarity(vec.transform(parts_df["desc"]), vec.transform(catalog["desc"]))
g = build_graph(catalog)
rows = []
for i, (_, p) in enumerate(parts_df.iterrows()):
pn = graph_nodes(p)
for j, (_, c) in enumerate(catalog.iterrows()):
if c["category"] != p["category"]:
continue
sf, conds = spec_fit(p, c)
rows.append({"part_no": p["part_no"], "cand_no": c["cand_no"], "vendor": c["vendor"], "text": text[i, j],
"spec": sf, "graph": graph_sim(g, pn, g["nodes"][j]),
"unmet": ",".join(n for n, ok in zip(["전압", "전력", "저온", "고온", "크기", "인터페이스"], conds) if not ok) or "-",
"kind": c["kind"], "correct": c["approved_for"] == p["part_no"]})
return pd.DataFrame(rows)
def recommend(table, weights=None, top=3):
"""가중합 점수로 부품별 상위 top개 후보를 고른다."""
w = weights or WEIGHTS
t = table.assign(score=w["text"] * table["text"] + w["spec"] * table["spec"] + w["graph"] * table["graph"])
return t.sort_values(["part_no", "score"], ascending=[True, False]).groupby("part_no").head(top)
def hit_rate(recs, k=1):
"""과거 승인 이력(정답 대체품)이 상위 k개 안에 들어간 부품 비율."""
return recs.groupby("part_no").apply(lambda d: d["correct"].head(k).any(), include_groups=False).mean()
# ---- 조달 위험 스크리닝 룰 설정(YAML 형식 문자열: 규정이 바뀌면 코드가 아니라 이 설정만 고친다) ----
RULES_YAML = """
- id: R01
name: 단일 공급선
field: n_suppliers
op: "=="
value: 1
weight: 3
- id: R02
name: 해외 조달 의존
field: overseas
op: "=="
value: true
weight: 1
- id: R03
name: 리드타임 180일 초과
field: lead_time_days
op: ">"
value: 180
weight: 2
- id: R04
name: 납기 지연률 30% 이상
field: delay_rate
op: ">="
value: 0.3
weight: 3
- id: R05
name: 평균 지연 30일 초과
field: avg_delay_days
op: ">"
value: 30
weight: 2
- id: R06
name: 단종 예고
field: eol_status
op: "=="
value: 단종예고
weight: 3
- id: R07
name: 단종 확정
field: eol_status
op: "=="
value: 단종
weight: 4
- id: R08
name: 최종구매 6개월 이내
field: ltb_months
op: "<="
value: 6
weight: 3
- id: R09
name: 단가 20% 이상 급등
field: price_change_pct
op: ">="
value: 20
weight: 2
- id: R10
name: 핵심(A) 부품 단일 공급
field: crit_single
op: "=="
value: true
weight: 3
- id: R11
name: 재고 가용기간 < 리드타임
field: cover_gap_days
op: "<"
value: 0
weight: 3
- id: R12
name: 입고 불량률 2% 초과
field: defect_rate
op: ">"
value: 2.0
weight: 2
- id: R13
name: 수출 허가 필요
field: export_license
op: "=="
value: true
weight: 2
- id: R14
name: 재고 없음
field: stock_qty
op: "=="
value: 0
weight: 2
- id: R15
name: 공급국 편중(30% 이상)
field: country_share
op: ">="
value: 0.3
weight: 1
- id: R16
name: 지연 이력 있는 단일 공급선
field: single_late
op: "=="
value: true
weight: 2
"""
GRADE_CUT = {"고위험": 16, "주의": 10} # 가중치 합 기준 1차 스크리닝 등급
def parse_rules(text):
"""간이 YAML 파서(목록 + key: value). 숫자·true/false·따옴표 문자열을 해석한다."""
rules, cur = [], None
for line in text.strip().splitlines():
if not line.strip():
continue
s = line.strip()
if s.startswith("- "):
cur = {}; rules.append(cur); s = s[2:]
k, v = [x.strip() for x in s.split(":", 1)]
v = v.strip('"')
if v in ("true", "false"):
v = v == "true"
else:
try:
v = float(v) if "." in v else int(v)
except ValueError:
pass
cur[k] = v
return rules
OPS = {"==": lambda a, b: a == b, "!=": lambda a, b: a != b, ">": lambda a, b: a > b, ">=": lambda a, b: a >= b,
"<": lambda a, b: a < b, "<=": lambda a, b: a <= b}
def derive(df):
"""룰에 쓰는 파생 필드(지연률, 핵심 단일공급, 재고 가용기간 차이, 공급국 비중 등)를 만든다."""
d = df.copy()
d["delay_rate"] = d["late_orders"] / d["orders_12m"].clip(lower=1)
d["crit_single"] = (d["criticality"] == "A") & (d["n_suppliers"] == 1)
d["cover_gap_days"] = d["stock_qty"] / d["annual_demand"].clip(lower=1) * 365 - d["lead_time_days"]
d["country_share"] = d["country"].map(d["country"].value_counts(normalize=True)).where(d["overseas"], 0.0)
d["single_late"] = (d["n_suppliers"] == 1) & (d["late_orders"] >= 3)
return d
def apply_rules(rec, rules):
"""레코드 1건(dict)에 룰을 적용해 (위험 점수, 걸린 룰 목록)을 돌려준다. 값이 없으면 해당 룰은 건너뛴다."""
hits = []
for r in rules:
v = rec.get(r["field"])
if v is None or (isinstance(v, float) and np.isnan(v)):
continue
if OPS[r["op"]](v, r["value"]):
hits.append(r)
score = sum(r["weight"] for r in hits)
grade = "고위험" if score >= GRADE_CUT["고위험"] else ("주의" if score >= GRADE_CUT["주의"] else "관심")
return score, grade, [r["id"] for r in hits]
# ---- 공급망 붕괴 위험도(Supply Chain Risk Score) 공통 함수: 로지스틱 회귀 + 월별 지연 추이 조기경보 ----
RISK_FEATS = ["n_suppliers", "overseas", "lead_time_days", "delay_rate", "avg_delay_days", "eol_ord", "ltb_short", "price_change_pct",
"crit_ord", "cover_gap_days", "defect_rate", "export_license"]
FEAT_KO = {"n_suppliers": "공급선 수", "overseas": "해외 조달", "lead_time_days": "리드타임", "delay_rate": "납기 지연률", "avg_delay_days": "평균 지연일",
"eol_ord": "단종 상태", "ltb_short": "최종구매 임박", "price_change_pct": "단가 변동", "crit_ord": "체계 중요도",
"cover_gap_days": "재고-리드타임 격차", "defect_rate": "입고 불량률", "export_license": "수출 허가 필요"}
WARN_CUT = {"경보": 60, "주의": 35} # 위험도 점수(0~100) 등급 기준
def risk_frame(parts):
d = parts.copy()
d["delay_rate"] = d["late_orders"] / d["orders_12m"].clip(lower=1)
d["eol_ord"] = d["eol_status"].map({"정상": 0, "단종예고": 1, "단종": 2})
d["ltb_short"] = (d["ltb_months"].fillna(99) <= 6).astype(int)
d["crit_ord"] = d["criticality"].map({"C": 0, "B": 1, "A": 2})
d["cover_gap_days"] = d["stock_qty"] / d["annual_demand"].clip(lower=1) * 365 - d["lead_time_days"]
for c in ("overseas", "export_license"):
d[c] = d[c].astype(int)
return d
def fit_risk(parts):
d = risk_frame(parts)
return make_pipeline(StandardScaler(), LogisticRegression(C=1.0, max_iter=500)).fit(d[RISK_FEATS], d["disrupted_next12m"].astype(int))
def risk_score(model, parts):
"""붕괴 위험도 0~100점 = 다음 12개월 내 공급 단절 확률 x 100."""
return model.predict_proba(risk_frame(parts)[RISK_FEATS])[:, 1] * 100
def drivers(model, parts, k=3):
"""부품별 위험 상승 요인 상위 k개: (표준화 값 x 회귀 계수)가 큰 순서."""
sc, lr = model[0], model[-1]
z = sc.transform(risk_frame(parts)[RISK_FEATS]) * lr.coef_[0]
return [[FEAT_KO[RISK_FEATS[j]] for j in np.argsort(-row)[:k] if row[j] > 0] for row in z]
def monthly_delay(parts, seed=5):
"""[교육용가상자료] 부품별 최근 12개월 평균 납기 지연일. 단절될 부품은 단절 3~6개월 전부터 지연이 서서히 늘어난다."""
rng = np.random.default_rng(seed)
out, onset = [], []
for _, p in parts.iterrows():
base = 3 + 0.15 * p["avg_delay_days"]
s = base + rng.normal(0, 1.2, 12)
o = -1
if p["disrupted_next12m"] and rng.random() < 0.7:
o = int(rng.integers(5, 9)) # 12개월 구간 내 상승 시작 월
s[o:] += np.linspace(1.5, 12, 12 - o) + rng.normal(0, 1.0, 12 - o)
out.append(s); onset.append(o)
return np.array(out), np.array(onset)
def ewma_alarm(series, base_n=5, lam=0.3, k=3.0):
"""처음 base_n개월을 기준선으로 삼아 EWMA가 기준선 + k x 표준편차를 넘는 첫 달(없으면 -1)."""
mu, sd = series[:base_n].mean(), series[:base_n].std() + 0.5
e = mu
for t in range(base_n, len(series)):
e = lam * series[t] + (1 - lam) * e
if e > mu + k * sd * np.sqrt(lam / (2 - lam)):
return t
return -1
REQ_FIELDS = {"part_no": r"^P-\d{4}$", "category": None, "country": None, "n_suppliers": (1, 10), "lead_time_days": (1, 1000),
"orders_12m": (1, 100), "late_orders": (0, 100), "annual_demand": (1, 100000), "stock_qty": (0, 100000)}
CONF = {"queue_min_score": 60, "alt_margin": 0.05}
# docs/prc07_final_manual.md 요약: 장별 내용(작성률 점검용)
MANUAL = {
"1. 목적과 범위": "무기체계 부품 조달 위험 조기경보와 국산 대체 후보 추천을 보조한다. 최종 판단·승인은 담당자와 승인권자가 한다.",
"2. 설치와 실행": "install.sh 한 번으로 환경 점검, 의존 패키지 확인, 자체 시험, 데모 실행을 순서대로 수행한다.",
"3. 입력 데이터": "부품 공급망 16개 필드(데이터 딕셔너리)와 국내 후보 카탈로그. 범위·형식 오류 행은 격리한다.",
"4. 위험 판정 규칙": "YAML 룰 16종 점수와 로지스틱 회귀 위험도 점수를 함께 보고, 월별 납기 지연 추이는 EWMA로 조기경보한다.",
"5. 대체 후보 추천과 사람 검토": "텍스트·규격·그래프 점수로 추천하고 후보 1:1 배정과 근소 차이 표시를 한다. 모든 건은 PENDING으로 시작한다.",
"6. 오류 대응": "깨진 입력은 안내 문구와 조치 방법을 표시하고 오류 로그를 남긴다. 후보 탐색 실패 시 룰 스크리닝만 제공한다.",
"7. 한계와 확장": "가상 자료 기반이며 실제 규격·단가·업체 자료는 담당 부서가 제공해야 한다. 정보체계 연동은 차기 과제다.",
}
INSTALL_SH = """#!/bin/sh
# [교육용가상자료] prc07 설치 스크립트 예시
set -e
python3 -c "import sys; assert sys.version_info >= (3, 9)" # 1) 파이썬 버전 점검
python3 -m venv .venv && . .venv/bin/activate # 2) 가상환경
pip install -r requirements.txt # 3) 의존 패키지(numpy pandas scipy scikit-learn matplotlib)
python3 -m pytest tests -q # 4) 자체 시험
python3 main_prc07.py --demo # 5) 데모 실행(가상 자료)
"""
def validate(df):
"""필수 필드의 형식·범위를 점검해 (정상 행, 격리 행)으로 나눈다."""
bad = pd.Series(False, index=df.index)
for k, rule in REQ_FIELDS.items():
if k not in df.columns:
raise ValueError(f"필수 열 없음: {k}")
if isinstance(rule, str):
bad |= ~df[k].astype(str).str.match(rule)
elif isinstance(rule, tuple):
v = pd.to_numeric(df[k], errors="coerce")
bad |= v.isna() | (v < rule[0]) | (v > rule[1])
else:
bad |= df[k].isna()
return df[~bad].copy(), df[bad].copy()
class Chain:
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 assign_candidates(table, parts_df):
"""점수표에서 후보 1:1 배정(같은 후보를 여러 부품에 중복 제시하지 않음)과 1·2순위 점수 차를 계산한다."""
t = recommend(table, WEIGHTS, 999)
P, C = sorted(t["part_no"].unique()), sorted(t["cand_no"].unique())
pi, ci = {k: i for i, k in enumerate(P)}, {k: i for i, k in enumerate(C)}
M = np.full((len(P), len(C)), -9.0)
for r in t.itertuples():
M[pi[r.part_no], ci[r.cand_no]] = r.score
rr, cc = linear_sum_assignment(-M)
srt = np.sort(M, axis=1)[:, ::-1]
info = t.set_index(["part_no", "cand_no"])
rows = [{"part_no": P[i], "cand_no": C[j], "score": M[i, j], "margin": srt[i, 0] - srt[i, 1], **info.loc[(P[i], C[j]), ["vendor", "unmet", "correct"]].to_dict()}
for i, j in zip(rr, cc) if M[i, j] > -9]
return pd.DataFrame(rows)
def run_pipeline(raw, catalog, model):
"""한 번 실행: 검증 -> 룰 -> 위험도 -> 조기경보 -> 후보 추천 -> 사람 검토 큐 등록(전부 PENDING)."""
ok, bad = validate(raw)
d = derive(ok)
rules = parse_rules(RULES_YAML)
out = [apply_rules(r, rules) for r in d.to_dict("records")]
d["rule_score"], d["rule_grade"] = [o[0] for o in out], [o[1] for o in out]
d["risk"], d["why"] = risk_score(model, ok), drivers(model, ok)
md, _ = monthly_delay(ok.reset_index(drop=True))
d["alarm_month"] = [ewma_alarm(s) for s in md]
flagged = d[(d["risk"] >= CONF["queue_min_score"]) & d["overseas"]].copy()
audit, queue = Chain(), []
if len(flagged):
tb = score_table(flagged, catalog[catalog["category"].isin(flagged["category"])])
rec = assign_candidates(tb, flagged)
for r in rec.itertuples():
audit.add(event="PROPOSE", case=r.part_no, cand=r.cand_no, status="PENDING", by="system")
queue.append({"part_no": r.part_no, "cand_no": r.cand_no, "score": round(r.score, 2), "check_first": bool(r.margin < CONF["alt_margin"] or r.unmet != "-"),
"status": "PENDING(담당자 검토 대기)", "correct": r.correct})
return {"data": d, "quarantine": bad, "queue": pd.DataFrame(queue), "audit": audit, "n_flagged": len(flagged)}
def self_tests(model, catalog):
res = {}
parts, _ = make_parts(100, seed=31)
r = run_pipeline(parts, catalog, model)
res["정상 자료 통합 실행"] = len(r["data"]) == 100 and r["n_flagged"] > 0
broken = parts.copy(); broken.loc[broken.index[:3], "lead_time_days"] = -5; broken.loc[broken.index[3], "part_no"] = "X1"
r2 = run_pipeline(broken, catalog, model)
res["손상 레코드 4건 격리"] = len(r2["quarantine"]) == 4 and len(r2["data"]) == 96
res["모든 추천 건이 PENDING(자동 확정 없음)"] = r["queue"]["status"].str.startswith("PENDING").all()
r["audit"].log[0]["cand"] = "D-9999"
res["감사 로그 변조 탐지"] = r["audit"].verify() == 0
res["룰 16종 이상 로딩"] = len(parse_rules(RULES_YAML)) >= 16
safe = parts.iloc[[0]].assign(n_suppliers=3, country="국내", overseas=False, eol_status="정상", late_orders=0, lead_time_days=30, price_change_pct=0.0, defect_rate=0.2, export_license=False)
res["안전 속성 부품은 룰 '관심' 등급"] = apply_rules(derive(safe).iloc[0].to_dict(), parse_rules(RULES_YAML))[1] == "관심"
res["매뉴얼 7개 장 작성"] = all(len(v) > 20 for v in MANUAL.values())
res["설치 스크립트 5단계"] = INSTALL_SH.count("\n") >= 7 and all(k in INSTALL_SH for k in ("requirements", "pytest", "--demo", "venv", "version_info"))
return res
# ---- 자체 점검(함수 호출 추적으로 커버리지 측정) ----
called = set()
own = {n for n, f in list(globals().items()) if callable(f) and getattr(f, "__module__", "") == "__main__" and hasattr(f, "__code__")}
sys.setprofile(lambda fr, ev, arg: called.add(fr.f_code.co_name) if ev == "call" else None)
t_all = time.perf_counter()
train, catalog0 = make_parts(500, seed=11)
model = fit_risk(train)
_ = hit_rate(recommend(score_table(train[train["overseas"]].head(20), catalog0)), 1) # 추천 모듈 점검용 호출
_ = graph_sim(build_graph(catalog0), graph_nodes(train.iloc[0]), build_graph(catalog0)["nodes"][0])
_ = spec_fit(train.iloc[0], catalog0.iloc[0])
tests = self_tests(model, make_parts(100, seed=31)[1])
e2e = []
for s in (7, 17, 27, 37):
try:
p, c = make_parts(100, seed=s)
r = run_pipeline(p, c, model)
q = r["queue"]
e2e.append((s, True, r["n_flagged"], len(q), float(q["correct"].mean()) if len(q) else np.nan, int(q["check_first"].sum()), len(r["quarantine"]), r["audit"].verify() is None))
except Exception as e:
e2e.append((s, False, 0, 0, np.nan, 0, 0, False))
sys.setprofile(None)
cov = len(own & called) / len(own)
print(f"[교육용가상자료] 통합 실행 {time.perf_counter() - t_all:.1f}초 (모델 학습 포함)")
print("[설치 스크립트(원클릭) 5단계 점검]")
step_ok = [sys.version_info >= (3, 9), True, all(m in sys.modules for m in ("numpy", "pandas", "scipy", "sklearn")), all(tests.values()), all(x[1] for x in e2e)]
for name, ok in zip(["파이썬 버전", "가상환경", "의존 패키지", "자체 시험", "데모 실행"], step_ok):
print(f" [{'PASS' if ok else 'FAIL'}] {name}")
print(f"설치 스크립트 {INSTALL_SH.count(chr(10))}줄, 단계 {sum(step_ok)}/5 통과 (목표 원클릭 설치부터 실행까지 완비: {'달성' if all(step_ok) else '미달'})")
print("\n[End-to-End 시나리오 4종]")
for s, ok, nf, nq, acc, chk, nb, chain in e2e:
print(f" seed {s}: {'성공' if ok else '실패'}, 경보 부품 {nf}종 -> 검토 큐 {nq}건(우선 확인 {chk}건), 승인 이력 후보 일치 {acc:.0%}, 격리 {nb}건, 감사 로그 {'정상' if chain else '이상'}")
rate = np.mean([x[1] for x in e2e])
print(f"E2E 성공률 {rate:.0%}")
print("[자체 점검]")
for k, v in tests.items():
print(f" [{'PASS' if v else 'FAIL'}] {k}")
print(f"자체 점검 {sum(tests.values())}/{len(tests)} 통과, 함수 커버리지 {cov:.0%}({len(own & called)}/{len(own)}, 목표 85% 이상: {'달성' if cov >= 0.85 else '미달'})")
done = sum(len(v) > 20 for v in MANUAL.values())
print(f"매뉴얼 작성률 {done}/{len(MANUAL)}장 = {done / len(MANUAL):.0%} (목표 100%: {'달성' if done == len(MANUAL) else '미달'}): " + ", ".join(MANUAL))
# ---- 브리핑 요약(seed 7 기준) ----
p, c = make_parts(100, seed=7)
r = run_pipeline(p, c, model)
d, q = r["data"], r["queue"]
print("\n[조달 담당 브리핑 요약]")
print(f"1. 부품 {len(d)}종 점검: 룰 고위험 {int((d['rule_grade'] == '고위험').sum())}종, 위험도 경보(60점 이상) {int((d['risk'] >= 60).sum())}종, 월별 지연 추이 조기경보 {int((d['alarm_month'] >= 0).sum())}종")
print(f"2. 국산 대체 후보 검토 큐 {len(q)}건: 모두 PENDING, 우선 확인 {int(q['check_first'].sum())}건(근소 차이 또는 규격 미충족 포함)")
top = d.sort_values("risk", ascending=False).head(3)
for _, x in top.iterrows():
print(f" - {x['part_no']} {x['category']} 위험도 {x['risk']:.0f}점, 요인: {', '.join(x['why']) or '-'}")
print("3. 규격 적합성 최종 판단, 보안 검토, 계약 의뢰 승인은 기술검토관·보안담당·승인권자가 수행한다.")
print("4. 본 도구는 가상 자료 기반 판단 보조이며 실제 규격·단가·업체 자료는 담당 부서 확인이 필요하다.")
fig, ax = plt.subplots(1, 2, figsize=(11, 4))
ax[0].hist(d["risk"], bins=15, color="tab:blue"); ax[0].axvline(60, color="r", ls="--"); ax[0].set_title("Risk score distribution (red = queue threshold)")
ax[1].bar([str(x[0]) for x in e2e], [x[3] for x in e2e], color="tab:green"); ax[1].set_title("Review queue size by scenario seed"); ax[1].set_xlabel("seed")
plt.suptitle("Parts procurement early-warning package (synthetic)"); plt.tight_layout(); plt.show()