3323 lines
206 KiB
Markdown
3323 lines
206 KiB
Markdown
# PRC07 정답 코드 - 데이터 기반 부품 국산화 후보 자동추천 Tool
|
||
|
||
> [교육용가상자료] 모든 데이터는 코드 안에서 난수로 만든 가상 자료이며 실제 부대·장비·인원·작전 정보와 무관합니다.
|
||
|
||
## 실행 환경
|
||
|
||
- Python 3.10 이상, numpy, pandas, scipy, scikit-learn, matplotlib
|
||
- 미션별 코드는 각각 한 파일로 독립 실행됩니다. 외부 파일 없이 코드 안에서 가상 데이터를 생성합니다.
|
||
- 그래프의 글자는 한글 글꼴이 없는 실행 환경에서도 깨지지 않도록 영문으로 표기했습니다. 표·출력 결과는 한글입니다.
|
||
- 난수 시드를 고정해 두어 같은 코드를 실행하면 같은 결과가 나옵니다.
|
||
|
||
## 미션 1. 무기체계 핵심 부품 공급망(단종 부품, 해외 의존도, 리드타임) 데이터셋 스키마 정의
|
||
|
||
- 난이도: 초급 / 예상 소요시간: 1.5시간
|
||
- 코드 설명: 무기체계 핵심 부품 공급망(단종 부품, 해외 의존도, 리드타임) 데이터셋 스키마를 정의·검증하고, 국산화 후보 선정 업무를 AS-IS/TO-BE 단계로 분해해 병목 구간을 도출한다.
|
||
|
||
```python
|
||
"""무기체계 핵심 부품 공급망(단종 부품, 해외 의존도, 리드타임) 데이터셋 스키마를 정의·검증하고, 국산화 후보 선정 업무를 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):
|
||
return f"{cat} {' '.join(words)} {s['v']}V {s['w']}W {s['tmin']}~{s['tmax']}C {s['std']}"
|
||
|
||
|
||
def _sig(x):
|
||
return 1 / (1 + np.exp(-x))
|
||
|
||
|
||
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()
|
||
```
|
||
|
||
실행 결과 예시
|
||
|
||
```text
|
||
[스키마 검증] 부품 100건 중 통과 100건, 오류 0건 / 필드 16개
|
||
해외 의존 85건, 단일 공급선 42건, 단종예고·단종 37건, 평균 리드타임 145일
|
||
|
||
[오류 주입 결과]
|
||
- part_no: 형식 불일치 'P12'
|
||
- country: 허용되지 않은 값 '미상'
|
||
- n_suppliers: 자료형 오류(str)
|
||
- lead_time_days: 최소값 1 미만
|
||
- late_orders: 발주 건수보다 지연 건수가 많음
|
||
- ltb_months: 단종예고 부품은 LTB 개월 필수
|
||
|
||
[AS-IS / TO-BE 업무 단계 비교]
|
||
단계 작업(일) 대기(일) AS-IS(일) TO-BE 방식 TO-BE(일) 병목
|
||
01. 체계 부품목록(BOM) 수집 2.0 1.0 3.0 유지(사람 판단) 3.0
|
||
02. 공급업체 자료 요청·회신 3.9 8.5 12.4 표준 서식 자동 수집 3.7 병목
|
||
03. 단종(DMSMS) 공고 수집·확인 2.1 6.6 8.7 공고 자동 파싱(미션 2) 1.7 병목
|
||
04. 조달 이력·납기 지연 수작업 집계 5.9 2.0 7.9 룰 엔진 자동 집계(미션 3) 0.8
|
||
05. 해외 의존도 분류 2.4 1.1 3.5 자동 분류(미션 3) 0.4
|
||
06. 위험 부품 선정 회의 1.0 7.2 8.3 유지(사람 판단) 8.3 병목
|
||
07. 국내 업체 탐색(전화·자료) 5.3 7.5 12.8 대체품 자동 추천(미션 4) 3.8 병목
|
||
08. 규격 비교표 작성 4.7 2.0 6.7 규격 자동 대조(미션 4) 1.3
|
||
09. 기술 검토 의뢰 1.1 9.6 10.7 유지(사람 판단) 10.7 병목
|
||
10. 국산화 후보 보고서 작성 3.1 1.2 4.3 보고서 초안 자동 생성(미션 10) 1.7
|
||
11. 결재(승인) 0.9 5.7 6.6 유지(사람 판단) 6.6 병목
|
||
12. 계약 의뢰 준비 2.1 2.1 4.1 유지(사람 판단) 4.1
|
||
|
||
업무 단계 12개(목표 10개 이상: 달성), 병목 6개소(목표 3개소 이상: 달성)
|
||
후보 선정 1건 소요: AS-IS 89.1일 -> TO-BE 46.3일 (48% 단축 예상). 결재·기술 검토·회의는 사람이 수행한다.
|
||
|
||
[입출력 명세] 입력: 부품 공급망 레코드(위 16개 필드) / 출력: 위험 등급, 국산화 후보 Top-3, 추천 근거, 결재 이력
|
||
```
|
||
|
||
## 미션 2. 국내외 공급업체 데이터 정제, 부품 단종(DMSMS) 공고 파싱 전처리 모듈 구현
|
||
|
||
- 난이도: 초급~중급 / 예상 소요시간: 2.0시간
|
||
- 코드 설명: 국내외 공급업체 자료(CSV·엑셀 내보내기 TSV·JSON)를 하나의 표준 스키마로 정제하고, 문서마다 서식이 다른 부품 단종(DMSMS) 공고문 텍스트에서 부품번호·단종일·최종구매(LTB) 기한·대체품 정보를 파싱하는 전처리 모듈을 구현한다.
|
||
|
||
```python
|
||
"""국내외 공급업체 자료(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):
|
||
s = re.sub(r"\(주\)|㈜|주식회사|\bInc\.?|\bCo\.?|\bGmbH", "", str(s), flags=re.I)
|
||
return re.sub(r"[\s.,]+", " ", s).strip().upper()
|
||
|
||
|
||
def norm_lead(v):
|
||
if isinstance(v, dict):
|
||
return int(v["value"] * UNIT_DAYS[v["unit"]])
|
||
m = re.match(r"^\s*(\d+)\s*(일|주|개월|년)?\s*$", str(v))
|
||
if not m:
|
||
raise ValueError(f"리드타임 해석 불가: {v}")
|
||
return int(m.group(1)) * UNIT_DAYS.get(m.group(2) or "일", 1)
|
||
|
||
|
||
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)} (코드 수정 없이 설정만 변경)")
|
||
```
|
||
|
||
실행 결과 예시
|
||
|
||
```text
|
||
[CSV ] 6행 중 6행 성공
|
||
[TSV ] 5행 중 5행 성공
|
||
[JSON] 3행 중 3행 성공
|
||
[공문 텍스트] 단종 공고 5건 중 5건 성공
|
||
|
||
전체 파싱 성공률 100% (19/19) - 목표 100%: 달성
|
||
|
||
[정제된 공급업체 자료] 14행 -> 중복 제거 후 13행, 업체 9곳
|
||
vendor country part_no lead_days last_trade source
|
||
가상정밀 국내 P-0001 45 2026-03-02 csv
|
||
가상정밀 국내 P-0002 56 2026-03-20 json
|
||
VIRTUAL PARTS 가상국가A P-0003 180 2026-01-10 csv
|
||
가상유공압 국내 P-0003 60 2026-02-27 csv
|
||
가상전자 국내 P-0004 60 2026-03-15 csv
|
||
SAMPLE OPTICS 가상국가B P-0005 210 2026-01-05 csv
|
||
가상광학 국내 P-0005 75 2026-02-11 tsv
|
||
MODEL SENSOR 가상국가C P-0006 365 2025-11-03 tsv
|
||
가상테크 국내 P-0008 90 2026-03-05 tsv
|
||
VIRTUAL PARTS 가상국가A P-0011 180 2025-12-30 csv
|
||
SAMPLE OPTICS 가상국가B P-0013 200 2026-01-20 tsv
|
||
MODEL SENSOR 가상국가C P-0014 150 2026-01-09 json
|
||
가상시스템 국내 P-0016 90 2026-02-01 json
|
||
|
||
[단종 공고 파싱 결과]
|
||
part_no eol_date ltb_date replacement
|
||
P-0003 2027-03-31 2026-12-31 없음
|
||
P-0011 2027-06-30 2027-01-15 P-0011A
|
||
P-0013 2026-09-30 2026-06-30 협의 필요
|
||
P-0006 2026-12-31 2026-10-31 P-0006-R
|
||
P-0020 2027-02-28 2026-11-30 없음
|
||
|
||
새 양식(열 이름 변경) 1차 시도: 성공 0 / 실패 1 -> '필수 열 없음: vendor'
|
||
별칭 사전 갱신 후: 성공 1 / 실패 0 (코드 수정 없이 설정만 변경)
|
||
```
|
||
|
||
## 미션 3. 공급선 단일화 및 납기 지연 이력 기반 1차 조달 위험 스크리닝 알고리즘 구현
|
||
|
||
- 난이도: 중급 / 예상 소요시간: 2.5시간
|
||
- 코드 설명: 공급선 단일화·납기 지연 이력 등 16종 조달 위험 룰(YAML 설정)을 코딩해 부품별 1차 스크리닝 등급을 산출하고, 룰 처리 지연시간·과거 단절 이력 대비 탐지력·룰별 적중 현황·규정 변경 시 설정만 바꾸는 확장성을 확인한다.
|
||
|
||
```python
|
||
"""공급선 단일화·납기 지연 이력 등 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):
|
||
return f"{cat} {' '.join(words)} {s['v']}V {s['w']}W {s['tmin']}~{s['tmax']}C {s['std']}"
|
||
|
||
|
||
def _sig(x):
|
||
return 1 / (1 + np.exp(-x))
|
||
|
||
|
||
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()
|
||
```
|
||
|
||
실행 결과 예시
|
||
|
||
```text
|
||
[룰 설정] 검증 룰 16종(목표 15종 이상: 달성), 모든 룰에 가상 지침 조항 연결 True
|
||
룰 처리 지연시간: 평균 0.012ms, 최대 0.043ms (목표 50ms 이하: 달성)
|
||
|
||
[1차 스크리닝 등급 분포]
|
||
부품수 실제단절률
|
||
grade
|
||
고위험 20 0.60
|
||
주의 38 0.39
|
||
관심 42 0.29
|
||
고위험·주의 선별: 실제 단절 부품 39건 중 27건 포착(재현율 69%), 선별 58건 중 정밀도 47%
|
||
|
||
[룰별 적중 현황]
|
||
룰 이름 가중치 적중 근거 조항
|
||
R01 단일 공급선 3 42 가상지침 3조1항(공급선 다원화)
|
||
R02 해외 조달 의존 1 85 가상지침 3조2항(해외 의존 관리)
|
||
R03 리드타임 180일 초과 2 27 가상지침 4조1항(리드타임 관리)
|
||
R04 납기 지연률 30% 이상 3 49 가상지침 4조2항(납기 준수)
|
||
R05 평균 지연 30일 초과 2 64 가상지침 4조2항(납기 준수)
|
||
R06 단종 예고 3 26 가상지침 5조1항(단종 관리)
|
||
R07 단종 확정 4 11 가상지침 5조1항(단종 관리)
|
||
R08 최종구매 6개월 이내 3 6 가상지침 5조2항(최종구매 판단)
|
||
R09 단가 20% 이상 급등 2 8 가상지침 6조1항(가격 변동 점검)
|
||
R10 핵심(A) 부품 단일 공급 3 14 가상지침 3조3항(핵심 부품 관리)
|
||
R11 재고 가용기간 < 리드타임 3 40 가상지침 4조3항(재고 확보)
|
||
R12 입고 불량률 2% 초과 2 24 가상지침 7조1항(품질 관리)
|
||
R13 수출 허가 필요 2 35 가상지침 3조4항(수출통제 확인)
|
||
R14 재고 없음 2 1 가상지침 4조3항(재고 확보)
|
||
R15 공급국 편중(30% 이상) 1 70 가상지침 3조2항(해외 의존 관리)
|
||
R16 지연 이력 있는 단일 공급선 2 35 가상지침 3조1항(공급선 다원화)
|
||
|
||
[고위험 상위 5건과 근거]
|
||
P-0087 RF증폭기 점수 26 고위험 (적중 룰 11종): 단일 공급선, 해외 조달 의존, 납기 지연률 30% 이상, 평균 지연 30일 초과, 단종 예고, 최종구매 6...
|
||
P-0028 베어링 점수 25 고위험 (적중 룰 11종): 단일 공급선, 해외 조달 의존, 리드타임 180일 초과, 납기 지연률 30% 이상, 평균 지연 30일 초과,...
|
||
P-0066 커넥터 점수 25 고위험 (적중 룰 11종): 단일 공급선, 해외 조달 의존, 리드타임 180일 초과, 납기 지연률 30% 이상, 평균 지연 30일 초과,...
|
||
P-0033 전원모듈 점수 24 고위험 (적중 룰 11종): 단일 공급선, 해외 조달 의존, 리드타임 180일 초과, 납기 지연률 30% 이상, 평균 지연 30일 초과,...
|
||
P-0077 광학렌즈 점수 24 고위험 (적중 룰 10종): 단일 공급선, 해외 조달 의존, 납기 지연률 30% 이상, 평균 지연 30일 초과, 단종 확정, 핵심(A) ...
|
||
|
||
[설정 변경] R03 리드타임 기준 180일 -> 120일: 점수가 바뀐 부품 27건, 등급이 바뀐 부품 8건 (코드 수정 없이 YAML만 변경)
|
||
처리 후 사람 검토: 스크리닝은 1차 선별이며, 최종 위험 판단과 조치는 조달 담당자가 결정한다.
|
||
```
|
||
|
||
## 미션 4. NLP 및 지식 그래프 기반 대체 가능 호환 부품(Alternative Parts) 자동 추천 모델 고도화
|
||
|
||
- 난이도: 중급~고급 / 예상 소요시간: 3.0시간
|
||
- 코드 설명: 부품 설명문의 TF-IDF 텍스트 유사도, 규격 적합성, 동의어를 연결한 지식 그래프 이웃 유사도를 결합해 해외 부품별 국산 대체 후보 Top-3를 추천하고, 과거 승인 이력 기준 정확도·요소별 기여·오추천 유형·추천 사유 문장을 확인한다.
|
||
|
||
```python
|
||
"""부품 설명문의 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):
|
||
return f"{cat} {' '.join(words)} {s['v']}V {s['w']}W {s['tmin']}~{s['tmax']}C {s['std']}"
|
||
|
||
|
||
def _sig(x):
|
||
return 1 / (1 + np.exp(-x))
|
||
|
||
|
||
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()
|
||
```
|
||
|
||
실행 결과 예시
|
||
|
||
```text
|
||
[데이터] 해외 조달 부품 85종, 국내 후보 카탈로그 255건(승인 이력 있는 대체품 85건, 규격 불일치 유사 후보 170건)
|
||
추천 처리: 부품 1종당 14.0ms (후보 쌍 2745개 점수 계산 포함)
|
||
|
||
[요소별 기여(과거 승인 대체품을 상위에 올렸는가)]
|
||
방식 Top-1 정확도 Top-3 정확도 1순위가 규격불일치 후보인 비율
|
||
텍스트 유사도만 0.000 0.000 1.000
|
||
규격 적합성만 0.812 1.000 0.000
|
||
그래프만 0.553 0.741 0.047
|
||
텍스트+그래프 0.000 0.082 1.000
|
||
텍스트+규격 0.871 0.988 0.000
|
||
세 신호 결합(운용) 0.894 0.976 0.012
|
||
|
||
운용 방식 Top-1 정확도 89.4%, Top-3 정확도 97.6% (목표 85% 이상: 달성)
|
||
|
||
[1순위 오추천 9건 분석]
|
||
P-0009: 추천 D-0187(점수 0.77, equivalent) / 정답 D-0016(규격 미충족 -) -> 다른 부품용으로 승인된 같은 범주 대체품이 규격도 충족 -> 승인 이력상 오답, 실무상 검토 후보
|
||
P-0028: 추천 D-0046(점수 0.76, equivalent) / 정답 D-0070(규격 미충족 크기) -> 다른 부품용으로 승인된 같은 범주 대체품이 규격도 충족 -> 승인 이력상 오답, 실무상 검토 후보
|
||
P-0032: 추천 D-0034(점수 0.82, equivalent) / 정답 D-0082(규격 미충족 크기) -> 다른 부품용으로 승인된 같은 범주 대체품이 규격도 충족 -> 승인 이력상 오답, 실무상 검토 후보
|
||
P-0046: 추천 D-0119(점수 0.67, volt_mismatch) / 정답 D-0118(규격 미충족 크기) -> 규격 불일치 후보가 상위(미충족 전압) -> 규격 가중치 보강 필요
|
||
P-0049: 추천 D-0016(점수 0.82, equivalent) / 정답 D-0124(규격 미충족 -) -> 다른 부품용으로 승인된 같은 범주 대체품이 규격도 충족 -> 승인 이력상 오답, 실무상 검토 후보
|
||
P-0056: 추천 D-0058(점수 0.84, equivalent) / 정답 D-0145(규격 미충족 -) -> 다른 부품용으로 승인된 같은 범주 대체품이 규격도 충족 -> 승인 이력상 오답, 실무상 검토 후보
|
||
P-0060: 추천 D-0235(점수 0.81, equivalent) / 정답 D-0154(규격 미충족 -) -> 다른 부품용으로 승인된 같은 범주 대체품이 규격도 충족 -> 승인 이력상 오답, 실무상 검토 후보
|
||
P-0063: 추천 D-0181(점수 0.76, equivalent) / 정답 D-0163(규격 미충족 -) -> 다른 부품용으로 승인된 같은 범주 대체품이 규격도 충족 -> 승인 이력상 오답, 실무상 검토 후보
|
||
P-0077: 추천 D-0049(점수 0.76, equivalent) / 정답 D-0196(규격 미충족 크기) -> 다른 부품용 승인 대체품이 상위(정답 후보는 크기 차이로 감점) -> 크기 허용 오차 재검토
|
||
|
||
[추천 사유 예시]
|
||
P-0002 커넥터 -> D-0001(가상정밀(주)): 설명 유사도 0.64, 규격 적합성 1.00, 그래프 연결 1.00 / 규격 6개 항목 모두 충족
|
||
P-0003 유압밸브 -> D-0004(가상정밀(주)): 설명 유사도 0.47, 규격 적합성 1.00, 그래프 연결 1.00 / 규격 6개 항목 모두 충족
|
||
P-0004 베어링 -> D-0007(가상정밀(주)): 설명 유사도 0.54, 규격 적합성 1.00, 그래프 연결 1.00 / 규격 6개 항목 모두 충족
|
||
※ 추천은 후보 제시이며 규격 적합성 최종 판단과 계약 의뢰는 기술검토관·계약 담당자가 결정한다.
|
||
```
|
||
|
||
## 미션 5. 공급망 붕괴 위험도(Supply Chain Risk Score) 및 조기경보 산출 엔진 개발
|
||
|
||
- 난이도: 중급 / 예상 소요시간: 3.0시간
|
||
- 코드 설명: 12개 공급망 지표로 부품별 공급망 붕괴 위험도(0~100점)를 로지스틱 회귀로 산출하고 월별 납기 지연 추이의 EWMA로 조기경보를 내는 엔진을 만든 뒤, 입력-분석-검토-결과 4개 화면(텍스트 패널과 그래프)을 구성해 별도 가상 부품군에서 성능을 검증한다.
|
||
|
||
```python
|
||
"""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):
|
||
return f"{cat} {' '.join(words)} {s['v']}V {s['w']}W {s['tmin']}~{s['tmax']}C {s['std']}"
|
||
|
||
|
||
def _sig(x):
|
||
return 1 / (1 + np.exp(-x))
|
||
|
||
|
||
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()
|
||
```
|
||
|
||
실행 결과 예시
|
||
|
||
```text
|
||
[화면 1. 입력] [교육용가상자료]
|
||
검증용 부품 200종 / 입력 지표 12개 / 필수값 결측 0건 / 학습 부품 600종
|
||
해외 조달 159종, 단종예고·단종 60종, 단일 공급선 89종
|
||
|
||
[화면 2. 분석] 위험도 점수 분포와 모델 성능
|
||
ROC-AUC 0.693, Brier 0.222 (단절 실제 비율 40%)
|
||
부품수 평균점수 실제단절률
|
||
level
|
||
경보 51 76.75 0.59
|
||
주의 59 47.63 0.42
|
||
정상 90 22.39 0.27
|
||
위험 상승 요인(회귀 계수 상위 4): 납기 지연률 +0.58, 리드타임 +0.50, 단종 상태 +0.48, 수출 허가 필요 +0.12
|
||
|
||
[조기경보] 지연 상승형 단절 부품 49종 중 100% 감지, 상승 시작 후 평균 1.9개월 만에 경보, 정상 부품 오경보율 2.0%
|
||
|
||
[화면 3. 검토] 경보 51종 중 상위 5종 (담당자가 근거를 확인하고 조치 여부를 결정)
|
||
P-0066 커넥터 96점 | 요인: 단종 상태, 납기 지연률, 리드타임 | 단종 단종, 공급선 1개
|
||
P-0080 제어보드 96점 | 요인: 단종 상태, 납기 지연률, 리드타임 | 단종 단종, 공급선 1개
|
||
P-0036 베어링 94점 | 요인: 단종 상태, 납기 지연률, 공급선 수 | 단종 단종, 공급선 1개
|
||
P-0003 유압밸브 94점 | 요인: 단종 상태, 납기 지연률, 리드타임 | 단종 단종, 공급선 1개
|
||
P-0140 베어링 94점 | 요인: 리드타임, 납기 지연률, 공급선 수 | 단종 정상, 공급선 1개
|
||
|
||
[화면 4. 결과]
|
||
경보 51종, 주의 59종, 정상 90종
|
||
경보 등급의 실제 단절 30종(59%) vs 정상 등급 27%
|
||
내보내기 항목: 부품번호, 점수, 등급, 요인, 조기경보 월, 검토 상태(미검토)
|
||
화면 4종(입력-분석-검토-결과) 구현: 4/4 (목표 4종: 달성)
|
||
※ 점수는 우선순위를 정하는 참고값이며, 실제 단절 여부와 조치는 담당자가 확인한다.
|
||
```
|
||
|
||
## 미션 6. 부품 조달 위험 공급망 지도 및 대체선 추천 인터랙티브 대시보드 구현
|
||
|
||
- 난이도: 중급~고급 / 예상 소요시간: 2.5시간
|
||
- 코드 설명: 조달 위험 공급망 지도(국가 x 부품 범주 위험 히트맵)와 대체선 추천 패널을 그리는 대시보드에 입력 검증기와 에러 핸들러를 붙이고, 깨진 입력 14종을 주입해 크래시 0건·에러 로그 기록률 100%·사용자용 조치 안내 문구를 확인한다.
|
||
|
||
```python
|
||
"""조달 위험 공급망 지도(국가 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):
|
||
return f"{cat} {' '.join(words)} {s['v']}V {s['w']}W {s['tmin']}~{s['tmax']}C {s['std']}"
|
||
|
||
|
||
def _sig(x):
|
||
return 1 / (1 + np.exp(-x))
|
||
|
||
|
||
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})")
|
||
```
|
||
|
||
실행 결과 예시
|
||
|
||
```text
|
||
[에러 핸들러 시험] 입력 16종 실행
|
||
OK 정상 입력 -> 표시 완료
|
||
OK 정상 CSV 문자열 -> 표시 완료
|
||
안내 입력 없음(None) -> 입력이 비어 있습니다. | 조치: 파일을 다시 선택해 주세요.
|
||
안내 빈 문자열 -> 빈 파일입니다. | 조치: 내용이 있는 파일인지 확인해 주세요.
|
||
안내 깨진 JSON -> 파일 형식을 읽을 수 없습니다. | 조치: CSV(쉼표 구분) 또는 JSON(목록) 형식으로 저장했는지 확인해 주세요.
|
||
안내 지원하지 않는 형식(정수) -> 지원하지 않는 입력 형식입니다(int). | 조치: CSV 또는 JSON 파일을 사용해 주세요.
|
||
안내 머리글만 있는 CSV -> 읽은 행이 0건입니다. | 조치: 머리글만 있는 파일이 아닌지 확인해 주세요.
|
||
안내 필수 열 누락 -> 필수 열이 없습니다: lead_time_days, desc | 조치: 열 이름이 양식(데이터 딕셔너리)과 같은지 확인해 주세요.
|
||
OK 숫자 열에 문자 -> 표시 완료(격리 3행)
|
||
OK 음수 리드타임 -> 표시 완료(격리 3행)
|
||
OK 공급선 수 결측 -> 표시 완료(격리 3행)
|
||
OK 허용되지 않은 국가 -> 표시 완료(격리 3행)
|
||
OK 부품번호 중복·형식 오류 -> 표시 완료(격리 3행)
|
||
OK 연간 소요량 0(나눗셈 위험) -> 표시 완료(격리 3행)
|
||
OK 극단값(1e12) -> 표시 완료(격리 3행)
|
||
안내 전부 불량 행 -> 사용할 수 있는 행이 없습니다. | 조치: 격리 사유를 확인해 값을 고친 뒤 다시 올려 주세요.
|
||
|
||
크래시 0건(목표 0건: 달성), 오류 7건 중 로그 기록 7건 -> 기록률 100%(목표 100%: 달성)
|
||
행 단위 격리 경고 21건 기록, 안내 문구에 조치 가이드 포함 7/7건
|
||
|
||
[에러 로그 앞 5건]
|
||
seq level code detail
|
||
1 ERROR E01 입력 없음(None): 입력이 비어 있습니다.
|
||
2 ERROR E02 빈 문자열: 빈 파일입니다.
|
||
3 ERROR E03 깨진 JSON: 파일 형식을 읽을 수 없습니다.
|
||
4 ERROR E04 지원하지 않는 형식(정수): 지원하지 않는 입력 형식입니다(int).
|
||
5 ERROR E02 머리글만 있는 CSV: 읽은 행이 0건입니다.
|
||
|
||
[공급망 지도] 국가 x 범주 평균 위험 점수(상위 위험 국가 행)
|
||
category RF증폭기 광학렌즈 베어링 유압밸브 자이로센서 전원모듈 제어보드 커넥터
|
||
country
|
||
가상국가A 39 58 48 41 44 36 46 69
|
||
가상국가B 42 49 75 54 51 68 57 30
|
||
가상국가C 14 63 39 0 16 0 71 29
|
||
국내 0 19 4 38 28 2 28 42
|
||
|
||
[대체선 추천 패널] 위험 상위 해외 부품별 국산 후보와 규격 적합성
|
||
P-0080 -> D-0205 (규격 적합성 1.00)
|
||
P-0066 -> D-0172 (규격 적합성 1.00)
|
||
... (이하 생략)
|
||
```
|
||
|
||
## 미션 7. 추천된 대체 부품의 규격 적합성 검토 및 조달 승인을 위한 HITL 워크플로우 설계
|
||
|
||
- 난이도: 고급 / 예상 소요시간: 3.0시간
|
||
- 코드 설명: 위험 상위 부품의 대체 부품 추천 결과를 조달담당 상신 -> 기술검토관 규격 검토 -> 보안담당 검토 -> 승인권자 결재의 HITL 워크플로우로 처리하되, 시스템의 자동 확정과 권한 우회 시도를 차단하고 모든 행위를 해시 체인 감사 로그에 남겨 이력 무결성과 위변조 탐지를 검증한다.
|
||
|
||
```python
|
||
"""위험 상위 부품의 대체 부품 추천 결과를 조달담당 상신 -> 기술검토관 규격 검토 -> 보안담당 검토 -> 승인권자 결재의 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):
|
||
return f"{cat} {' '.join(words)} {s['v']}V {s['w']}W {s['tmin']}~{s['tmax']}C {s['std']}"
|
||
|
||
|
||
def _sig(x):
|
||
return 1 / (1 + np.exp(-x))
|
||
|
||
|
||
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()
|
||
```
|
||
|
||
실행 결과 예시
|
||
|
||
```text
|
||
[결재 결과] 12건
|
||
건 부품 후보 최종 상태 비고
|
||
C01 P-0028 D-0046 반려 점수 미달 반려
|
||
C02 P-0056 D-0058 승인 승인권자 결정
|
||
C03 P-0087 D-0223 승인 승인권자 결정
|
||
C04 P-0036 D-0094 무응답 -> 보류 자동 확정 없음: 기술검토관 응답 대기
|
||
C05 P-0066 D-0172 승인 승인권자 결정
|
||
C06 P-0080 D-0205 승인 승인권자 결정
|
||
C07 P-0077 D-0049 반려 점수 미달 반려
|
||
C08 P-0045 D-0115 승인 승인권자 결정
|
||
C09 P-0003 D-0004 승인 승인권자 결정
|
||
C10 P-0062 D-0160 반려 보안 반려: 업체 확인 필요
|
||
C11 P-0053 D-0136 승인 승인권자 결정
|
||
C12 P-0012 D-0025 승인 승인권자 결정
|
||
상태 집계: {'승인': 8, '반려': 3, '무응답 -> 보류': 1} (모의 결재자 기준, 실제 결재는 사람이 한다)
|
||
|
||
[권한 우회·절차 위반 시도]
|
||
시스템 자동 승인 -> 차단: 권한 없음(시스템 역할에 approve 권한 없음)
|
||
조달담당이 규격 검토 대행 -> 차단: 권한 없음(조달담당 역할에 review_spec 권한 없음)
|
||
검토 단계 건너뛰고 승인 -> 차단: 단계 위반(현재 상신, 필요 행위 review_spec)
|
||
미등록 사용자 접근 -> 차단: 미등록 사용자
|
||
사유 없는 검토 -> 차단: 사유·근거 입력 필수
|
||
감사 로그 삭제 -> 차단: 감사 로그 삭제·수정 권한은 누구에게도 없음
|
||
감사관이 승인 시도 -> 차단: 권한 없음(감사관 역할에 approve 권한 없음)
|
||
종결된 건 재승인 -> 차단: 이미 종결된 건(반려)
|
||
상신자가 본인 건 승인(분리 원칙) -> 차단: 상신자와 승인자 분리 원칙 위반
|
||
우회·위반 시도 차단 9/9 (100%) (목표 100%: 달성)
|
||
|
||
감사 로그 71건, 해시 체인 검증: 정상 -> 이력 무결성 100%(목표 100%: 달성)
|
||
10번째 기록 임의 수정 후 검증: 위변조 탐지(seq 10)
|
||
승인된 건의 마지막 기록 예시: {'seq': 55, 'who': 'choi', 'case': 'C12', 'reason': '승인: 위험 부품 국산화 시험 착수', 'evidence': '후보 점수 0.84, 위험 62'}
|
||
※ 대체 부품의 규격 적합성 최종 판단과 조달 승인은 사람(기술검토관·보안담당·승인권자)이 하며, 시스템은 추천과 근거만 제공한다.
|
||
```
|
||
|
||
## 미션 8. 지정학적 위기 모의 글로벌 공급망 전면 차단 스트레스 시뮬레이션
|
||
|
||
- 난이도: 고급 / 예상 소요시간: 3.5시간
|
||
- 코드 설명: 가상 공급국 전면 차단(지정학적 위기 모의) 시나리오 4종을 부품 2,000종 규모 배치 파이프라인으로 실행하고, 레코드 손상 2%·일시 오류 주입 상황에서 실행 시간(5분 이내)·자동화 성공률(99% 이상)·수작업 대비 시간 절감(80% 이상)과 시간 초과 시 기능 축소(Fail-safe) 동작을 검증한다.
|
||
|
||
```python
|
||
"""가상 공급국 전면 차단(지정학적 위기 모의) 시나리오 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):
|
||
return f"{cat} {' '.join(words)} {s['v']}V {s['w']}W {s['tmin']}~{s['tmax']}C {s['std']}"
|
||
|
||
|
||
def _sig(x):
|
||
return 1 / (1 + np.exp(-x))
|
||
|
||
|
||
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()
|
||
```
|
||
|
||
실행 결과 예시
|
||
|
||
```text
|
||
[교육용가상자료] 국내 업체 수용량 가정: 업체당 분기 70건
|
||
[교육용가상자료] 부품 2000종, 국내 후보 4797건, 손상 레코드 40건(2%) 주입, 일시 오류 확률 3%/시도
|
||
|
||
[차단 시나리오별 영향과 실행 결과]
|
||
시나리오 단절 부품 대체 후보 있음 분기 내 착수 가능 핵심(A) 착수 대기 재고 90일 미만 단절 실행(초) 성공 재시도 격리 실패
|
||
평시 0 0 0 0 0 0.09 1960 50 40 0
|
||
가상국가A 차단 656 656 420 0 141 0.17 1960 55 40 0
|
||
가상국가A+B 차단 1262 1262 420 5 302 0.18 1960 43 40 0
|
||
해외 전면 차단 1574 1574 420 52 363 0.14 1960 57 40 0
|
||
|
||
전체 배치 실행 시간 0.6초 (목표 5분 이내: 달성)
|
||
자동화 성공률 100.00% = 처리 성공 7840건 / 처리 대상 7840건(손상 격리 160건은 별도 보고, 일시 오류 205회는 재시도로 복구) (목표 99% 이상: 달성)
|
||
수작업 대비(해외 전면 차단, 2000종): 수작업 1067시간 vs 자동 108시간(담당자 확인 포함) -> 90% 절감 (목표 80% 이상: 달성)
|
||
가정: 수작업 건당 32분, 자동 처리 후 확인 건당 4분(단절·격리 건만). 실제 업무 시간은 현업 측정으로 대체해야 한다.
|
||
|
||
[규모 확대 시험(해외 전면 차단)]
|
||
부품 500종: 0.05초, 처리 500건
|
||
부품 1000종: 0.07초, 처리 1000건
|
||
부품 2000종: 0.17초, 처리 2000건
|
||
부품 4000종: 0.46초, 처리 4000건
|
||
시간 예산 0.01초 부여: 기능 축소 작동 -> 룰 스크리닝은 1960건 완료, 대체 후보 탐색은 생략(대체 후보 확인 0건). 시스템은 중단되지 않고 '후보 탐색 생략' 상태를 표시한다.
|
||
※ 시나리오는 가상 국가·가상 부품 기반의 취약점 점검이며 실제 국가·업체·교역 자료와 무관하다. 대체 조달 결정은 사람이 한다.
|
||
```
|
||
|
||
## 미션 9. AAR 기반 대체선 오추천 사례 분석 및 부품 유사도 매칭 가중치 재보정
|
||
|
||
- 난이도: 고급 / 예상 소요시간: 3.0시간
|
||
- 코드 설명: 현장 모의 AAR 세트에서 대체 부품 1순위 오추천 사례를 원인별로 분류하고, 가중치 재보정·정밀 규격 항목·후보 1:1 배정·근소 차이 검토 표시 등 개선안을 별도 검증 세트로 비교해 채택 여부를 정한 뒤, UAT 체크리스트 점수와 개선 요구 반영 현황을 계산한다.
|
||
|
||
```python
|
||
"""현장 모의 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):
|
||
return f"{cat} {' '.join(words)} {s['v']}V {s['w']}W {s['tmin']}~{s['tmax']}C {s['std']}"
|
||
|
||
|
||
def _sig(x):
|
||
return 1 / (1 + np.exp(-x))
|
||
|
||
|
||
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()
|
||
```
|
||
|
||
실행 결과 예시
|
||
|
||
```text
|
||
[AAR 세트] 해외 부품 160종 / [검증 세트] 156종 (서로 다른 가상 부품군)
|
||
기준 가중치 {'text': 0.35, 'spec': 0.45, 'graph': 0.2}: AAR 세트 Top-1 91.2%, 검증 세트 91.0%
|
||
|
||
[오추천 14건 원인 분류]
|
||
10건 다른 부품용 승인 후보와 점수 근소 차이(후보 충돌)
|
||
4건 정답 후보의 크기 편차로 규격 점수 감점
|
||
|
||
[개선안 비교] F1 재보정 가중치 {'text': 0.2, 'spec': 0.75, 'graph': 0.05}
|
||
개선안 AAR 세트 검증 세트 판정
|
||
기준(재보정 전) 0.912 0.910 -
|
||
F1 가중치 재보정 0.925 0.917 보류(검증 세트 개선 1%p 미만)
|
||
F2 정밀 규격 항목 추가 0.912 0.833 기각(검증 세트에서 악화)
|
||
F3 후보 1:1 배정 0.988 0.981 채택
|
||
F1+F3 0.975 0.949 보류(F3 단독보다 낫지 않음)
|
||
|
||
최종 채택: F3 후보 1:1 배정 (AAR 세트 기준 선택), 검증 세트 Top-1 91.0% -> 98.1% (+7.1%p)
|
||
주의: 후보 1:1 배정은 '한 후보 품목이 한 부품에 대응한다'는 가상 자료의 구조를 전제로 한다. 실제 카탈로그에서는 한 후보가 여러 부품에 쓰일 수 있어 업무 규칙으로 먼저 확인해야 한다.
|
||
|
||
[근소 차이 검토 표시] 점수 차 0.05 미만 45건 표시, 오추천 3건 중 2건 포착
|
||
|
||
후보 중복 배정(서로 다른 부품에 같은 후보를 1순위로 제시): 개선 전 13건 -> 개선 후 0건
|
||
1순위가 규격 불일치 후보인 비율: 0.6% (1:1 배정으로 좋은 후보를 다른 부품이 가져가면 불일치 후보가 배정될 수 있어 규격 필수 조건 확인은 계속 사람이 한다)
|
||
UAT 10.0/10 추천 정확도(Top-1) 85% 이상
|
||
UAT 5.0/10 규격 불일치 후보 1순위 0%
|
||
UAT 10.0/10 추천 사유(점수 구성) 제시
|
||
UAT 10.0/10 근소 차이 건 사람 확인 표시
|
||
UAT 10.0/10 후보 중복 충돌 해소
|
||
UAT 10.0/10 가중치 재보정 전후 비교 가능
|
||
UAT 10.0/10 오추천 원인 분류 기록
|
||
UAT 10.0/10 처리 속도(부품 1종 100ms 이내)
|
||
UAT 10.0/10 사람 승인 없이 확정되지 않음(HITL)
|
||
UAT 10.0/10 한계(1:1 전제 등) 고지
|
||
UAT 체크리스트 점수 95점 (목표 90점 이상: 달성)
|
||
[반영] R1 오추천 원인 분류를 운용 로그 항목으로 추가
|
||
[반영] R2 후보 1:1 배정 규칙 도입
|
||
[반영] R3 근소 차이 건 사람 확인 표시
|
||
[반영] R4 가중치 재보정 절차(AAR 설계 -> 검증 세트 확인 -> 채택·보류 판정)
|
||
[반영] R5 정밀 규격 항목 시험과 기각 사유 기록
|
||
개선 요구 5건 반영 / 5건 (목표 5건: 달성)
|
||
재발 점검: 같은 유형 오추천이 검증 세트에서 남은 건수 = 3 건 -> 근소 차이 표시로 사람 확인 대상에 포함
|
||
```
|
||
|
||
## 미션 10. 무기체계 부품 조달 조기경보 및 대체 공급선 추천 시스템 통합 패키지 완성
|
||
|
||
- 난이도: 최고급 / 예상 소요시간: 3.0시간
|
||
- 코드 설명: 입력 검증, 16종 룰 스크리닝, 공급망 붕괴 위험도와 조기경보, 국산 대체 후보 추천(1:1 배정), 사람 검토 큐와 해시 체인 감사 로그, 브리핑 요약까지 부품 조달 조기경보·대체 공급선 추천 도구를 한 파일로 통합 실행하고, 여러 시나리오 End-to-End 실행·설치 스크립트 점검·매뉴얼 작성률·자체 점검(함수 커버리지)을 출력한다.
|
||
|
||
```python
|
||
"""입력 검증, 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):
|
||
return f"{cat} {' '.join(words)} {s['v']}V {s['w']}W {s['tmin']}~{s['tmax']}C {s['std']}"
|
||
|
||
|
||
def _sig(x):
|
||
return 1 / (1 + np.exp(-x))
|
||
|
||
|
||
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()
|
||
```
|
||
|
||
실행 결과 예시
|
||
|
||
```text
|
||
[교육용가상자료] 통합 실행 9.9초 (모델 학습 포함)
|
||
[설치 스크립트(원클릭) 5단계 점검]
|
||
[PASS] 파이썬 버전
|
||
[PASS] 가상환경
|
||
[PASS] 의존 패키지
|
||
[PASS] 자체 시험
|
||
[PASS] 데모 실행
|
||
설치 스크립트 8줄, 단계 5/5 통과 (목표 원클릭 설치부터 실행까지 완비: 달성)
|
||
|
||
[End-to-End 시나리오 4종]
|
||
seed 7: 성공, 경보 부품 29종 -> 검토 큐 29건(우선 확인 11건), 승인 이력 후보 일치 86%, 격리 0건, 감사 로그 정상
|
||
seed 17: 성공, 경보 부품 32종 -> 검토 큐 32건(우선 확인 5건), 승인 이력 후보 일치 97%, 격리 0건, 감사 로그 정상
|
||
seed 27: 성공, 경보 부품 22종 -> 검토 큐 22건(우선 확인 6건), 승인 이력 후보 일치 100%, 격리 0건, 감사 로그 정상
|
||
seed 37: 성공, 경보 부품 29종 -> 검토 큐 29건(우선 확인 9건), 승인 이력 후보 일치 93%, 격리 0건, 감사 로그 정상
|
||
E2E 성공률 100%
|
||
[자체 점검]
|
||
[PASS] 정상 자료 통합 실행
|
||
[PASS] 손상 레코드 4건 격리
|
||
[PASS] 모든 추천 건이 PENDING(자동 확정 없음)
|
||
[PASS] 감사 로그 변조 탐지
|
||
[PASS] 룰 16종 이상 로딩
|
||
[PASS] 안전 속성 부품은 룰 '관심' 등급
|
||
[PASS] 매뉴얼 7개 장 작성
|
||
[PASS] 설치 스크립트 5단계
|
||
자체 점검 8/8 통과, 함수 커버리지 100%(23/23, 목표 85% 이상: 달성)
|
||
매뉴얼 작성률 7/7장 = 100% (목표 100%: 달성): 1. 목적과 범위, 2. 설치와 실행, 3. 입력 데이터, 4. 위험 판정 규칙, 5. 대체 후보 추천과 사람 검토, 6. 오류 대응, 7. 한계와 확장
|
||
|
||
[조달 담당 브리핑 요약]
|
||
1. 부품 100종 점검: 룰 고위험 20종, 위험도 경보(60점 이상) 29종, 월별 지연 추이 조기경보 23종
|
||
2. 국산 대체 후보 검토 큐 29건: 모두 PENDING, 우선 확인 11건(근소 차이 또는 규격 미충족 포함)
|
||
- P-0036 베어링 위험도 97점, 요인: 납기 지연률, 단종 상태, 평균 지연일
|
||
- P-0066 커넥터 위험도 94점, 요인: 납기 지연률, 단종 상태, 리드타임
|
||
- P-0080 제어보드 위험도 93점, 요인: 납기 지연률, 단종 상태, 리드타임
|
||
3. 규격 적합성 최종 판단, 보안 검토, 계약 의뢰 승인은 기술검토관·보안담당·승인권자가 수행한다.
|
||
4. 본 도구는 가상 자료 기반 판단 보조이며 실제 규격·단가·업체 자료는 담당 부서 확인이 필요하다.
|
||
```
|