214 KiB
214 KiB
TRN02 정답 코드 - 방공·드론 식별 퀴즈 자동채점·오답유형 분석기
[교육용가상자료] 모든 데이터는 코드 안에서 난수로 만든 가상 자료이며 실제 부대·장비·인원·작전 정보와 무관합니다.
실행 환경
- Python 3.10 이상, numpy, pandas, scipy, scikit-learn, matplotlib
- 미션별 코드는 각각 한 파일로 독립 실행됩니다. 외부 파일 없이 코드 안에서 가상 데이터를 생성합니다.
- 그래프의 글자는 한글 글꼴이 없는 실행 환경에서도 깨지지 않도록 영문으로 표기했습니다. 표·출력 결과는 한글입니다.
- 난수 시드를 고정해 두어 같은 코드를 실행하면 같은 결과가 나옵니다.
미션 1. 방공 식별 및 대드론 교전 수칙 퀴즈 문항 데이터베이스 및 평가 기준 수립
- 난이도: 초급 / 예상 소요시간: 1.5시간
- 코드 설명: 방공 식별·대드론 교전 통제 원칙 퀴즈의 문항 데이터베이스와 응답 기록 스키마, 난이도별 배점·합격 기준을 정의하고, 가상 문항·응답 샘플을 검증한다.
"""방공 식별·대드론 교전 통제 원칙 퀴즈의 문항 데이터베이스와 응답 기록 스키마, 난이도별 배점·합격 기준을 정의하고, 가상 문항·응답 샘플을 검증한다."""
import copy
import re
import numpy as np
import pandas as pd
# ---------------------------------------------------------------------------
# [교육용가상자료] 방공·드론 식별 퀴즈 문항 은행과 가상 응시자 응답 생성기
# 문항은 일반 원칙 수준의 가상 문항이며 실제 교범·교전규칙 원문이 아니다. 응시자는 가명(S001~)이다.
# ---------------------------------------------------------------------------
AREAS = ["형상식별", "피아식별", "경보보고", "교전통제", "대드론일반"]
ETYPES = ["개념 혼동", "피아 오인", "절차 누락"]
POINTS = {1: 2, 2: 3, 3: 5}
C, F, P = ETYPES
def _mc(qid, area, diff, stem, opts, ans, dist):
return {"qid": qid, "type": "MC", "area": area, "diff": diff, "stem": stem, "options": opts, "answer": ans,
"dist": dict(zip([i for i in range(1, 5) if i != ans], dist))}
def _sa(qid, area, diff, stem, accept, wrong):
return {"qid": qid, "type": "SA", "area": area, "diff": diff, "stem": stem, "accept": accept, "wrong": wrong}
def _order(qid, area, diff, stem):
return {"qid": qid, "type": "ORDER", "area": area, "diff": diff, "stem": stem, "answer": "ABCD"}
BANK = [
_mc("Q01", "형상식별", 1, "꼬리날개 없이 삼각형 날개를 가진 소형 고정익 실루엣은?", ["쿼드콥터형", "삼각익 고정익형", "회전익 헬기형", "조류"], 2, [C, C, F]),
_mc("Q02", "형상식별", 1, "회전날개 4개가 십자로 배치된 실루엣은?", ["고정익 드론형", "쿼드콥터형", "순항미사일형", "탄도미사일형"], 2, [C, C, C]),
_mc("Q03", "형상식별", 2, "가늘고 긴 동체 중간에 작은 날개가 붙은 실루엣은?", ["순항미사일형", "탄도미사일형", "민간 경비행기형", "조류"], 1, [C, F, F]),
_mc("Q04", "형상식별", 3, "원거리라 실루엣 식별이 어려울 때 먼저 할 행동은?", ["추정 유형으로 확정 보고", "관측 정보를 그대로 보고하고 추가 관측", "관측 중단", "민간기로 간주"], 2, [P, P, F]),
_mc("Q05", "피아식별", 2, "식별 신호 응답이 없는 항공기에 대한 올바른 판단은?", ["즉시 적기로 확정", "미확인 항공기로 분류하고 추가 확인", "아군기로 간주", "무시"], 2, [F, F, P]),
_mc("Q06", "피아식별", 2, "운항 정보와 일치하는 저속 비행체의 분류는?", ["적 드론", "민간기 가능성 높음, 추가 확인", "순항미사일", "아군 정찰기로 확정"], 2, [F, C, F]),
_mc("Q07", "피아식별", 3, "아군 드론 운용 공지가 있는 공역에서 미확인 드론을 보면 우선 확인할 것은?", ["기상 정보", "아군 운용 계획과 식별 신호", "대응 준비 상태", "주변 지형"], 2, [C, P, C]),
_mc("Q08", "피아식별", 3, "조류 무리와 소형 드론 군집 구분에 가장 도움이 되는 정보는?", ["크기만 비교", "궤적의 규칙성과 열 신호", "색상", "관측 시각"], 2, [C, C, C]),
_mc("Q09", "경보보고", 1, "공중 위협 최초 보고에 포함하지 않아도 되는 것은?", ["관측 시각", "방향과 거리", "추정 유형", "관측자 개인 연락처"], 4, [P, P, P]),
_mc("Q10", "경보보고", 2, "경보 전파 후 상황이 바뀌면?", ["최초 보고 유지", "변경 사항을 즉시 추가 보고", "상황 종료 후 일괄 보고", "개인 판단으로 경보 해제"], 2, [P, P, P]),
_mc("Q11", "경보보고", 2, "보고 내용이 불확실할 때 올바른 표현은?", ["확정 표현으로 보고", "'추정'임을 밝히고 근거와 함께 보고", "보고 보류", "다른 관측자 보고를 복사"], 2, [C, P, P]),
_mc("Q12", "경보보고", 1, "드론 탐지 후 보고 경로로 알맞은 것은?", ["인접 부대에만 전파", "지휘계통을 따라 보고", "개인 메신저로 공유", "보고 없이 대응"], 2, [P, P, P]),
_mc("Q13", "교전통제", 1, "AI 탐지 결과만으로 대응을 결정할 수 있는가?", ["가능하다", "인간 지휘관의 승인이 필요하다", "신뢰도 90% 이상이면 가능", "야간에만 가능"], 2, [C, C, C]),
_mc("Q14", "교전통제", 2, "민간 피해 가능성이 확인되면?", ["그대로 진행", "즉시 보고하고 지휘관 판단을 받는다", "조치 후 보고", "무시"], 2, [P, P, F]),
_mc("Q15", "교전통제", 3, "식별이 불확실한 표적에 대한 원칙은?", ["위협으로 간주해 즉시 대응", "식별 확인 전까지 승인 절차를 따른다", "무조건 무시", "다수결로 결정"], 2, [F, F, C]),
_mc("Q16", "교전통제", 2, "판단 근거를 기록해야 하는 이유는?", ["형식상 필요", "사후 검토와 책임 추적", "보고 생략", "기록 불필요"], 2, [C, P, P]),
_mc("Q17", "대드론일반", 1, "대드론 체계의 일반적 처리 순서는?", ["대응-탐지-식별", "탐지-식별-보고·판단-대응", "식별-대응-탐지", "보고-탐지-대응"], 2, [P, P, P]),
_mc("Q18", "대드론일반", 3, "레이더만으로 조류와 드론 구분이 어려운 이유는?", ["반사 신호 크기가 비슷할 수 있어서", "레이더는 야간에 못 써서", "드론에는 금속이 없어서", "조류는 탐지되지 않아서"], 1, [C, C, C]),
_mc("Q19", "대드론일반", 2, "전자광학(EO) 센서의 약점이 커지는 조건은?", ["맑은 주간", "안개·야간", "저속 표적", "근거리"], 2, [C, C, C]),
_mc("Q20", "대드론일반", 2, "경보가 반복해서 오경보로 판명되면?", ["경보 장치를 끈다", "사례를 기록·보고하고 기준 재검토 요청", "무시한다", "모두 실제 위협으로 간주"], 2, [P, P, F]),
_sa("Q21", "피아식별", 1, "아군·적·민간을 구분하는 절차를 무엇이라 하는가?", ["피아식별", "IFF"], {"표적획득": C, "적기판정": F, "경계": C}),
_sa("Q22", "형상식별", 2, "꼬리날개 없는 삼각형 날개 형태를 무엇이라 하는가?", ["삼각익", "델타익", "delta"], {"직선익": C, "후퇴익": C, "쿼드콥터": C}),
_sa("Q23", "경보보고", 2, "불확실한 관측을 보고할 때 붙이는 표현은?", ["추정"], {"확정": C, "확인": C, "생략": P}),
_sa("Q24", "교전통제", 1, "AI 탐지 결과에 대해 최종 결정을 내리는 주체는?", ["지휘관"], {"AI": C, "운용병": P, "레이더": C}),
_sa("Q25", "대드론일반", 1, "조류를 드론으로 잘못 경보하는 것을 무엇이라 하는가?", ["오경보", "오탐", "false alarm"], {"미탐": C, "재경보": C, "적기": F}),
_sa("Q26", "피아식별", 2, "운항 정보가 확인된 여객기는 어떻게 분류하는가?", ["민간기", "민항기"], {"적기": F, "미확인기": P, "아군기": F}),
_sa("Q27", "형상식별", 1, "회전날개 4개를 가진 드론 형태는?", ["쿼드콥터", "quadcopter"], {"헥사콥터": C, "고정익": C, "헬기": F}),
_sa("Q28", "경보보고", 1, "보고는 무엇을 따라 올라가는가?", ["지휘계통"], {"인접부대": P, "개인메신저": P, "통신망": C}),
_order("Q29", "경보보고", 2, "최초 보고 순서: A 관측 시각, B 방향·거리, C 추정 유형, D 관측자 조치"),
_order("Q30", "대드론일반", 2, "처리 순서: A 탐지, B 식별, C 보고·판단, D 대응"),
_order("Q31", "교전통제", 3, "통제 순서: A 식별 확인, B 상급 승인 요청, C 승인 수령, D 조치 기록"),
_order("Q32", "피아식별", 3, "확인 순서: A 운항정보 대조, B 식별 신호 확인, C 미확인 분류, D 보고"),
]
QIDX = {q["qid"]: q for q in BANK}
UNITS = ["가상1중대", "가상2중대", "가상3중대"]
_CIRC = "①②③④"
PARA = {"Q21": "아군적군 구분", "Q22": "세모 날개", "Q23": "추정됨으로 표기", "Q24": "중대장(지휘관)", "Q25": "잘못된 경보",
"Q26": "민간 여객기", "Q27": "4발 드론", "Q28": "지휘 라인"} # 뜻은 맞지만 인정 답안 목록에 없는 표현
def make_soldiers(n_per_unit, seed):
rng = np.random.default_rng(seed)
out = []
for u, unit in enumerate(UNITS):
base = [0.3, -0.1, 0.0][u]
for i in range(n_per_unit):
off = rng.normal(0, 0.5, len(AREAS)) + (np.array([0, 0, 0, -0.6, 0]) if u == 1 else 0)
out.append({"sid": f"S{len(out) + 1:03d}", "unit": unit, "theta": dict(zip(AREAS, base + rng.normal(0, 0.6) + off)),
"tend": dict(zip(ETYPES, rng.dirichlet([1.2, 1.2, 1.2])))})
return out
def p_correct(theta, diff):
return 1 / (1 + np.exp(-1.7 * (theta - (diff - 2) * 0.9 + 0.9)))
def _typo(s, rng):
if len(s) < 2:
return s
i = int(rng.integers(len(s) - 1))
return s[:i] + s[i + 1] + s[i] + s[i + 2:] if len(s) <= 3 or rng.random() < 0.5 else s[:i] + s[i + 1:]
def _fmt_mc(k, rng):
r = rng.random()
if r < 0.55:
return str(k)
return rng.choice([_CIRC[k - 1], f"{k}번", "ABCD"[k - 1], "abcd"[k - 1] + ")", f" {k} ", chr(0xFF10 + k), f"{k}."])
def _fmt_sa(s, rng):
r = rng.random()
if r < 0.45:
return s
if r < 0.6:
return " ".join(s) if len(s) <= 4 else s[:2] + " " + s[2:]
if r < 0.72:
return s + rng.choice(["입니다", "이다", ".", "임"])
if r < 0.8:
return s.upper() if s.isascii() else "".join(chr(ord(ch) + 0xFEE0) if "!" <= ch <= "~" else ch for ch in s) + " "
return _typo(s, rng)
def answer(q, sol, rng):
"""한 문항 응답: (원본 응답 문자열, 의도한 정답 여부, 의도한 답, 오답 유형)."""
ok = rng.random() < p_correct(sol["theta"][q["area"]], q["diff"])
if q["type"] == "MC":
if ok:
k, et = q["answer"], None
else:
ks = list(q["dist"]); w = np.array([sol["tend"][q["dist"][k]] for k in ks])
k = int(rng.choice(ks, p=w / w.sum())); et = q["dist"][k]
return _fmt_mc(k, rng), ok, str(k), et
if q["type"] == "SA":
if ok and rng.random() < 0.06:
return PARA[q["qid"]], ok, PARA[q["qid"]], None
if ok:
s, et = str(rng.choice(q["accept"])), None
else:
ws = list(q["wrong"]); w = np.array([sol["tend"][q["wrong"][x]] for x in ws])
s = str(rng.choice(ws, p=w / w.sum())); et = q["wrong"][s]
return _fmt_sa(s, rng), ok, s, et
if ok:
s = "ABCD"
elif rng.random() < 0.5:
s = "ABCD".replace("ABCD"[rng.integers(1, 4)], "") # 단계 하나 빠뜨림
else:
i = int(rng.integers(0, 3)); s = list("ABCD"); s[i], s[i + 1] = s[i + 1], s[i]; s = "".join(s) # 순서 뒤바뀜
raw = s if rng.random() < 0.6 else rng.choice(["-".join(s), " ".join(s), s.lower(), ",".join(s)])
return raw, ok, s, (None if ok else P)
def make_responses(n_per_unit=40, seed=7, blank=0.02):
"""응시자 x 문항 응답표(DataFrame)를 만든다. truth_* 열은 교관 기준 정답(평가용)이다."""
rng = np.random.default_rng(seed)
rows = []
for sol in make_soldiers(n_per_unit, seed):
for q in BANK:
raw, ok, intended, et = answer(q, sol, rng)
if rng.random() < blank:
raw, ok, intended, et = "", False, "", None
rows.append({"rid": f"R{len(rows):05d}", "sid": sol["sid"], "unit": sol["unit"], "qid": q["qid"], "raw": raw,
"truth_correct": ok, "truth_answer": intended, "truth_etype": et})
return pd.DataFrame(rows)
# ---------------------------------------------------------------------------
# 1) 스키마: 문항(유형·영역·난이도·정답·오답 유형 매핑·상황 조건) / 응답(응시자 가명·문항·원본 응답·제출 시각)
SCHEMA = {
"item": {
"qid": {"type": str, "pattern": r"^Q\d{2}$"},
"type": {"type": str, "enum": ["MC", "SA", "ORDER", "ESSAY"]},
"area": {"type": str, "enum": AREAS},
"diff": {"type": int, "enum": [1, 2, 3]},
"stem": {"type": str, "minlen": 5},
},
"context": { # 보완한 전술 상황 파라미터
"time": {"type": str, "enum": ["주간", "야간"]},
"weather": {"type": str, "enum": ["맑음", "안개", "비", "눈"]},
"comm": {"type": str, "enum": ["정상", "지연", "두절"]},
"position_error_m": {"type": (int, float), "min": 0, "max": 500},
},
"response": {
"rid": {"type": str, "pattern": r"^R\d{5}$"},
"sid": {"type": str, "pattern": r"^S\d{3}$"},
"qid": {"type": str, "pattern": r"^Q\d{2}$"},
"raw": {"type": str},
"submitted_s": {"type": (int, float), "min": 0, "max": 7200},
},
}
RUBRIC = {"배점(난이도 1/2/3)": "2 / 3 / 5점", "서술형": "10점(교관 채점)", "합격 기준": "총점의 70% 이상", "미응답": "0점, 미응답으로 기록(삭제 안 함)"}
def check(name, v, r):
if not isinstance(v, r["type"]) or isinstance(v, bool):
return [f"{name}: 자료형 오류({type(v).__name__})"]
e = []
if "enum" in r and v not in r["enum"]:
e.append(f"{name}: 허용되지 않은 값 '{v}'")
if "pattern" in r and not re.match(r["pattern"], v):
e.append(f"{name}: 형식 불일치 '{v}'")
if "minlen" in r and len(v) < r["minlen"]:
e.append(f"{name}: 길이 부족")
if "min" in r and v < r["min"]:
e.append(f"{name}: 최소값 미만")
if "max" in r and v > r["max"]:
e.append(f"{name}: 최대값 초과")
return e
def validate_item(it):
e = []
for g in ("item", "context"):
src = it if g == "item" else it.get("context", {})
for k, r in SCHEMA[g].items():
e += [f"{g}.{k}: 필수 필드 누락"] if k not in src else check(f"{g}.{k}", src[k], r)
if it.get("type") == "MC":
if len(it.get("options", [])) != 4 or it.get("answer") not in (1, 2, 3, 4):
e.append("MC: 보기 4개·정답 번호(1~4) 필요")
elif set(it.get("dist", {})) != {1, 2, 3, 4} - {it["answer"]} or not set(it["dist"].values()) <= set(ETYPES):
e.append("MC: 오답 보기별 오답 유형 매핑 불완전")
if it.get("type") == "SA" and (not it.get("accept") or not set(it.get("wrong", {}).values()) <= set(ETYPES)):
e.append("SA: 인정 답안·오답 유형 목록 필요")
return e
def validate_resp(r):
e = []
for k, rule in SCHEMA["response"].items():
e += [f"response.{k}: 필수 필드 누락"] if k not in r else check(f"response.{k}", r[k], rule)
if r.get("qid") not in QIDX:
e.append("response.qid: 문항 DB에 없는 문항")
return e
rng = np.random.default_rng(1)
items = []
for q in BANK:
it = copy.deepcopy(q)
it["context"] = {"time": str(rng.choice(["주간", "야간"])), "weather": str(rng.choice(["맑음", "안개", "비", "눈"])),
"comm": str(rng.choice(["정상", "지연"])), "position_error_m": float(round(rng.uniform(5, 80), 1))}
items.append(it)
resp = make_responses(5, seed=3).head(50)
recs = [{"rid": r.rid, "sid": r.sid, "qid": r.qid, "raw": r.raw, "submitted_s": float(i * 12)} for i, r in enumerate(resp.itertuples())]
ni, nr = sum(not validate_item(i) for i in items), sum(not validate_resp(r) for r in recs)
print(f"[교육용가상자료] 문항 {len(items)}개 중 검증 통과 {ni}개, 응답 {len(recs)}건 중 통과 {nr}건 (목표 오류 0건: {'달성' if ni == len(items) and nr == len(recs) else '미달'})")
bad = copy.deepcopy(items[0]); bad["qid"] = "Q1"; bad["diff"] = 4; bad["dist"].pop(4); del bad["context"]["comm"]
bad_r = dict(recs[0], sid="병사1", qid="Q99", submitted_s=9999)
print("\n[오류 주입 결과]")
for e in validate_item(bad) + validate_resp(bad_r):
print(" -", e)
print("\n[문항 구성]")
print(pd.crosstab(pd.Series([i["area"] for i in items], name="영역"), pd.Series([i["type"] for i in items], name="유형"), margins=True, margins_name="합계"))
print("\n[평가 기준]")
for k, v in RUBRIC.items():
print(f" - {k}: {v}")
print(f" - 만점: {sum(POINTS[i['diff']] for i in items)}점(서술형 제외), 오답 유형: {', '.join(ETYPES)}")
n_fields = sum(len(v) for v in SCHEMA.values())
print(f"\n스키마 필드 {n_fields}개 정의(문항 {len(SCHEMA['item'])}, 상황 {len(SCHEMA['context'])}, 응답 {len(SCHEMA['response'])}), 정의 완성도 100%")
print("문항 예시:", items[3]["qid"], items[3]["stem"], "| 정답", items[3]["answer"], "| 오답 유형", items[3]["dist"])
실행 결과 예시
[교육용가상자료] 문항 32개 중 검증 통과 32개, 응답 50건 중 통과 50건 (목표 오류 0건: 달성)
[오류 주입 결과]
- item.qid: 형식 불일치 'Q1'
- item.diff: 허용되지 않은 값 '4'
- context.comm: 필수 필드 누락
- MC: 오답 보기별 오답 유형 매핑 불완전
- response.sid: 형식 불일치 '병사1'
- response.submitted_s: 최대값 초과
- response.qid: 문항 DB에 없는 문항
[문항 구성]
유형 MC ORDER SA 합계
영역
경보보고 4 1 2 7
교전통제 4 1 1 6
대드론일반 4 1 1 6
피아식별 4 1 2 7
형상식별 4 0 2 6
합계 20 4 8 32
[평가 기준]
- 배점(난이도 1/2/3): 2 / 3 / 5점
- 서술형: 10점(교관 채점)
- 합격 기준: 총점의 70% 이상
- 미응답: 0점, 미응답으로 기록(삭제 안 함)
- 만점: 99점(서술형 제외), 오답 유형: 개념 혼동, 피아 오인, 절차 누락
스키마 필드 14개 정의(문항 5, 상황 4, 응답 5), 정의 완성도 100%
문항 예시: Q04 원거리라 실루엣 식별이 어려울 때 먼저 할 행동은? | 정답 2 | 오답 유형 {1: '절차 누락', 3: '절차 누락', 4: '피아 오인'}
미션 2. 장병 응답 텍스트/선택지 정제 및 시험 답안 파싱 전처리 모듈 구현
- 난이도: 초급~중급 / 예상 소요시간: 2.0시간
- 코드 설명: 장병 응답의 전각·원문자·공백·어미 등 표기 차이를 정제하고 선다형·단답형·순서형 답안을 파싱하며, 통신 상태에 따라 재전송 중복을 제거하고 미응답·형식 오류를 분리하는 전처리 모듈을 구현한다. 처리 속도와 단위 테스트 5종을 확인한다.
"""장병 응답의 전각·원문자·공백·어미 등 표기 차이를 정제하고 선다형·단답형·순서형 답안을 파싱하며, 통신 상태에 따라 재전송 중복을 제거하고 미응답·형식 오류를 분리하는 전처리 모듈을 구현한다. 처리 속도와 단위 테스트 5종을 확인한다."""
import difflib
import re
import time
import unicodedata
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
# ---------------------------------------------------------------------------
# [교육용가상자료] 방공·드론 식별 퀴즈 문항 은행과 가상 응시자 응답 생성기
# 문항은 일반 원칙 수준의 가상 문항이며 실제 교범·교전규칙 원문이 아니다. 응시자는 가명(S001~)이다.
# ---------------------------------------------------------------------------
AREAS = ["형상식별", "피아식별", "경보보고", "교전통제", "대드론일반"]
ETYPES = ["개념 혼동", "피아 오인", "절차 누락"]
POINTS = {1: 2, 2: 3, 3: 5}
C, F, P = ETYPES
def _mc(qid, area, diff, stem, opts, ans, dist):
return {"qid": qid, "type": "MC", "area": area, "diff": diff, "stem": stem, "options": opts, "answer": ans,
"dist": dict(zip([i for i in range(1, 5) if i != ans], dist))}
def _sa(qid, area, diff, stem, accept, wrong):
return {"qid": qid, "type": "SA", "area": area, "diff": diff, "stem": stem, "accept": accept, "wrong": wrong}
def _order(qid, area, diff, stem):
return {"qid": qid, "type": "ORDER", "area": area, "diff": diff, "stem": stem, "answer": "ABCD"}
BANK = [
_mc("Q01", "형상식별", 1, "꼬리날개 없이 삼각형 날개를 가진 소형 고정익 실루엣은?", ["쿼드콥터형", "삼각익 고정익형", "회전익 헬기형", "조류"], 2, [C, C, F]),
_mc("Q02", "형상식별", 1, "회전날개 4개가 십자로 배치된 실루엣은?", ["고정익 드론형", "쿼드콥터형", "순항미사일형", "탄도미사일형"], 2, [C, C, C]),
_mc("Q03", "형상식별", 2, "가늘고 긴 동체 중간에 작은 날개가 붙은 실루엣은?", ["순항미사일형", "탄도미사일형", "민간 경비행기형", "조류"], 1, [C, F, F]),
_mc("Q04", "형상식별", 3, "원거리라 실루엣 식별이 어려울 때 먼저 할 행동은?", ["추정 유형으로 확정 보고", "관측 정보를 그대로 보고하고 추가 관측", "관측 중단", "민간기로 간주"], 2, [P, P, F]),
_mc("Q05", "피아식별", 2, "식별 신호 응답이 없는 항공기에 대한 올바른 판단은?", ["즉시 적기로 확정", "미확인 항공기로 분류하고 추가 확인", "아군기로 간주", "무시"], 2, [F, F, P]),
_mc("Q06", "피아식별", 2, "운항 정보와 일치하는 저속 비행체의 분류는?", ["적 드론", "민간기 가능성 높음, 추가 확인", "순항미사일", "아군 정찰기로 확정"], 2, [F, C, F]),
_mc("Q07", "피아식별", 3, "아군 드론 운용 공지가 있는 공역에서 미확인 드론을 보면 우선 확인할 것은?", ["기상 정보", "아군 운용 계획과 식별 신호", "대응 준비 상태", "주변 지형"], 2, [C, P, C]),
_mc("Q08", "피아식별", 3, "조류 무리와 소형 드론 군집 구분에 가장 도움이 되는 정보는?", ["크기만 비교", "궤적의 규칙성과 열 신호", "색상", "관측 시각"], 2, [C, C, C]),
_mc("Q09", "경보보고", 1, "공중 위협 최초 보고에 포함하지 않아도 되는 것은?", ["관측 시각", "방향과 거리", "추정 유형", "관측자 개인 연락처"], 4, [P, P, P]),
_mc("Q10", "경보보고", 2, "경보 전파 후 상황이 바뀌면?", ["최초 보고 유지", "변경 사항을 즉시 추가 보고", "상황 종료 후 일괄 보고", "개인 판단으로 경보 해제"], 2, [P, P, P]),
_mc("Q11", "경보보고", 2, "보고 내용이 불확실할 때 올바른 표현은?", ["확정 표현으로 보고", "'추정'임을 밝히고 근거와 함께 보고", "보고 보류", "다른 관측자 보고를 복사"], 2, [C, P, P]),
_mc("Q12", "경보보고", 1, "드론 탐지 후 보고 경로로 알맞은 것은?", ["인접 부대에만 전파", "지휘계통을 따라 보고", "개인 메신저로 공유", "보고 없이 대응"], 2, [P, P, P]),
_mc("Q13", "교전통제", 1, "AI 탐지 결과만으로 대응을 결정할 수 있는가?", ["가능하다", "인간 지휘관의 승인이 필요하다", "신뢰도 90% 이상이면 가능", "야간에만 가능"], 2, [C, C, C]),
_mc("Q14", "교전통제", 2, "민간 피해 가능성이 확인되면?", ["그대로 진행", "즉시 보고하고 지휘관 판단을 받는다", "조치 후 보고", "무시"], 2, [P, P, F]),
_mc("Q15", "교전통제", 3, "식별이 불확실한 표적에 대한 원칙은?", ["위협으로 간주해 즉시 대응", "식별 확인 전까지 승인 절차를 따른다", "무조건 무시", "다수결로 결정"], 2, [F, F, C]),
_mc("Q16", "교전통제", 2, "판단 근거를 기록해야 하는 이유는?", ["형식상 필요", "사후 검토와 책임 추적", "보고 생략", "기록 불필요"], 2, [C, P, P]),
_mc("Q17", "대드론일반", 1, "대드론 체계의 일반적 처리 순서는?", ["대응-탐지-식별", "탐지-식별-보고·판단-대응", "식별-대응-탐지", "보고-탐지-대응"], 2, [P, P, P]),
_mc("Q18", "대드론일반", 3, "레이더만으로 조류와 드론 구분이 어려운 이유는?", ["반사 신호 크기가 비슷할 수 있어서", "레이더는 야간에 못 써서", "드론에는 금속이 없어서", "조류는 탐지되지 않아서"], 1, [C, C, C]),
_mc("Q19", "대드론일반", 2, "전자광학(EO) 센서의 약점이 커지는 조건은?", ["맑은 주간", "안개·야간", "저속 표적", "근거리"], 2, [C, C, C]),
_mc("Q20", "대드론일반", 2, "경보가 반복해서 오경보로 판명되면?", ["경보 장치를 끈다", "사례를 기록·보고하고 기준 재검토 요청", "무시한다", "모두 실제 위협으로 간주"], 2, [P, P, F]),
_sa("Q21", "피아식별", 1, "아군·적·민간을 구분하는 절차를 무엇이라 하는가?", ["피아식별", "IFF"], {"표적획득": C, "적기판정": F, "경계": C}),
_sa("Q22", "형상식별", 2, "꼬리날개 없는 삼각형 날개 형태를 무엇이라 하는가?", ["삼각익", "델타익", "delta"], {"직선익": C, "후퇴익": C, "쿼드콥터": C}),
_sa("Q23", "경보보고", 2, "불확실한 관측을 보고할 때 붙이는 표현은?", ["추정"], {"확정": C, "확인": C, "생략": P}),
_sa("Q24", "교전통제", 1, "AI 탐지 결과에 대해 최종 결정을 내리는 주체는?", ["지휘관"], {"AI": C, "운용병": P, "레이더": C}),
_sa("Q25", "대드론일반", 1, "조류를 드론으로 잘못 경보하는 것을 무엇이라 하는가?", ["오경보", "오탐", "false alarm"], {"미탐": C, "재경보": C, "적기": F}),
_sa("Q26", "피아식별", 2, "운항 정보가 확인된 여객기는 어떻게 분류하는가?", ["민간기", "민항기"], {"적기": F, "미확인기": P, "아군기": F}),
_sa("Q27", "형상식별", 1, "회전날개 4개를 가진 드론 형태는?", ["쿼드콥터", "quadcopter"], {"헥사콥터": C, "고정익": C, "헬기": F}),
_sa("Q28", "경보보고", 1, "보고는 무엇을 따라 올라가는가?", ["지휘계통"], {"인접부대": P, "개인메신저": P, "통신망": C}),
_order("Q29", "경보보고", 2, "최초 보고 순서: A 관측 시각, B 방향·거리, C 추정 유형, D 관측자 조치"),
_order("Q30", "대드론일반", 2, "처리 순서: A 탐지, B 식별, C 보고·판단, D 대응"),
_order("Q31", "교전통제", 3, "통제 순서: A 식별 확인, B 상급 승인 요청, C 승인 수령, D 조치 기록"),
_order("Q32", "피아식별", 3, "확인 순서: A 운항정보 대조, B 식별 신호 확인, C 미확인 분류, D 보고"),
]
QIDX = {q["qid"]: q for q in BANK}
UNITS = ["가상1중대", "가상2중대", "가상3중대"]
_CIRC = "①②③④"
PARA = {"Q21": "아군적군 구분", "Q22": "세모 날개", "Q23": "추정됨으로 표기", "Q24": "중대장(지휘관)", "Q25": "잘못된 경보",
"Q26": "민간 여객기", "Q27": "4발 드론", "Q28": "지휘 라인"} # 뜻은 맞지만 인정 답안 목록에 없는 표현
def make_soldiers(n_per_unit, seed):
rng = np.random.default_rng(seed)
out = []
for u, unit in enumerate(UNITS):
base = [0.3, -0.1, 0.0][u]
for i in range(n_per_unit):
off = rng.normal(0, 0.5, len(AREAS)) + (np.array([0, 0, 0, -0.6, 0]) if u == 1 else 0)
out.append({"sid": f"S{len(out) + 1:03d}", "unit": unit, "theta": dict(zip(AREAS, base + rng.normal(0, 0.6) + off)),
"tend": dict(zip(ETYPES, rng.dirichlet([1.2, 1.2, 1.2])))})
return out
def p_correct(theta, diff):
return 1 / (1 + np.exp(-1.7 * (theta - (diff - 2) * 0.9 + 0.9)))
def _typo(s, rng):
if len(s) < 2:
return s
i = int(rng.integers(len(s) - 1))
return s[:i] + s[i + 1] + s[i] + s[i + 2:] if len(s) <= 3 or rng.random() < 0.5 else s[:i] + s[i + 1:]
def _fmt_mc(k, rng):
r = rng.random()
if r < 0.55:
return str(k)
return rng.choice([_CIRC[k - 1], f"{k}번", "ABCD"[k - 1], "abcd"[k - 1] + ")", f" {k} ", chr(0xFF10 + k), f"{k}."])
def _fmt_sa(s, rng):
r = rng.random()
if r < 0.45:
return s
if r < 0.6:
return " ".join(s) if len(s) <= 4 else s[:2] + " " + s[2:]
if r < 0.72:
return s + rng.choice(["입니다", "이다", ".", "임"])
if r < 0.8:
return s.upper() if s.isascii() else "".join(chr(ord(ch) + 0xFEE0) if "!" <= ch <= "~" else ch for ch in s) + " "
return _typo(s, rng)
def answer(q, sol, rng):
"""한 문항 응답: (원본 응답 문자열, 의도한 정답 여부, 의도한 답, 오답 유형)."""
ok = rng.random() < p_correct(sol["theta"][q["area"]], q["diff"])
if q["type"] == "MC":
if ok:
k, et = q["answer"], None
else:
ks = list(q["dist"]); w = np.array([sol["tend"][q["dist"][k]] for k in ks])
k = int(rng.choice(ks, p=w / w.sum())); et = q["dist"][k]
return _fmt_mc(k, rng), ok, str(k), et
if q["type"] == "SA":
if ok and rng.random() < 0.06:
return PARA[q["qid"]], ok, PARA[q["qid"]], None
if ok:
s, et = str(rng.choice(q["accept"])), None
else:
ws = list(q["wrong"]); w = np.array([sol["tend"][q["wrong"][x]] for x in ws])
s = str(rng.choice(ws, p=w / w.sum())); et = q["wrong"][s]
return _fmt_sa(s, rng), ok, s, et
if ok:
s = "ABCD"
elif rng.random() < 0.5:
s = "ABCD".replace("ABCD"[rng.integers(1, 4)], "") # 단계 하나 빠뜨림
else:
i = int(rng.integers(0, 3)); s = list("ABCD"); s[i], s[i + 1] = s[i + 1], s[i]; s = "".join(s) # 순서 뒤바뀜
raw = s if rng.random() < 0.6 else rng.choice(["-".join(s), " ".join(s), s.lower(), ",".join(s)])
return raw, ok, s, (None if ok else P)
def make_responses(n_per_unit=40, seed=7, blank=0.02):
"""응시자 x 문항 응답표(DataFrame)를 만든다. truth_* 열은 교관 기준 정답(평가용)이다."""
rng = np.random.default_rng(seed)
rows = []
for sol in make_soldiers(n_per_unit, seed):
for q in BANK:
raw, ok, intended, et = answer(q, sol, rng)
if rng.random() < blank:
raw, ok, intended, et = "", False, "", None
rows.append({"rid": f"R{len(rows):05d}", "sid": sol["sid"], "unit": sol["unit"], "qid": q["qid"], "raw": raw,
"truth_correct": ok, "truth_answer": intended, "truth_etype": et})
return pd.DataFrame(rows)
# ---------------------------------------------------------------------------
# ---- 응답 정제·파싱·채점 공통 함수 ----
TAILS = ("입니다", "이다", "임")
def norm_text(s):
"""전각->반각(NFKC), 소문자, 공백·문장부호 제거, 끝맺음 어미 제거."""
s = unicodedata.normalize("NFKC", str(s)).strip().lower()
s = re.sub(r"[\s\.\,\-\(\)·]", "", s)
for t in TAILS:
if s.endswith(t) and len(s) > len(t) + 1:
s = s[:-len(t)]
return s
def parse(q, raw):
"""원본 응답 -> (정제값, 상태). 상태: 정상 / 미응답 / 형식오류"""
if raw is None or str(raw).strip() == "":
return "", "미응답"
s = unicodedata.normalize("NFKC", str(raw)).strip()
if q["type"] == "MC":
t = re.sub(r"[\s번\.\)]", "", s).lower()
t = {"a": "1", "b": "2", "c": "3", "d": "4"}.get(t, t)
return (t, "정상") if t in ("1", "2", "3", "4") else (t, "형식오류")
if q["type"] == "ORDER":
t = re.sub(r"[^A-Za-z]", "", s).upper()
return (t, "정상") if t and set(t) <= set("ABCD") and len(set(t)) == len(t) else (t, "형식오류")
return norm_text(s), "정상"
def grade_exact(q, val):
if q["type"] == "MC":
return val == str(q["answer"])
if q["type"] == "ORDER":
return val == q["answer"]
return val in {norm_text(a) for a in q["accept"]}
def grade_fuzzy(q, val, cut=0.75):
"""단답형은 오탈자(글자 순서 바뀜·한 글자 누락)를 허용(유사도 cut 이상)하되, 알려진 오답과 더 가까우면 오답으로 본다."""
if grade_exact(q, val) or q["type"] != "SA" or len(val) < 2:
return grade_exact(q, val)
best = max(1.0 if sorted(val) == sorted(norm_text(a)) else difflib.SequenceMatcher(None, val, norm_text(a)).ratio() for a in q["accept"])
worst = max(difflib.SequenceMatcher(None, val, norm_text(w)).ratio() for w in q["wrong"])
return best >= cut and best > worst
def score_of(q, correct):
return POINTS[q["diff"]] if correct else 0
DUP_WINDOW_S = {"정상": 5, "지연": 30} # 통신 상태별 재전송 판단 시간(동적 임계값)
def inject_noise(df, seed=1):
"""[교육용가상자료] 재전송 중복·형식 오류·제출 시각·통신 상태를 덧붙인다."""
rng = np.random.default_rng(seed)
df = df.copy()
df["comm"] = np.where(df["unit"] == "가상2중대", "지연", "정상")
df["ts"] = np.arange(len(df)) * 3.0
bad = rng.choice(len(df), 20, replace=False)
df.loc[bad, "raw"] = rng.choice(["7", "모름", "??"], 20)
df.loc[bad, "truth_correct"] = False
dup = df.sample(frac=0.03, random_state=seed).copy()
dup["ts"] = dup["ts"] + np.where(dup["comm"] == "지연", 20.0, 2.0)
dup["rid"] = dup["rid"] + "D"
return pd.concat([df, dup]).sort_values("ts").reset_index(drop=True)
def preprocess(df):
"""중복 제거 -> 파싱 -> 상태 분류. 미응답은 삭제하지 않고 상태로 남긴다(결측 보정)."""
df = df.sort_values("ts")
win = df["comm"].map(DUP_WINDOW_S)
prev_ts = df.groupby(["sid", "qid"])["ts"].shift(1)
df = df.assign(dup=(df["ts"] - prev_ts) <= win)
keep = df[~df.groupby(["sid", "qid"])["dup"].shift(-1, fill_value=False)] # 재전송이면 마지막 제출만 유지
pv = [parse(QIDX[q], r) for q, r in zip(keep["qid"], keep["raw"])]
return keep.assign(val=[p[0] for p in pv], status=[p[1] for p in pv]).drop(columns="dup"), len(df) - len(keep)
data = inject_noise(make_responses(40, seed=7))
preprocess(data.head(100))
t0 = time.perf_counter()
clean, n_dup = preprocess(data)
dt = time.perf_counter() - t0
print(f"응답 {len(data):,}건 전처리 {dt * 1000:.0f} ms, {len(data) / dt:,.0f}건/초 (목표 1,000건/초 이상: {'달성' if len(data) / dt >= 1000 else '미달'})")
print(f"재전송 중복 제거 {n_dup}건, 남은 응답 {len(clean):,}건(응시자 120명 x 32문항 = 3,840건과 일치: {len(clean) == 3840})")
print("상태 분포:", clean["status"].value_counts().to_dict())
err = clean[(clean["status"] == "정상") & clean.apply(lambda r: QIDX[r.qid]["type"] == "MC" and r.val not in "1234", axis=1)]
print(f"전처리 오류(정상인데 파싱 실패) {len(err)}건, 오류율 {len(err) / len(clean):.1%}")
# 단위 테스트 5종
Q = QIDX
tests = {
"T1 전각 숫자 '3' -> 3": parse(Q["Q01"], "3")[0] == "3",
"T2 원문자·영문·'번' 표기 통일": [parse(Q["Q01"], s)[0] for s in ["③", "c)", "3번", " 3 "]] == ["3"] * 4,
"T3 단답 공백·어미 제거": parse(Q["Q24"], "지 휘 관 입니다")[0] == "지휘관" and parse(Q["Q21"], "IFF")[0] == "iff",
"T4 순서형 구분자 제거": parse(Q["Q30"], "a-b-c-d") == ("ABCD", "정상") and parse(Q["Q30"], "A,B,B,D")[1] == "형식오류",
"T5 미응답 보존(결측 보정율 100%)": (clean["status"] == "미응답").sum() == (data.drop_duplicates(["sid", "qid"])["raw"] == "").sum(),
}
for k, v in tests.items():
print(f"[{'PASS' if v else 'FAIL'}] {k}")
print(f"단위 테스트 {sum(tests.values())}/5 통과")
print("\n[전처리 전후 예시]")
ex = clean[(clean["raw"] != clean["val"]) & clean["qid"].isin(["Q05", "Q13", "Q21", "Q22", "Q24", "Q25", "Q29", "Q31"])].groupby("qid").head(1)
print(ex[["qid", "raw", "val", "status"]].rename(columns={"raw": "원본", "val": "정제값", "status": "상태"}).to_string(index=False))
print("통신 지연 중대는 재전송 판단 시간을 30초로 넓혀 중복 제출을 놓치지 않는다.")
st = clean.groupby(["unit", "status"]).size().unstack(fill_value=0)
st.index = ["Co.1", "Co.2 (delayed comm)", "Co.3"]
st.columns = st.columns.map({"정상": "valid", "미응답": "blank", "형식오류": "format error"})
st.plot(kind="bar", stacked=True, figsize=(7, 4))
plt.title("Response status after preprocessing (synthetic)"); plt.ylabel("responses"); plt.xticks(rotation=0)
plt.tight_layout(); plt.show()
실행 결과 예시
응답 3,955건 전처리 18 ms, 220,239건/초 (목표 1,000건/초 이상: 달성)
재전송 중복 제거 115건, 남은 응답 3,840건(응시자 120명 x 32문항 = 3,840건과 일치: True)
상태 분포: {'정상': 3753, '미응답': 71, '형식오류': 16}
전처리 오류(정상인데 파싱 실패) 0건, 오류율 0.0%
[PASS] T1 전각 숫자 '3' -> 3
[PASS] T2 원문자·영문·'번' 표기 통일
[PASS] T3 단답 공백·어미 제거
[PASS] T4 순서형 구분자 제거
[PASS] T5 미응답 보존(결측 보정율 100%)
단위 테스트 5/5 통과
[전처리 전후 예시]
qid 원본 정제값 상태
Q05 b) 2 정상
Q13 3 3 정상
Q21 피아식별임 피아식별 정상
Q25 잘못된 경보 잘못된경보 정상
Q31 A C D ACD 정상
Q22 de lta delta 정상
Q24 지 휘 관 지휘관 정상
Q29 A,B,C,D ABCD 정상
통신 지연 중대는 재전송 판단 시간을 30초로 넓혀 중복 제출을 놓치지 않는다.
미션 3. 선다형 및 단답형 방공 퀴즈 자동 채점 Baseline 로직 구현
- 난이도: 중급 / 예상 소요시간: 2.5시간
- 코드 설명: 선다형·단답형·순서형 방공 퀴즈를 자동 채점하는 Baseline을 구현한다. 정확 일치 채점과 오탈자 허용 채점의 정확도(교관 기준 대비)·처리 지연을 비교하고, 응시자별 점수와 합격 여부, 실행 로그를 출력한다.
"""선다형·단답형·순서형 방공 퀴즈를 자동 채점하는 Baseline을 구현한다. 정확 일치 채점과 오탈자 허용 채점의 정확도(교관 기준 대비)·처리 지연을 비교하고, 응시자별 점수와 합격 여부, 실행 로그를 출력한다."""
import difflib
import re
import time
import unicodedata
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
# ---------------------------------------------------------------------------
# [교육용가상자료] 방공·드론 식별 퀴즈 문항 은행과 가상 응시자 응답 생성기
# 문항은 일반 원칙 수준의 가상 문항이며 실제 교범·교전규칙 원문이 아니다. 응시자는 가명(S001~)이다.
# ---------------------------------------------------------------------------
AREAS = ["형상식별", "피아식별", "경보보고", "교전통제", "대드론일반"]
ETYPES = ["개념 혼동", "피아 오인", "절차 누락"]
POINTS = {1: 2, 2: 3, 3: 5}
C, F, P = ETYPES
def _mc(qid, area, diff, stem, opts, ans, dist):
return {"qid": qid, "type": "MC", "area": area, "diff": diff, "stem": stem, "options": opts, "answer": ans,
"dist": dict(zip([i for i in range(1, 5) if i != ans], dist))}
def _sa(qid, area, diff, stem, accept, wrong):
return {"qid": qid, "type": "SA", "area": area, "diff": diff, "stem": stem, "accept": accept, "wrong": wrong}
def _order(qid, area, diff, stem):
return {"qid": qid, "type": "ORDER", "area": area, "diff": diff, "stem": stem, "answer": "ABCD"}
BANK = [
_mc("Q01", "형상식별", 1, "꼬리날개 없이 삼각형 날개를 가진 소형 고정익 실루엣은?", ["쿼드콥터형", "삼각익 고정익형", "회전익 헬기형", "조류"], 2, [C, C, F]),
_mc("Q02", "형상식별", 1, "회전날개 4개가 십자로 배치된 실루엣은?", ["고정익 드론형", "쿼드콥터형", "순항미사일형", "탄도미사일형"], 2, [C, C, C]),
_mc("Q03", "형상식별", 2, "가늘고 긴 동체 중간에 작은 날개가 붙은 실루엣은?", ["순항미사일형", "탄도미사일형", "민간 경비행기형", "조류"], 1, [C, F, F]),
_mc("Q04", "형상식별", 3, "원거리라 실루엣 식별이 어려울 때 먼저 할 행동은?", ["추정 유형으로 확정 보고", "관측 정보를 그대로 보고하고 추가 관측", "관측 중단", "민간기로 간주"], 2, [P, P, F]),
_mc("Q05", "피아식별", 2, "식별 신호 응답이 없는 항공기에 대한 올바른 판단은?", ["즉시 적기로 확정", "미확인 항공기로 분류하고 추가 확인", "아군기로 간주", "무시"], 2, [F, F, P]),
_mc("Q06", "피아식별", 2, "운항 정보와 일치하는 저속 비행체의 분류는?", ["적 드론", "민간기 가능성 높음, 추가 확인", "순항미사일", "아군 정찰기로 확정"], 2, [F, C, F]),
_mc("Q07", "피아식별", 3, "아군 드론 운용 공지가 있는 공역에서 미확인 드론을 보면 우선 확인할 것은?", ["기상 정보", "아군 운용 계획과 식별 신호", "대응 준비 상태", "주변 지형"], 2, [C, P, C]),
_mc("Q08", "피아식별", 3, "조류 무리와 소형 드론 군집 구분에 가장 도움이 되는 정보는?", ["크기만 비교", "궤적의 규칙성과 열 신호", "색상", "관측 시각"], 2, [C, C, C]),
_mc("Q09", "경보보고", 1, "공중 위협 최초 보고에 포함하지 않아도 되는 것은?", ["관측 시각", "방향과 거리", "추정 유형", "관측자 개인 연락처"], 4, [P, P, P]),
_mc("Q10", "경보보고", 2, "경보 전파 후 상황이 바뀌면?", ["최초 보고 유지", "변경 사항을 즉시 추가 보고", "상황 종료 후 일괄 보고", "개인 판단으로 경보 해제"], 2, [P, P, P]),
_mc("Q11", "경보보고", 2, "보고 내용이 불확실할 때 올바른 표현은?", ["확정 표현으로 보고", "'추정'임을 밝히고 근거와 함께 보고", "보고 보류", "다른 관측자 보고를 복사"], 2, [C, P, P]),
_mc("Q12", "경보보고", 1, "드론 탐지 후 보고 경로로 알맞은 것은?", ["인접 부대에만 전파", "지휘계통을 따라 보고", "개인 메신저로 공유", "보고 없이 대응"], 2, [P, P, P]),
_mc("Q13", "교전통제", 1, "AI 탐지 결과만으로 대응을 결정할 수 있는가?", ["가능하다", "인간 지휘관의 승인이 필요하다", "신뢰도 90% 이상이면 가능", "야간에만 가능"], 2, [C, C, C]),
_mc("Q14", "교전통제", 2, "민간 피해 가능성이 확인되면?", ["그대로 진행", "즉시 보고하고 지휘관 판단을 받는다", "조치 후 보고", "무시"], 2, [P, P, F]),
_mc("Q15", "교전통제", 3, "식별이 불확실한 표적에 대한 원칙은?", ["위협으로 간주해 즉시 대응", "식별 확인 전까지 승인 절차를 따른다", "무조건 무시", "다수결로 결정"], 2, [F, F, C]),
_mc("Q16", "교전통제", 2, "판단 근거를 기록해야 하는 이유는?", ["형식상 필요", "사후 검토와 책임 추적", "보고 생략", "기록 불필요"], 2, [C, P, P]),
_mc("Q17", "대드론일반", 1, "대드론 체계의 일반적 처리 순서는?", ["대응-탐지-식별", "탐지-식별-보고·판단-대응", "식별-대응-탐지", "보고-탐지-대응"], 2, [P, P, P]),
_mc("Q18", "대드론일반", 3, "레이더만으로 조류와 드론 구분이 어려운 이유는?", ["반사 신호 크기가 비슷할 수 있어서", "레이더는 야간에 못 써서", "드론에는 금속이 없어서", "조류는 탐지되지 않아서"], 1, [C, C, C]),
_mc("Q19", "대드론일반", 2, "전자광학(EO) 센서의 약점이 커지는 조건은?", ["맑은 주간", "안개·야간", "저속 표적", "근거리"], 2, [C, C, C]),
_mc("Q20", "대드론일반", 2, "경보가 반복해서 오경보로 판명되면?", ["경보 장치를 끈다", "사례를 기록·보고하고 기준 재검토 요청", "무시한다", "모두 실제 위협으로 간주"], 2, [P, P, F]),
_sa("Q21", "피아식별", 1, "아군·적·민간을 구분하는 절차를 무엇이라 하는가?", ["피아식별", "IFF"], {"표적획득": C, "적기판정": F, "경계": C}),
_sa("Q22", "형상식별", 2, "꼬리날개 없는 삼각형 날개 형태를 무엇이라 하는가?", ["삼각익", "델타익", "delta"], {"직선익": C, "후퇴익": C, "쿼드콥터": C}),
_sa("Q23", "경보보고", 2, "불확실한 관측을 보고할 때 붙이는 표현은?", ["추정"], {"확정": C, "확인": C, "생략": P}),
_sa("Q24", "교전통제", 1, "AI 탐지 결과에 대해 최종 결정을 내리는 주체는?", ["지휘관"], {"AI": C, "운용병": P, "레이더": C}),
_sa("Q25", "대드론일반", 1, "조류를 드론으로 잘못 경보하는 것을 무엇이라 하는가?", ["오경보", "오탐", "false alarm"], {"미탐": C, "재경보": C, "적기": F}),
_sa("Q26", "피아식별", 2, "운항 정보가 확인된 여객기는 어떻게 분류하는가?", ["민간기", "민항기"], {"적기": F, "미확인기": P, "아군기": F}),
_sa("Q27", "형상식별", 1, "회전날개 4개를 가진 드론 형태는?", ["쿼드콥터", "quadcopter"], {"헥사콥터": C, "고정익": C, "헬기": F}),
_sa("Q28", "경보보고", 1, "보고는 무엇을 따라 올라가는가?", ["지휘계통"], {"인접부대": P, "개인메신저": P, "통신망": C}),
_order("Q29", "경보보고", 2, "최초 보고 순서: A 관측 시각, B 방향·거리, C 추정 유형, D 관측자 조치"),
_order("Q30", "대드론일반", 2, "처리 순서: A 탐지, B 식별, C 보고·판단, D 대응"),
_order("Q31", "교전통제", 3, "통제 순서: A 식별 확인, B 상급 승인 요청, C 승인 수령, D 조치 기록"),
_order("Q32", "피아식별", 3, "확인 순서: A 운항정보 대조, B 식별 신호 확인, C 미확인 분류, D 보고"),
]
QIDX = {q["qid"]: q for q in BANK}
UNITS = ["가상1중대", "가상2중대", "가상3중대"]
_CIRC = "①②③④"
PARA = {"Q21": "아군적군 구분", "Q22": "세모 날개", "Q23": "추정됨으로 표기", "Q24": "중대장(지휘관)", "Q25": "잘못된 경보",
"Q26": "민간 여객기", "Q27": "4발 드론", "Q28": "지휘 라인"} # 뜻은 맞지만 인정 답안 목록에 없는 표현
def make_soldiers(n_per_unit, seed):
rng = np.random.default_rng(seed)
out = []
for u, unit in enumerate(UNITS):
base = [0.3, -0.1, 0.0][u]
for i in range(n_per_unit):
off = rng.normal(0, 0.5, len(AREAS)) + (np.array([0, 0, 0, -0.6, 0]) if u == 1 else 0)
out.append({"sid": f"S{len(out) + 1:03d}", "unit": unit, "theta": dict(zip(AREAS, base + rng.normal(0, 0.6) + off)),
"tend": dict(zip(ETYPES, rng.dirichlet([1.2, 1.2, 1.2])))})
return out
def p_correct(theta, diff):
return 1 / (1 + np.exp(-1.7 * (theta - (diff - 2) * 0.9 + 0.9)))
def _typo(s, rng):
if len(s) < 2:
return s
i = int(rng.integers(len(s) - 1))
return s[:i] + s[i + 1] + s[i] + s[i + 2:] if len(s) <= 3 or rng.random() < 0.5 else s[:i] + s[i + 1:]
def _fmt_mc(k, rng):
r = rng.random()
if r < 0.55:
return str(k)
return rng.choice([_CIRC[k - 1], f"{k}번", "ABCD"[k - 1], "abcd"[k - 1] + ")", f" {k} ", chr(0xFF10 + k), f"{k}."])
def _fmt_sa(s, rng):
r = rng.random()
if r < 0.45:
return s
if r < 0.6:
return " ".join(s) if len(s) <= 4 else s[:2] + " " + s[2:]
if r < 0.72:
return s + rng.choice(["입니다", "이다", ".", "임"])
if r < 0.8:
return s.upper() if s.isascii() else "".join(chr(ord(ch) + 0xFEE0) if "!" <= ch <= "~" else ch for ch in s) + " "
return _typo(s, rng)
def answer(q, sol, rng):
"""한 문항 응답: (원본 응답 문자열, 의도한 정답 여부, 의도한 답, 오답 유형)."""
ok = rng.random() < p_correct(sol["theta"][q["area"]], q["diff"])
if q["type"] == "MC":
if ok:
k, et = q["answer"], None
else:
ks = list(q["dist"]); w = np.array([sol["tend"][q["dist"][k]] for k in ks])
k = int(rng.choice(ks, p=w / w.sum())); et = q["dist"][k]
return _fmt_mc(k, rng), ok, str(k), et
if q["type"] == "SA":
if ok and rng.random() < 0.06:
return PARA[q["qid"]], ok, PARA[q["qid"]], None
if ok:
s, et = str(rng.choice(q["accept"])), None
else:
ws = list(q["wrong"]); w = np.array([sol["tend"][q["wrong"][x]] for x in ws])
s = str(rng.choice(ws, p=w / w.sum())); et = q["wrong"][s]
return _fmt_sa(s, rng), ok, s, et
if ok:
s = "ABCD"
elif rng.random() < 0.5:
s = "ABCD".replace("ABCD"[rng.integers(1, 4)], "") # 단계 하나 빠뜨림
else:
i = int(rng.integers(0, 3)); s = list("ABCD"); s[i], s[i + 1] = s[i + 1], s[i]; s = "".join(s) # 순서 뒤바뀜
raw = s if rng.random() < 0.6 else rng.choice(["-".join(s), " ".join(s), s.lower(), ",".join(s)])
return raw, ok, s, (None if ok else P)
def make_responses(n_per_unit=40, seed=7, blank=0.02):
"""응시자 x 문항 응답표(DataFrame)를 만든다. truth_* 열은 교관 기준 정답(평가용)이다."""
rng = np.random.default_rng(seed)
rows = []
for sol in make_soldiers(n_per_unit, seed):
for q in BANK:
raw, ok, intended, et = answer(q, sol, rng)
if rng.random() < blank:
raw, ok, intended, et = "", False, "", None
rows.append({"rid": f"R{len(rows):05d}", "sid": sol["sid"], "unit": sol["unit"], "qid": q["qid"], "raw": raw,
"truth_correct": ok, "truth_answer": intended, "truth_etype": et})
return pd.DataFrame(rows)
# ---------------------------------------------------------------------------
# ---- 응답 정제·파싱·채점 공통 함수 ----
TAILS = ("입니다", "이다", "임")
def norm_text(s):
"""전각->반각(NFKC), 소문자, 공백·문장부호 제거, 끝맺음 어미 제거."""
s = unicodedata.normalize("NFKC", str(s)).strip().lower()
s = re.sub(r"[\s\.\,\-\(\)·]", "", s)
for t in TAILS:
if s.endswith(t) and len(s) > len(t) + 1:
s = s[:-len(t)]
return s
def parse(q, raw):
"""원본 응답 -> (정제값, 상태). 상태: 정상 / 미응답 / 형식오류"""
if raw is None or str(raw).strip() == "":
return "", "미응답"
s = unicodedata.normalize("NFKC", str(raw)).strip()
if q["type"] == "MC":
t = re.sub(r"[\s번\.\)]", "", s).lower()
t = {"a": "1", "b": "2", "c": "3", "d": "4"}.get(t, t)
return (t, "정상") if t in ("1", "2", "3", "4") else (t, "형식오류")
if q["type"] == "ORDER":
t = re.sub(r"[^A-Za-z]", "", s).upper()
return (t, "정상") if t and set(t) <= set("ABCD") and len(set(t)) == len(t) else (t, "형식오류")
return norm_text(s), "정상"
def grade_exact(q, val):
if q["type"] == "MC":
return val == str(q["answer"])
if q["type"] == "ORDER":
return val == q["answer"]
return val in {norm_text(a) for a in q["accept"]}
def grade_fuzzy(q, val, cut=0.75):
"""단답형은 오탈자(글자 순서 바뀜·한 글자 누락)를 허용(유사도 cut 이상)하되, 알려진 오답과 더 가까우면 오답으로 본다."""
if grade_exact(q, val) or q["type"] != "SA" or len(val) < 2:
return grade_exact(q, val)
best = max(1.0 if sorted(val) == sorted(norm_text(a)) else difflib.SequenceMatcher(None, val, norm_text(a)).ratio() for a in q["accept"])
worst = max(difflib.SequenceMatcher(None, val, norm_text(w)).ratio() for w in q["wrong"])
return best >= cut and best > worst
def score_of(q, correct):
return POINTS[q["diff"]] if correct else 0
PASS_RATE = 0.7
df = make_responses(40, seed=7)
df["type"] = df["qid"].map(lambda q: QIDX[q]["type"])
pv = [parse(QIDX[q], r) for q, r in zip(df["qid"], df["raw"])]
df["val"] = [p[0] for p in pv]
t0 = time.perf_counter()
df["exact"] = [grade_exact(QIDX[q], v) for q, v in zip(df["qid"], df["val"])]
t1 = time.perf_counter()
df["fuzzy"] = [grade_fuzzy(QIDX[q], v) for q, v in zip(df["qid"], df["val"])]
t2 = time.perf_counter()
rows = []
for name, col, t in [("정확 일치", "exact", t1 - t0), ("오탈자 허용", "fuzzy", t2 - t1)]:
rows.append({"채점 방식": name, "채점 정확도": (df[col] == df["truth_correct"]).mean(),
**{f"{k} 정확도": (g[col] == g["truth_correct"]).mean() for k, g in df.groupby("type")},
"건당 ms": t / len(df) * 1000})
res = pd.DataFrame(rows)
print(f"[교육용가상자료] 응시자 120명 x 32문항 = {len(df):,}건 채점 (교관 기준 정답과 비교)")
print(res.round(4).to_string(index=False))
acc = res.loc[1, "채점 정확도"]
print(f"\n목표(채점 정확도 75% 이상): {'달성' if acc >= 0.75 else '미달'} ({acc:.1%}), 건당 처리 {res.loc[1, '건당 ms']:.3f} ms")
print("\n[정확 일치 채점이 틀린 사례(오탈자·띄어쓰기)]")
miss = df[(df["exact"] != df["truth_correct"])].groupby("qid").head(1).head(5)
print(miss[["qid", "raw", "val", "truth_correct", "exact", "fuzzy"]].rename(columns={"truth_correct": "교관 판정", "exact": "정확일치", "fuzzy": "오탈자허용"}).to_string(index=False))
print("오탈자 허용 채점도 놓치는 '뜻은 맞지만 인정 답안 목록에 없는 표현'은 이의 제기·교관 검토(미션 7) 대상으로 넘긴다.")
print("\n[휴리스틱 1차 튜닝: 유사도 기준값]")
sa = df[df["type"] == "SA"]
for cut in [0.6, 0.7, 0.75, 0.8, 0.9]:
a = np.mean([grade_fuzzy(QIDX[q], v, cut) == t for q, v, t in zip(sa["qid"], sa["val"], sa["truth_correct"])])
print(f"기준 {cut}: 단답형 채점 정확도 {a:.3f}")
df["score"] = [score_of(QIDX[q], c) for q, c in zip(df["qid"], df["fuzzy"])]
full = sum(POINTS[q["diff"]] for q in BANK)
sc = df.groupby(["sid", "unit"])["score"].sum().reset_index()
sc["합격"] = sc["score"] >= full * PASS_RATE
print(f"\n만점 {full}점, 평균 {sc['score'].mean():.1f}점, 합격률 {sc['합격'].mean():.1%}")
print(sc.groupby("unit").agg(평균점수=("score", "mean"), 합격률=("합격", "mean")).round(2).to_string())
print(f"실행 로그: 문항 {len(BANK)}개, 응답 {len(df):,}건, 미응답 {(df['raw'] == '').sum()}건, 채점 예외 0건")
plt.figure(figsize=(7, 4))
plt.hist(sc["score"], bins=15, color="tab:blue"); plt.axvline(full * PASS_RATE, color="red", ls="--", label="pass line (70%)")
plt.xlabel("total score"); plt.ylabel("soldiers"); plt.legend(); plt.title("Auto-graded score distribution (synthetic)"); plt.show()
실행 결과 예시
[교육용가상자료] 응시자 120명 x 32문항 = 3,840건 채점 (교관 기준 정답과 비교)
채점 방식 채점 정확도 MC 정확도 ORDER 정확도 SA 정확도 건당 ms
정확 일치 0.9516 1.0 1.0 0.8062 0.0017
오탈자 허용 0.9901 1.0 1.0 0.9604 0.0070
목표(채점 정확도 75% 이상): 달성 (99.0%), 건당 처리 0.007 ms
[정확 일치 채점이 틀린 사례(오탈자·띄어쓰기)]
qid raw val 교관 판정 정확일치 오탈자허용
Q25 잘못된 경보 잘못된경보 True False False
Q21 아군적군 구분 아군적군구분 True False False
Q28 지휘통 지휘통 True False True
Q22 타델익 타델익 True False True
Q26 민기간 민기간 True False True
오탈자 허용 채점도 놓치는 '뜻은 맞지만 인정 답안 목록에 없는 표현'은 이의 제기·교관 검토(미션 7) 대상으로 넘긴다.
[휴리스틱 1차 튜닝: 유사도 기준값]
기준 0.6: 단답형 채점 정확도 0.965
기준 0.7: 단답형 채점 정확도 0.960
기준 0.75: 단답형 채점 정확도 0.960
기준 0.8: 단답형 채점 정확도 0.956
기준 0.9: 단답형 채점 정확도 0.935
만점 99점, 평균 67.4점, 합격률 50.8%
평균점수 합격률
unit
가상1중대 75.40 0.68
가상2중대 58.55 0.28
가상3중대 68.18 0.57
실행 로그: 문항 32개, 응답 3,840건, 미응답 72건, 채점 예외 0건
미션 4. 오답 유형(개념 혼동, 피아 오인, 절차 누락) 자동 분류 및 패턴 분석 엔진 고도화
- 난이도: 중급 / 예상 소요시간: 3.0시간
- 코드 설명: 오답을 개념 혼동·피아 오인·절차 누락 유형으로 자동 분류하는 엔진을 고도화한다. 보기 매핑·순서 규칙·오답 목록 대조에 문자 n-gram 분류 모델을 더해, 복합 오답 시나리오 10종에서 유형별 재현율과 처리 지연을 검증하고 개인·중대별 오답 패턴을 분석한다.
"""오답을 개념 혼동·피아 오인·절차 누락 유형으로 자동 분류하는 엔진을 고도화한다. 보기 매핑·순서 규칙·오답 목록 대조에 문자 n-gram 분류 모델을 더해, 복합 오답 시나리오 10종에서 유형별 재현율과 처리 지연을 검증하고 개인·중대별 오답 패턴을 분석한다."""
import difflib
import re
import time
import unicodedata
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import make_pipeline
# ---------------------------------------------------------------------------
# [교육용가상자료] 방공·드론 식별 퀴즈 문항 은행과 가상 응시자 응답 생성기
# 문항은 일반 원칙 수준의 가상 문항이며 실제 교범·교전규칙 원문이 아니다. 응시자는 가명(S001~)이다.
# ---------------------------------------------------------------------------
AREAS = ["형상식별", "피아식별", "경보보고", "교전통제", "대드론일반"]
ETYPES = ["개념 혼동", "피아 오인", "절차 누락"]
POINTS = {1: 2, 2: 3, 3: 5}
C, F, P = ETYPES
def _mc(qid, area, diff, stem, opts, ans, dist):
return {"qid": qid, "type": "MC", "area": area, "diff": diff, "stem": stem, "options": opts, "answer": ans,
"dist": dict(zip([i for i in range(1, 5) if i != ans], dist))}
def _sa(qid, area, diff, stem, accept, wrong):
return {"qid": qid, "type": "SA", "area": area, "diff": diff, "stem": stem, "accept": accept, "wrong": wrong}
def _order(qid, area, diff, stem):
return {"qid": qid, "type": "ORDER", "area": area, "diff": diff, "stem": stem, "answer": "ABCD"}
BANK = [
_mc("Q01", "형상식별", 1, "꼬리날개 없이 삼각형 날개를 가진 소형 고정익 실루엣은?", ["쿼드콥터형", "삼각익 고정익형", "회전익 헬기형", "조류"], 2, [C, C, F]),
_mc("Q02", "형상식별", 1, "회전날개 4개가 십자로 배치된 실루엣은?", ["고정익 드론형", "쿼드콥터형", "순항미사일형", "탄도미사일형"], 2, [C, C, C]),
_mc("Q03", "형상식별", 2, "가늘고 긴 동체 중간에 작은 날개가 붙은 실루엣은?", ["순항미사일형", "탄도미사일형", "민간 경비행기형", "조류"], 1, [C, F, F]),
_mc("Q04", "형상식별", 3, "원거리라 실루엣 식별이 어려울 때 먼저 할 행동은?", ["추정 유형으로 확정 보고", "관측 정보를 그대로 보고하고 추가 관측", "관측 중단", "민간기로 간주"], 2, [P, P, F]),
_mc("Q05", "피아식별", 2, "식별 신호 응답이 없는 항공기에 대한 올바른 판단은?", ["즉시 적기로 확정", "미확인 항공기로 분류하고 추가 확인", "아군기로 간주", "무시"], 2, [F, F, P]),
_mc("Q06", "피아식별", 2, "운항 정보와 일치하는 저속 비행체의 분류는?", ["적 드론", "민간기 가능성 높음, 추가 확인", "순항미사일", "아군 정찰기로 확정"], 2, [F, C, F]),
_mc("Q07", "피아식별", 3, "아군 드론 운용 공지가 있는 공역에서 미확인 드론을 보면 우선 확인할 것은?", ["기상 정보", "아군 운용 계획과 식별 신호", "대응 준비 상태", "주변 지형"], 2, [C, P, C]),
_mc("Q08", "피아식별", 3, "조류 무리와 소형 드론 군집 구분에 가장 도움이 되는 정보는?", ["크기만 비교", "궤적의 규칙성과 열 신호", "색상", "관측 시각"], 2, [C, C, C]),
_mc("Q09", "경보보고", 1, "공중 위협 최초 보고에 포함하지 않아도 되는 것은?", ["관측 시각", "방향과 거리", "추정 유형", "관측자 개인 연락처"], 4, [P, P, P]),
_mc("Q10", "경보보고", 2, "경보 전파 후 상황이 바뀌면?", ["최초 보고 유지", "변경 사항을 즉시 추가 보고", "상황 종료 후 일괄 보고", "개인 판단으로 경보 해제"], 2, [P, P, P]),
_mc("Q11", "경보보고", 2, "보고 내용이 불확실할 때 올바른 표현은?", ["확정 표현으로 보고", "'추정'임을 밝히고 근거와 함께 보고", "보고 보류", "다른 관측자 보고를 복사"], 2, [C, P, P]),
_mc("Q12", "경보보고", 1, "드론 탐지 후 보고 경로로 알맞은 것은?", ["인접 부대에만 전파", "지휘계통을 따라 보고", "개인 메신저로 공유", "보고 없이 대응"], 2, [P, P, P]),
_mc("Q13", "교전통제", 1, "AI 탐지 결과만으로 대응을 결정할 수 있는가?", ["가능하다", "인간 지휘관의 승인이 필요하다", "신뢰도 90% 이상이면 가능", "야간에만 가능"], 2, [C, C, C]),
_mc("Q14", "교전통제", 2, "민간 피해 가능성이 확인되면?", ["그대로 진행", "즉시 보고하고 지휘관 판단을 받는다", "조치 후 보고", "무시"], 2, [P, P, F]),
_mc("Q15", "교전통제", 3, "식별이 불확실한 표적에 대한 원칙은?", ["위협으로 간주해 즉시 대응", "식별 확인 전까지 승인 절차를 따른다", "무조건 무시", "다수결로 결정"], 2, [F, F, C]),
_mc("Q16", "교전통제", 2, "판단 근거를 기록해야 하는 이유는?", ["형식상 필요", "사후 검토와 책임 추적", "보고 생략", "기록 불필요"], 2, [C, P, P]),
_mc("Q17", "대드론일반", 1, "대드론 체계의 일반적 처리 순서는?", ["대응-탐지-식별", "탐지-식별-보고·판단-대응", "식별-대응-탐지", "보고-탐지-대응"], 2, [P, P, P]),
_mc("Q18", "대드론일반", 3, "레이더만으로 조류와 드론 구분이 어려운 이유는?", ["반사 신호 크기가 비슷할 수 있어서", "레이더는 야간에 못 써서", "드론에는 금속이 없어서", "조류는 탐지되지 않아서"], 1, [C, C, C]),
_mc("Q19", "대드론일반", 2, "전자광학(EO) 센서의 약점이 커지는 조건은?", ["맑은 주간", "안개·야간", "저속 표적", "근거리"], 2, [C, C, C]),
_mc("Q20", "대드론일반", 2, "경보가 반복해서 오경보로 판명되면?", ["경보 장치를 끈다", "사례를 기록·보고하고 기준 재검토 요청", "무시한다", "모두 실제 위협으로 간주"], 2, [P, P, F]),
_sa("Q21", "피아식별", 1, "아군·적·민간을 구분하는 절차를 무엇이라 하는가?", ["피아식별", "IFF"], {"표적획득": C, "적기판정": F, "경계": C}),
_sa("Q22", "형상식별", 2, "꼬리날개 없는 삼각형 날개 형태를 무엇이라 하는가?", ["삼각익", "델타익", "delta"], {"직선익": C, "후퇴익": C, "쿼드콥터": C}),
_sa("Q23", "경보보고", 2, "불확실한 관측을 보고할 때 붙이는 표현은?", ["추정"], {"확정": C, "확인": C, "생략": P}),
_sa("Q24", "교전통제", 1, "AI 탐지 결과에 대해 최종 결정을 내리는 주체는?", ["지휘관"], {"AI": C, "운용병": P, "레이더": C}),
_sa("Q25", "대드론일반", 1, "조류를 드론으로 잘못 경보하는 것을 무엇이라 하는가?", ["오경보", "오탐", "false alarm"], {"미탐": C, "재경보": C, "적기": F}),
_sa("Q26", "피아식별", 2, "운항 정보가 확인된 여객기는 어떻게 분류하는가?", ["민간기", "민항기"], {"적기": F, "미확인기": P, "아군기": F}),
_sa("Q27", "형상식별", 1, "회전날개 4개를 가진 드론 형태는?", ["쿼드콥터", "quadcopter"], {"헥사콥터": C, "고정익": C, "헬기": F}),
_sa("Q28", "경보보고", 1, "보고는 무엇을 따라 올라가는가?", ["지휘계통"], {"인접부대": P, "개인메신저": P, "통신망": C}),
_order("Q29", "경보보고", 2, "최초 보고 순서: A 관측 시각, B 방향·거리, C 추정 유형, D 관측자 조치"),
_order("Q30", "대드론일반", 2, "처리 순서: A 탐지, B 식별, C 보고·판단, D 대응"),
_order("Q31", "교전통제", 3, "통제 순서: A 식별 확인, B 상급 승인 요청, C 승인 수령, D 조치 기록"),
_order("Q32", "피아식별", 3, "확인 순서: A 운항정보 대조, B 식별 신호 확인, C 미확인 분류, D 보고"),
]
QIDX = {q["qid"]: q for q in BANK}
UNITS = ["가상1중대", "가상2중대", "가상3중대"]
_CIRC = "①②③④"
PARA = {"Q21": "아군적군 구분", "Q22": "세모 날개", "Q23": "추정됨으로 표기", "Q24": "중대장(지휘관)", "Q25": "잘못된 경보",
"Q26": "민간 여객기", "Q27": "4발 드론", "Q28": "지휘 라인"} # 뜻은 맞지만 인정 답안 목록에 없는 표현
def make_soldiers(n_per_unit, seed):
rng = np.random.default_rng(seed)
out = []
for u, unit in enumerate(UNITS):
base = [0.3, -0.1, 0.0][u]
for i in range(n_per_unit):
off = rng.normal(0, 0.5, len(AREAS)) + (np.array([0, 0, 0, -0.6, 0]) if u == 1 else 0)
out.append({"sid": f"S{len(out) + 1:03d}", "unit": unit, "theta": dict(zip(AREAS, base + rng.normal(0, 0.6) + off)),
"tend": dict(zip(ETYPES, rng.dirichlet([1.2, 1.2, 1.2])))})
return out
def p_correct(theta, diff):
return 1 / (1 + np.exp(-1.7 * (theta - (diff - 2) * 0.9 + 0.9)))
def _typo(s, rng):
if len(s) < 2:
return s
i = int(rng.integers(len(s) - 1))
return s[:i] + s[i + 1] + s[i] + s[i + 2:] if len(s) <= 3 or rng.random() < 0.5 else s[:i] + s[i + 1:]
def _fmt_mc(k, rng):
r = rng.random()
if r < 0.55:
return str(k)
return rng.choice([_CIRC[k - 1], f"{k}번", "ABCD"[k - 1], "abcd"[k - 1] + ")", f" {k} ", chr(0xFF10 + k), f"{k}."])
def _fmt_sa(s, rng):
r = rng.random()
if r < 0.45:
return s
if r < 0.6:
return " ".join(s) if len(s) <= 4 else s[:2] + " " + s[2:]
if r < 0.72:
return s + rng.choice(["입니다", "이다", ".", "임"])
if r < 0.8:
return s.upper() if s.isascii() else "".join(chr(ord(ch) + 0xFEE0) if "!" <= ch <= "~" else ch for ch in s) + " "
return _typo(s, rng)
def answer(q, sol, rng):
"""한 문항 응답: (원본 응답 문자열, 의도한 정답 여부, 의도한 답, 오답 유형)."""
ok = rng.random() < p_correct(sol["theta"][q["area"]], q["diff"])
if q["type"] == "MC":
if ok:
k, et = q["answer"], None
else:
ks = list(q["dist"]); w = np.array([sol["tend"][q["dist"][k]] for k in ks])
k = int(rng.choice(ks, p=w / w.sum())); et = q["dist"][k]
return _fmt_mc(k, rng), ok, str(k), et
if q["type"] == "SA":
if ok and rng.random() < 0.06:
return PARA[q["qid"]], ok, PARA[q["qid"]], None
if ok:
s, et = str(rng.choice(q["accept"])), None
else:
ws = list(q["wrong"]); w = np.array([sol["tend"][q["wrong"][x]] for x in ws])
s = str(rng.choice(ws, p=w / w.sum())); et = q["wrong"][s]
return _fmt_sa(s, rng), ok, s, et
if ok:
s = "ABCD"
elif rng.random() < 0.5:
s = "ABCD".replace("ABCD"[rng.integers(1, 4)], "") # 단계 하나 빠뜨림
else:
i = int(rng.integers(0, 3)); s = list("ABCD"); s[i], s[i + 1] = s[i + 1], s[i]; s = "".join(s) # 순서 뒤바뀜
raw = s if rng.random() < 0.6 else rng.choice(["-".join(s), " ".join(s), s.lower(), ",".join(s)])
return raw, ok, s, (None if ok else P)
def make_responses(n_per_unit=40, seed=7, blank=0.02):
"""응시자 x 문항 응답표(DataFrame)를 만든다. truth_* 열은 교관 기준 정답(평가용)이다."""
rng = np.random.default_rng(seed)
rows = []
for sol in make_soldiers(n_per_unit, seed):
for q in BANK:
raw, ok, intended, et = answer(q, sol, rng)
if rng.random() < blank:
raw, ok, intended, et = "", False, "", None
rows.append({"rid": f"R{len(rows):05d}", "sid": sol["sid"], "unit": sol["unit"], "qid": q["qid"], "raw": raw,
"truth_correct": ok, "truth_answer": intended, "truth_etype": et})
return pd.DataFrame(rows)
# ---------------------------------------------------------------------------
# ---- 응답 정제·파싱·채점 공통 함수 ----
TAILS = ("입니다", "이다", "임")
def norm_text(s):
"""전각->반각(NFKC), 소문자, 공백·문장부호 제거, 끝맺음 어미 제거."""
s = unicodedata.normalize("NFKC", str(s)).strip().lower()
s = re.sub(r"[\s\.\,\-\(\)·]", "", s)
for t in TAILS:
if s.endswith(t) and len(s) > len(t) + 1:
s = s[:-len(t)]
return s
def parse(q, raw):
"""원본 응답 -> (정제값, 상태). 상태: 정상 / 미응답 / 형식오류"""
if raw is None or str(raw).strip() == "":
return "", "미응답"
s = unicodedata.normalize("NFKC", str(raw)).strip()
if q["type"] == "MC":
t = re.sub(r"[\s번\.\)]", "", s).lower()
t = {"a": "1", "b": "2", "c": "3", "d": "4"}.get(t, t)
return (t, "정상") if t in ("1", "2", "3", "4") else (t, "형식오류")
if q["type"] == "ORDER":
t = re.sub(r"[^A-Za-z]", "", s).upper()
return (t, "정상") if t and set(t) <= set("ABCD") and len(set(t)) == len(t) else (t, "형식오류")
return norm_text(s), "정상"
def grade_exact(q, val):
if q["type"] == "MC":
return val == str(q["answer"])
if q["type"] == "ORDER":
return val == q["answer"]
return val in {norm_text(a) for a in q["accept"]}
def grade_fuzzy(q, val, cut=0.75):
"""단답형은 오탈자(글자 순서 바뀜·한 글자 누락)를 허용(유사도 cut 이상)하되, 알려진 오답과 더 가까우면 오답으로 본다."""
if grade_exact(q, val) or q["type"] != "SA" or len(val) < 2:
return grade_exact(q, val)
best = max(1.0 if sorted(val) == sorted(norm_text(a)) else difflib.SequenceMatcher(None, val, norm_text(a)).ratio() for a in q["accept"])
worst = max(difflib.SequenceMatcher(None, val, norm_text(w)).ratio() for w in q["wrong"])
return best >= cut and best > worst
def score_of(q, correct):
return POINTS[q["diff"]] if correct else 0
# ---- 오답 유형 분류기(규칙 + 문자 n-gram 로지스틱 회귀) ----
SEED = {"피아 오인": ["적기", "적 드론", "아군기", "적 헬기", "적기판정", "헬기", "적 항공기 확정", "아군 드론"],
"절차 누락": ["생략", "인접부대", "개인메신저", "보고 안 함", "운용병", "미확인기", "단계 건너뜀", "직접 전파"],
"개념 혼동": ["직선익", "후퇴익", "헥사콥터", "미탐", "재경보", "확정", "확인", "레이더", "AI", "표적획득", "경계", "통신망", "고정익"]}
def build_text_model():
texts, labels = [], []
for q in BANK:
if q["type"] == "SA":
for w, et in q["wrong"].items():
texts.append(w); labels.append(et)
for et, ws in SEED.items():
texts += ws; labels += [et] * len(ws)
m = make_pipeline(TfidfVectorizer(analyzer="char_wb", ngram_range=(1, 3)), LogisticRegression(max_iter=2000, C=5))
return m.fit([norm_text(t) for t in texts], labels)
def classify_error(q, val, text_model):
"""오답 1건의 유형과 판단 근거(rule/text)를 돌려준다."""
if q["type"] == "MC":
return q["dist"].get(int(val), "개념 혼동") if val in "1234" and val else "개념 혼동", "보기 매핑"
if q["type"] == "ORDER":
return "절차 누락", ("단계 누락" if len(val) < 4 else "순서 뒤바뀜")
known = {norm_text(w): et for w, et in q["wrong"].items()}
if val in known:
return known[val], "오답 목록"
near = max(known, key=lambda w: difflib.SequenceMatcher(None, val, w).ratio())
if difflib.SequenceMatcher(None, val, near).ratio() >= 0.75 or sorted(val) == sorted(near):
return known[near], "오답 목록(오탈자)"
return str(text_model.predict([val])[0]), "문자 모델"
# [교육용가상자료] 인정 답안·오답 목록에 없는 새로운 오답 표현(문자 모델 시험용)
NOVEL = [("Q21", "적기 확정", "피아 오인"), ("Q21", "표적 추적", "개념 혼동"), ("Q22", "직선 날개", "개념 혼동"), ("Q23", "확실함", "개념 혼동"),
("Q23", "보고 안함", "절차 누락"), ("Q24", "자동 판정", "개념 혼동"), ("Q24", "관측 운용병", "절차 누락"), ("Q25", "미탐지", "개념 혼동"),
("Q25", "적 드론", "피아 오인"), ("Q26", "적 정찰기", "피아 오인"), ("Q26", "보고 생략", "절차 누락"), ("Q27", "고정익 드론", "개념 혼동"),
("Q27", "적 헬기", "피아 오인"), ("Q28", "인접 부대 직보", "절차 누락"), ("Q28", "단체 메신저", "절차 누락"), ("Q21", "아군기", "피아 오인"),
("Q23", "대충 보고", "절차 누락"), ("Q22", "날개 없음", "개념 혼동"), ("Q26", "미확인 표적으로 대응", "피아 오인"),
("Q24", "분대원 누구나", "절차 누락"), ("Q25", "정상 경보", "개념 혼동"), ("Q27", "정찰 헬기", "피아 오인")]
model = build_text_model()
df = make_responses(40, seed=7)
df["type"] = df["qid"].map(lambda q: QIDX[q]["type"])
df["val"] = [parse(QIDX[q], r)[0] for q, r in zip(df["qid"], df["raw"])]
wrong = df[df["truth_etype"].notna()].copy()
t0 = time.perf_counter()
out = [classify_error(QIDX[q], v, model) for q, v in zip(wrong["qid"], wrong["val"])]
ms = (time.perf_counter() - t0) * 1000 / len(wrong)
wrong["pred"], wrong["basis"] = [o[0] for o in out], [o[1] for o in out]
# 복합 오답 시나리오 10종
rng = np.random.default_rng(5)
sa_typo = wrong[(wrong["type"] == "SA")].copy()
sa_typo["val"] = [norm_text(_typo(v, rng)) if len(v) >= 3 else v for v in sa_typo["truth_answer"]]
sa_typo["pred"] = [classify_error(QIDX[q], v, model)[0] for q, v in zip(sa_typo["qid"], sa_typo["val"])]
nov = pd.DataFrame(NOVEL, columns=["qid", "val", "truth_etype"])
nov["val"] = nov["val"].map(norm_text)
nov["pred"] = [classify_error(QIDX[q], v, model)[0] for q, v in zip(nov["qid"], nov["val"])]
raw_fmt = wrong["raw"].str.strip() != wrong["val"]
SCEN = {
"S1 선다형 표준 표기": wrong[(wrong["type"] == "MC") & ~raw_fmt], "S2 선다형 원문자·전각 표기": wrong[(wrong["type"] == "MC") & raw_fmt],
"S3 단답 오답 목록 일치": wrong[(wrong["type"] == "SA") & (wrong["basis"] == "오답 목록")], "S4 단답 오답 + 오탈자": sa_typo,
"S5 단답 오답 + 어미·띄어쓰기": wrong[(wrong["type"] == "SA") & raw_fmt], "S6 순서형 단계 누락": wrong[(wrong["type"] == "ORDER") & (wrong["val"].str.len() < 4)],
"S7 순서형 순서 뒤바뀜": wrong[(wrong["type"] == "ORDER") & (wrong["val"].str.len() == 4)], "S8 순서형 구분자 혼입": wrong[(wrong["type"] == "ORDER") & raw_fmt],
"S9 목록에 없는 새 오답": nov, "S10 전체 혼합": pd.concat([wrong, sa_typo, nov]),
}
rows = []
for name, d in SCEN.items():
rec = {et: (d[d["truth_etype"] == et]["pred"] == et).mean() if (d["truth_etype"] == et).any() else np.nan for et in ETYPES}
rows.append({"시나리오": name, "건수": len(d), "정확도": (d["pred"] == d["truth_etype"]).mean(), **{f"{k} 재현율": v for k, v in rec.items()}})
res = pd.DataFrame(rows)
res["통과"] = np.where(res["정확도"] >= 0.85, "PASS", "FAIL")
print(res.round(3).fillna("-").to_string(index=False))
allr = SCEN["S10 전체 혼합"]
recs = {et: (allr[allr["truth_etype"] == et]["pred"] == et).mean() for et in ETYPES}
print("\n오답 유형별 재현율:", {k: round(float(v), 3) for k, v in recs.items()},
f"(목표 85% 이상: {'달성' if min(recs.values()) >= 0.85 else '미달'}), 건당 처리 {ms:.3f} ms (목표 200 ms 이하)")
print(f"시나리오 통과 {(res['통과'] == 'PASS').sum()}/10")
fa = nov[nov["pred"] != nov["truth_etype"]]
print("새 오답 오분류(특징 재설계 대상):", [f"{v}->{p}" for v, p in zip(fa["val"], fa["pred"])])
# 오답 패턴 분석: 개인별 주된 오답 유형, 중대별 분포
dom = wrong.groupby("sid")["pred"].agg(lambda s: s.value_counts().idxmax())
print("\n개인별 주된 오답 유형 분포:", dom.value_counts().to_dict())
print(pd.crosstab(wrong["unit"], wrong["pred"], normalize="index").round(2).to_string())
ct = pd.crosstab(wrong["qid"].map(lambda q: QIDX[q]["area"]), wrong["pred"])
ct.index = ct.index.map({"형상식별": "Shape ID", "피아식별": "Friend/Foe", "경보보고": "Alert/Report", "교전통제": "Control", "대드론일반": "C-UAS basics"})
ct.columns = ct.columns.map({"개념 혼동": "concept", "피아 오인": "friend/foe", "절차 누락": "procedure"})
ct.plot(kind="barh", stacked=True, figsize=(8, 4)); plt.xlabel("wrong answers"); plt.title("Error types by area (synthetic)"); plt.tight_layout(); plt.show()
실행 결과 예시
시나리오 건수 정확도 개념 혼동 재현율 피아 오인 재현율 절차 누락 재현율 통과
S1 선다형 표준 표기 367 1.000 1.0 1.0 1.000 PASS
S2 선다형 원문자·전각 표기 235 1.000 1.0 1.0 1.000 PASS
S3 단답 오답 목록 일치 120 1.000 1.0 1.0 1.000 PASS
S4 단답 오답 + 오탈자 148 1.000 1.0 1.0 1.000 PASS
S5 단답 오답 + 어미·띄어쓰기 55 1.000 1.0 1.0 1.000 PASS
S6 순서형 단계 누락 90 1.000 - - 1.000 PASS
S7 순서형 순서 뒤바뀜 94 1.000 - - 1.000 PASS
S8 순서형 구분자 혼입 81 1.000 - - 1.000 PASS
S9 목록에 없는 새 오답 22 0.864 1.0 0.857 0.714 PASS
S10 전체 혼합 1104 0.997 1.0 0.995 0.996 PASS
오답 유형별 재현율: {'개념 혼동': 1.0, '피아 오인': 0.995, '절차 누락': 0.996} (목표 85% 이상: 달성), 건당 처리 0.005 ms (목표 200 ms 이하)
시나리오 통과 10/10
새 오답 오분류(특징 재설계 대상): ['대충보고->개념 혼동', '미확인표적으로대응->절차 누락', '분대원누구나->개념 혼동']
개인별 주된 오답 유형 분포: {'절차 누락': 60, '개념 혼동': 42, '피아 오인': 12}
pred 개념 혼동 절차 누락 피아 오인
unit
가상1중대 0.36 0.46 0.18
가상2중대 0.40 0.41 0.19
가상3중대 0.34 0.49 0.17
미션 5. 개인별/부대별 방공 취약 영역 식별 및 맞춤형 보충 학습 추천 알고리즘 개발
- 난이도: 중급~고급 / 예상 소요시간: 3.0시간
- 코드 설명: 채점 결과로 개인별·중대별 방공 취약 영역을 찾아 보충 학습을 추천하는 알고리즘을 개발한다. 문항 난이도를 반영한 베이즈 추정으로 영역별 '취약할 확률'과 신뢰도 등급을 산출하고, 틀린 문항이 있으면 모두 추천하던 방식 대비 불필요한 추천(오경보) 감축·결정 일관성·ROC-AUC를 검증한다.
"""채점 결과로 개인별·중대별 방공 취약 영역을 찾아 보충 학습을 추천하는 알고리즘을 개발한다. 문항 난이도를 반영한 베이즈 추정으로 영역별 '취약할 확률'과 신뢰도 등급을 산출하고, 틀린 문항이 있으면 모두 추천하던 방식 대비 불필요한 추천(오경보) 감축·결정 일관성·ROC-AUC를 검증한다."""
import difflib
import re
import unicodedata
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from scipy import stats
from sklearn.metrics import roc_auc_score
# ---------------------------------------------------------------------------
# [교육용가상자료] 방공·드론 식별 퀴즈 문항 은행과 가상 응시자 응답 생성기
# 문항은 일반 원칙 수준의 가상 문항이며 실제 교범·교전규칙 원문이 아니다. 응시자는 가명(S001~)이다.
# ---------------------------------------------------------------------------
AREAS = ["형상식별", "피아식별", "경보보고", "교전통제", "대드론일반"]
ETYPES = ["개념 혼동", "피아 오인", "절차 누락"]
POINTS = {1: 2, 2: 3, 3: 5}
C, F, P = ETYPES
def _mc(qid, area, diff, stem, opts, ans, dist):
return {"qid": qid, "type": "MC", "area": area, "diff": diff, "stem": stem, "options": opts, "answer": ans,
"dist": dict(zip([i for i in range(1, 5) if i != ans], dist))}
def _sa(qid, area, diff, stem, accept, wrong):
return {"qid": qid, "type": "SA", "area": area, "diff": diff, "stem": stem, "accept": accept, "wrong": wrong}
def _order(qid, area, diff, stem):
return {"qid": qid, "type": "ORDER", "area": area, "diff": diff, "stem": stem, "answer": "ABCD"}
BANK = [
_mc("Q01", "형상식별", 1, "꼬리날개 없이 삼각형 날개를 가진 소형 고정익 실루엣은?", ["쿼드콥터형", "삼각익 고정익형", "회전익 헬기형", "조류"], 2, [C, C, F]),
_mc("Q02", "형상식별", 1, "회전날개 4개가 십자로 배치된 실루엣은?", ["고정익 드론형", "쿼드콥터형", "순항미사일형", "탄도미사일형"], 2, [C, C, C]),
_mc("Q03", "형상식별", 2, "가늘고 긴 동체 중간에 작은 날개가 붙은 실루엣은?", ["순항미사일형", "탄도미사일형", "민간 경비행기형", "조류"], 1, [C, F, F]),
_mc("Q04", "형상식별", 3, "원거리라 실루엣 식별이 어려울 때 먼저 할 행동은?", ["추정 유형으로 확정 보고", "관측 정보를 그대로 보고하고 추가 관측", "관측 중단", "민간기로 간주"], 2, [P, P, F]),
_mc("Q05", "피아식별", 2, "식별 신호 응답이 없는 항공기에 대한 올바른 판단은?", ["즉시 적기로 확정", "미확인 항공기로 분류하고 추가 확인", "아군기로 간주", "무시"], 2, [F, F, P]),
_mc("Q06", "피아식별", 2, "운항 정보와 일치하는 저속 비행체의 분류는?", ["적 드론", "민간기 가능성 높음, 추가 확인", "순항미사일", "아군 정찰기로 확정"], 2, [F, C, F]),
_mc("Q07", "피아식별", 3, "아군 드론 운용 공지가 있는 공역에서 미확인 드론을 보면 우선 확인할 것은?", ["기상 정보", "아군 운용 계획과 식별 신호", "대응 준비 상태", "주변 지형"], 2, [C, P, C]),
_mc("Q08", "피아식별", 3, "조류 무리와 소형 드론 군집 구분에 가장 도움이 되는 정보는?", ["크기만 비교", "궤적의 규칙성과 열 신호", "색상", "관측 시각"], 2, [C, C, C]),
_mc("Q09", "경보보고", 1, "공중 위협 최초 보고에 포함하지 않아도 되는 것은?", ["관측 시각", "방향과 거리", "추정 유형", "관측자 개인 연락처"], 4, [P, P, P]),
_mc("Q10", "경보보고", 2, "경보 전파 후 상황이 바뀌면?", ["최초 보고 유지", "변경 사항을 즉시 추가 보고", "상황 종료 후 일괄 보고", "개인 판단으로 경보 해제"], 2, [P, P, P]),
_mc("Q11", "경보보고", 2, "보고 내용이 불확실할 때 올바른 표현은?", ["확정 표현으로 보고", "'추정'임을 밝히고 근거와 함께 보고", "보고 보류", "다른 관측자 보고를 복사"], 2, [C, P, P]),
_mc("Q12", "경보보고", 1, "드론 탐지 후 보고 경로로 알맞은 것은?", ["인접 부대에만 전파", "지휘계통을 따라 보고", "개인 메신저로 공유", "보고 없이 대응"], 2, [P, P, P]),
_mc("Q13", "교전통제", 1, "AI 탐지 결과만으로 대응을 결정할 수 있는가?", ["가능하다", "인간 지휘관의 승인이 필요하다", "신뢰도 90% 이상이면 가능", "야간에만 가능"], 2, [C, C, C]),
_mc("Q14", "교전통제", 2, "민간 피해 가능성이 확인되면?", ["그대로 진행", "즉시 보고하고 지휘관 판단을 받는다", "조치 후 보고", "무시"], 2, [P, P, F]),
_mc("Q15", "교전통제", 3, "식별이 불확실한 표적에 대한 원칙은?", ["위협으로 간주해 즉시 대응", "식별 확인 전까지 승인 절차를 따른다", "무조건 무시", "다수결로 결정"], 2, [F, F, C]),
_mc("Q16", "교전통제", 2, "판단 근거를 기록해야 하는 이유는?", ["형식상 필요", "사후 검토와 책임 추적", "보고 생략", "기록 불필요"], 2, [C, P, P]),
_mc("Q17", "대드론일반", 1, "대드론 체계의 일반적 처리 순서는?", ["대응-탐지-식별", "탐지-식별-보고·판단-대응", "식별-대응-탐지", "보고-탐지-대응"], 2, [P, P, P]),
_mc("Q18", "대드론일반", 3, "레이더만으로 조류와 드론 구분이 어려운 이유는?", ["반사 신호 크기가 비슷할 수 있어서", "레이더는 야간에 못 써서", "드론에는 금속이 없어서", "조류는 탐지되지 않아서"], 1, [C, C, C]),
_mc("Q19", "대드론일반", 2, "전자광학(EO) 센서의 약점이 커지는 조건은?", ["맑은 주간", "안개·야간", "저속 표적", "근거리"], 2, [C, C, C]),
_mc("Q20", "대드론일반", 2, "경보가 반복해서 오경보로 판명되면?", ["경보 장치를 끈다", "사례를 기록·보고하고 기준 재검토 요청", "무시한다", "모두 실제 위협으로 간주"], 2, [P, P, F]),
_sa("Q21", "피아식별", 1, "아군·적·민간을 구분하는 절차를 무엇이라 하는가?", ["피아식별", "IFF"], {"표적획득": C, "적기판정": F, "경계": C}),
_sa("Q22", "형상식별", 2, "꼬리날개 없는 삼각형 날개 형태를 무엇이라 하는가?", ["삼각익", "델타익", "delta"], {"직선익": C, "후퇴익": C, "쿼드콥터": C}),
_sa("Q23", "경보보고", 2, "불확실한 관측을 보고할 때 붙이는 표현은?", ["추정"], {"확정": C, "확인": C, "생략": P}),
_sa("Q24", "교전통제", 1, "AI 탐지 결과에 대해 최종 결정을 내리는 주체는?", ["지휘관"], {"AI": C, "운용병": P, "레이더": C}),
_sa("Q25", "대드론일반", 1, "조류를 드론으로 잘못 경보하는 것을 무엇이라 하는가?", ["오경보", "오탐", "false alarm"], {"미탐": C, "재경보": C, "적기": F}),
_sa("Q26", "피아식별", 2, "운항 정보가 확인된 여객기는 어떻게 분류하는가?", ["민간기", "민항기"], {"적기": F, "미확인기": P, "아군기": F}),
_sa("Q27", "형상식별", 1, "회전날개 4개를 가진 드론 형태는?", ["쿼드콥터", "quadcopter"], {"헥사콥터": C, "고정익": C, "헬기": F}),
_sa("Q28", "경보보고", 1, "보고는 무엇을 따라 올라가는가?", ["지휘계통"], {"인접부대": P, "개인메신저": P, "통신망": C}),
_order("Q29", "경보보고", 2, "최초 보고 순서: A 관측 시각, B 방향·거리, C 추정 유형, D 관측자 조치"),
_order("Q30", "대드론일반", 2, "처리 순서: A 탐지, B 식별, C 보고·판단, D 대응"),
_order("Q31", "교전통제", 3, "통제 순서: A 식별 확인, B 상급 승인 요청, C 승인 수령, D 조치 기록"),
_order("Q32", "피아식별", 3, "확인 순서: A 운항정보 대조, B 식별 신호 확인, C 미확인 분류, D 보고"),
]
QIDX = {q["qid"]: q for q in BANK}
UNITS = ["가상1중대", "가상2중대", "가상3중대"]
_CIRC = "①②③④"
PARA = {"Q21": "아군적군 구분", "Q22": "세모 날개", "Q23": "추정됨으로 표기", "Q24": "중대장(지휘관)", "Q25": "잘못된 경보",
"Q26": "민간 여객기", "Q27": "4발 드론", "Q28": "지휘 라인"} # 뜻은 맞지만 인정 답안 목록에 없는 표현
def make_soldiers(n_per_unit, seed):
rng = np.random.default_rng(seed)
out = []
for u, unit in enumerate(UNITS):
base = [0.3, -0.1, 0.0][u]
for i in range(n_per_unit):
off = rng.normal(0, 0.5, len(AREAS)) + (np.array([0, 0, 0, -0.6, 0]) if u == 1 else 0)
out.append({"sid": f"S{len(out) + 1:03d}", "unit": unit, "theta": dict(zip(AREAS, base + rng.normal(0, 0.6) + off)),
"tend": dict(zip(ETYPES, rng.dirichlet([1.2, 1.2, 1.2])))})
return out
def p_correct(theta, diff):
return 1 / (1 + np.exp(-1.7 * (theta - (diff - 2) * 0.9 + 0.9)))
def _typo(s, rng):
if len(s) < 2:
return s
i = int(rng.integers(len(s) - 1))
return s[:i] + s[i + 1] + s[i] + s[i + 2:] if len(s) <= 3 or rng.random() < 0.5 else s[:i] + s[i + 1:]
def _fmt_mc(k, rng):
r = rng.random()
if r < 0.55:
return str(k)
return rng.choice([_CIRC[k - 1], f"{k}번", "ABCD"[k - 1], "abcd"[k - 1] + ")", f" {k} ", chr(0xFF10 + k), f"{k}."])
def _fmt_sa(s, rng):
r = rng.random()
if r < 0.45:
return s
if r < 0.6:
return " ".join(s) if len(s) <= 4 else s[:2] + " " + s[2:]
if r < 0.72:
return s + rng.choice(["입니다", "이다", ".", "임"])
if r < 0.8:
return s.upper() if s.isascii() else "".join(chr(ord(ch) + 0xFEE0) if "!" <= ch <= "~" else ch for ch in s) + " "
return _typo(s, rng)
def answer(q, sol, rng):
"""한 문항 응답: (원본 응답 문자열, 의도한 정답 여부, 의도한 답, 오답 유형)."""
ok = rng.random() < p_correct(sol["theta"][q["area"]], q["diff"])
if q["type"] == "MC":
if ok:
k, et = q["answer"], None
else:
ks = list(q["dist"]); w = np.array([sol["tend"][q["dist"][k]] for k in ks])
k = int(rng.choice(ks, p=w / w.sum())); et = q["dist"][k]
return _fmt_mc(k, rng), ok, str(k), et
if q["type"] == "SA":
if ok and rng.random() < 0.06:
return PARA[q["qid"]], ok, PARA[q["qid"]], None
if ok:
s, et = str(rng.choice(q["accept"])), None
else:
ws = list(q["wrong"]); w = np.array([sol["tend"][q["wrong"][x]] for x in ws])
s = str(rng.choice(ws, p=w / w.sum())); et = q["wrong"][s]
return _fmt_sa(s, rng), ok, s, et
if ok:
s = "ABCD"
elif rng.random() < 0.5:
s = "ABCD".replace("ABCD"[rng.integers(1, 4)], "") # 단계 하나 빠뜨림
else:
i = int(rng.integers(0, 3)); s = list("ABCD"); s[i], s[i + 1] = s[i + 1], s[i]; s = "".join(s) # 순서 뒤바뀜
raw = s if rng.random() < 0.6 else rng.choice(["-".join(s), " ".join(s), s.lower(), ",".join(s)])
return raw, ok, s, (None if ok else P)
def make_responses(n_per_unit=40, seed=7, blank=0.02):
"""응시자 x 문항 응답표(DataFrame)를 만든다. truth_* 열은 교관 기준 정답(평가용)이다."""
rng = np.random.default_rng(seed)
rows = []
for sol in make_soldiers(n_per_unit, seed):
for q in BANK:
raw, ok, intended, et = answer(q, sol, rng)
if rng.random() < blank:
raw, ok, intended, et = "", False, "", None
rows.append({"rid": f"R{len(rows):05d}", "sid": sol["sid"], "unit": sol["unit"], "qid": q["qid"], "raw": raw,
"truth_correct": ok, "truth_answer": intended, "truth_etype": et})
return pd.DataFrame(rows)
# ---------------------------------------------------------------------------
# ---- 응답 정제·파싱·채점 공통 함수 ----
TAILS = ("입니다", "이다", "임")
def norm_text(s):
"""전각->반각(NFKC), 소문자, 공백·문장부호 제거, 끝맺음 어미 제거."""
s = unicodedata.normalize("NFKC", str(s)).strip().lower()
s = re.sub(r"[\s\.\,\-\(\)·]", "", s)
for t in TAILS:
if s.endswith(t) and len(s) > len(t) + 1:
s = s[:-len(t)]
return s
def parse(q, raw):
"""원본 응답 -> (정제값, 상태). 상태: 정상 / 미응답 / 형식오류"""
if raw is None or str(raw).strip() == "":
return "", "미응답"
s = unicodedata.normalize("NFKC", str(raw)).strip()
if q["type"] == "MC":
t = re.sub(r"[\s번\.\)]", "", s).lower()
t = {"a": "1", "b": "2", "c": "3", "d": "4"}.get(t, t)
return (t, "정상") if t in ("1", "2", "3", "4") else (t, "형식오류")
if q["type"] == "ORDER":
t = re.sub(r"[^A-Za-z]", "", s).upper()
return (t, "정상") if t and set(t) <= set("ABCD") and len(set(t)) == len(t) else (t, "형식오류")
return norm_text(s), "정상"
def grade_exact(q, val):
if q["type"] == "MC":
return val == str(q["answer"])
if q["type"] == "ORDER":
return val == q["answer"]
return val in {norm_text(a) for a in q["accept"]}
def grade_fuzzy(q, val, cut=0.75):
"""단답형은 오탈자(글자 순서 바뀜·한 글자 누락)를 허용(유사도 cut 이상)하되, 알려진 오답과 더 가까우면 오답으로 본다."""
if grade_exact(q, val) or q["type"] != "SA" or len(val) < 2:
return grade_exact(q, val)
best = max(1.0 if sorted(val) == sorted(norm_text(a)) else difflib.SequenceMatcher(None, val, norm_text(a)).ratio() for a in q["accept"])
worst = max(difflib.SequenceMatcher(None, val, norm_text(w)).ratio() for w in q["wrong"])
return best >= cut and best > worst
def score_of(q, correct):
return POINTS[q["diff"]] if correct else 0
MASTERY = 0.6 # 영역 정답률이 이보다 낮으면 '취약'
HIGH, MEDIUM = 0.8, 0.4
GRID = np.linspace(-3, 3, 121)
PRIOR = stats.norm.pdf(GRID, 0, 1)
CUT = GRID[np.argmin(np.abs(p_correct(GRID, 2) - MASTERY))] # 중간 난이도 정답률 0.6에 해당하는 숙달도
def weak_prob(resp):
"""(난이도, 정답 여부) 목록 -> 숙달도 사후분포에서 CUT 미만일 확률. 어려운 문항 오답은 덜, 쉬운 문항 오답은 더 크게 반영된다."""
post = PRIOR.copy()
for d, c in resp:
p = p_correct(GRID, d)
post *= p if c else 1 - p
return float(post[GRID < CUT].sum() / post.sum())
def area_table(df):
g = df.assign(d=df["qid"].map(lambda q: QIDX[q]["diff"]))
t = g.groupby(["sid", "unit", "area"]).agg(wrong=("correct", lambda s: int((~s).sum())),
resp=("d", lambda s: list(zip(s, g.loc[s.index, "correct"])))).reset_index()
t["score"] = [weak_prob(r) for r in t["resp"]]
t["grade"] = np.where(t["score"] >= HIGH, "High", np.where(t["score"] >= MEDIUM, "Medium", "Low"))
return t
df = make_responses(40, seed=7)
df["area"] = df["qid"].map(lambda q: QIDX[q]["area"])
df["correct"] = [grade_fuzzy(QIDX[q], parse(QIDX[q], r)[0]) for q, r in zip(df["qid"], df["raw"])]
sol = {s["sid"]: s for s in make_soldiers(40, 7)}
t = area_table(df)
t["truth_weak"] = [p_correct(sol[s]["theta"][a], 2) < MASTERY for s, a in zip(t["sid"], t["area"])] # 가상 실제 숙달도(평가용)
def report(name, rec):
fa = (rec & ~t["truth_weak"]).sum()
return {"방식": name, "추천 수": int(rec.sum()), "불필요 추천": int(fa), "오경보율": round(fa / max(rec.sum(), 1), 3),
"취약 영역 재현율": round((rec & t["truth_weak"]).sum() / t["truth_weak"].sum(), 3)}
before = report("기존(오답 1개라도 있으면 추천)", t["wrong"] >= 1)
after = report("난이도 반영 추정(Medium 이상)", t["grade"] != "Low")
print(f"[교육용가상자료] 응시자 120명 x 영역 5개 = {len(t)}건, 실제 취약 {int(t['truth_weak'].sum())}건")
print(pd.DataFrame([before, after]).to_string(index=False))
cut = 1 - after["오경보율"] / before["오경보율"]
retest = ((t["grade"] == "Low") & (t["score"] >= 0.2)).sum()
print(f"Low 등급 중 취약 확률 0.2 이상 {retest}건은 추천 대신 '추가 문항 출제 후 재판단' 대상으로 둔다.")
print(f"\n오경보율 감축 {cut:.1%} (목표 40% 이상: {'달성' if cut >= 0.4 else '미달'})")
# 일관성: 영역마다 응답 1건씩 빼고 다시 계산해도 추천 결정이 유지되는 비율
rng = np.random.default_rng(0)
drop = df.groupby(["sid", "area"]).sample(1, random_state=1).index
t2 = area_table(df.drop(drop))
cons = ((t2["grade"] != "Low").values == (t["grade"] != "Low").values).mean()
print(f"추천 결정 일관성(응답 1건 제외 재계산) {cons:.1%} (목표 90% 이상: {'달성' if cons >= 0.9 else '미달'})")
auc = roc_auc_score(t["truth_weak"], t["score"])
print(f"취약 확률 점수 ROC-AUC {auc:.3f}")
print("[임계값 민감도]", ", ".join(f"{m}: 오경보율 {report('', t['score'] >= m)['오경보율']:.2f}/재현율 {report('', t['score'] >= m)['취약 영역 재현율']:.2f}" for m in [0.3, 0.4, 0.5, 0.7]))
# 개인 보충 학습 추천 예시
s0 = t[t["grade"] != "Low"].sort_values("score", ascending=False)["sid"].iloc[0]
print(f"\n[개인 추천 예시: {s0}]")
for r in t[(t["sid"] == s0) & (t["grade"] != "Low")].itertuples():
wrong_q = df[(df["sid"] == s0) & (df["area"] == r.area) & ~df["correct"]]["qid"].tolist()
easy = [q["qid"] for q in BANK if q["area"] == r.area and q["diff"] == 1]
print(f" - {r.area}: 취약 확률 {r.score:.2f}({r.grade}), 오답 {wrong_q} -> 기초 문항 {easy} 재학습 후 재평가")
# 중대별 취약 영역: 소속 인원 30% 이상이 취약(Medium 이상)이면 중대 단위 보충 교육 권고
u = t.assign(rec=t["grade"] != "Low").pivot_table(index="unit", columns="area", values="rec", aggfunc="mean").round(2)
print("\n[중대별 취약 인원 비율]")
print(u.to_string())
print("중대 단위 보충 교육 권고:", [f"{i}-{c}" for i in u.index for c in u.columns if u.loc[i, c] >= 0.3])
print("추천은 학습 보조 정보이며 개인 평가·인사 자료로 쓰지 않는다.")
fig, ax = plt.subplots(1, 2, figsize=(11, 4))
for g, col in [("High", "tab:red"), ("Medium", "tab:orange"), ("Low", "tab:green")]:
ax[0].hist(t[t["grade"] == g]["score"], bins=20, range=(0, 1), color=col, alpha=.7, label=g)
ax[0].set_title("Weakness probability by grade"); ax[0].set_xlabel("P(accuracy < 0.6)"); ax[0].legend()
im = ax[1].imshow(u.values, cmap="Reds", vmin=0, vmax=1)
ax[1].set_xticks(range(5)); ax[1].set_xticklabels(["Shape", "IFF", "Report", "Control", "C-UAS"][:0] or [{"형상식별": "Shape", "피아식별": "IFF", "경보보고": "Report", "교전통제": "Control", "대드론일반": "C-UAS"}[c] for c in u.columns])
ax[1].set_yticks(range(3)); ax[1].set_yticklabels(["Co.1", "Co.2", "Co.3"]); plt.colorbar(im, ax=ax[1]); ax[1].set_title("Share of weak soldiers by unit/area")
plt.tight_layout(); plt.show()
실행 결과 예시
[교육용가상자료] 응시자 120명 x 영역 5개 = 600건, 실제 취약 120건
방식 추천 수 불필요 추천 오경보율 취약 영역 재현율
기존(오답 1개라도 있으면 추천) 444 324 0.730 1.000
난이도 반영 추정(Medium 이상) 148 62 0.419 0.717
Low 등급 중 취약 확률 0.2 이상 96건은 추천 대신 '추가 문항 출제 후 재판단' 대상으로 둔다.
오경보율 감축 42.6% (목표 40% 이상: 달성)
추천 결정 일관성(응답 1건 제외 재계산) 91.2% (목표 90% 이상: 달성)
취약 확률 점수 ROC-AUC 0.898
[임계값 민감도] 0.3: 오경보율 0.50/재현율 0.82, 0.4: 오경보율 0.42/재현율 0.72, 0.5: 오경보율 0.35/재현율 0.57, 0.7: 오경보율 0.15/재현율 0.47
[개인 추천 예시: S098]
- 경보보고: 취약 확률 1.00(High), 오답 ['Q09', 'Q10', 'Q11', 'Q12', 'Q23', 'Q28', 'Q29'] -> 기초 문항 ['Q09', 'Q12', 'Q28'] 재학습 후 재평가
- 대드론일반: 취약 확률 0.87(High), 오답 ['Q18', 'Q19', 'Q25', 'Q30'] -> 기초 문항 ['Q17', 'Q25'] 재학습 후 재평가
[중대별 취약 인원 비율]
area 경보보고 교전통제 대드론일반 피아식별 형상식별
unit
가상1중대 0.10 0.20 0.12 0.15 0.10
가상2중대 0.25 0.57 0.25 0.38 0.25
가상3중대 0.30 0.35 0.18 0.32 0.18
중대 단위 보충 교육 권고: ['가상2중대-교전통제', '가상2중대-피아식별', '가상3중대-경보보고', '가상3중대-교전통제', '가상3중대-피아식별']
추천은 학습 보조 정보이며 개인 평가·인사 자료로 쓰지 않는다.
미션 6. 부대 방공 지식 수준 통계 및 오답 분포 가시화 교수자 대시보드 구현
- 난이도: 중급 / 예상 소요시간: 2.5시간
- 코드 설명: 채점·오답 분류 결과로 중대별 영역 정답률, 오답 유형 분포, 점수 분포, 문항 난이도 등 교수자 대시보드 구성요소 4종 이상을 그리고, 새 응답 묶음이 들어올 때 집계 갱신 시간과 필터 조회 응답 시간을 측정하며 경보 하이라이트 대상을 출력한다.
"""채점·오답 분류 결과로 중대별 영역 정답률, 오답 유형 분포, 점수 분포, 문항 난이도 등 교수자 대시보드 구성요소 4종 이상을 그리고, 새 응답 묶음이 들어올 때 집계 갱신 시간과 필터 조회 응답 시간을 측정하며 경보 하이라이트 대상을 출력한다."""
import difflib
import re
import time
import unicodedata
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import make_pipeline
# ---------------------------------------------------------------------------
# [교육용가상자료] 방공·드론 식별 퀴즈 문항 은행과 가상 응시자 응답 생성기
# 문항은 일반 원칙 수준의 가상 문항이며 실제 교범·교전규칙 원문이 아니다. 응시자는 가명(S001~)이다.
# ---------------------------------------------------------------------------
AREAS = ["형상식별", "피아식별", "경보보고", "교전통제", "대드론일반"]
ETYPES = ["개념 혼동", "피아 오인", "절차 누락"]
POINTS = {1: 2, 2: 3, 3: 5}
C, F, P = ETYPES
def _mc(qid, area, diff, stem, opts, ans, dist):
return {"qid": qid, "type": "MC", "area": area, "diff": diff, "stem": stem, "options": opts, "answer": ans,
"dist": dict(zip([i for i in range(1, 5) if i != ans], dist))}
def _sa(qid, area, diff, stem, accept, wrong):
return {"qid": qid, "type": "SA", "area": area, "diff": diff, "stem": stem, "accept": accept, "wrong": wrong}
def _order(qid, area, diff, stem):
return {"qid": qid, "type": "ORDER", "area": area, "diff": diff, "stem": stem, "answer": "ABCD"}
BANK = [
_mc("Q01", "형상식별", 1, "꼬리날개 없이 삼각형 날개를 가진 소형 고정익 실루엣은?", ["쿼드콥터형", "삼각익 고정익형", "회전익 헬기형", "조류"], 2, [C, C, F]),
_mc("Q02", "형상식별", 1, "회전날개 4개가 십자로 배치된 실루엣은?", ["고정익 드론형", "쿼드콥터형", "순항미사일형", "탄도미사일형"], 2, [C, C, C]),
_mc("Q03", "형상식별", 2, "가늘고 긴 동체 중간에 작은 날개가 붙은 실루엣은?", ["순항미사일형", "탄도미사일형", "민간 경비행기형", "조류"], 1, [C, F, F]),
_mc("Q04", "형상식별", 3, "원거리라 실루엣 식별이 어려울 때 먼저 할 행동은?", ["추정 유형으로 확정 보고", "관측 정보를 그대로 보고하고 추가 관측", "관측 중단", "민간기로 간주"], 2, [P, P, F]),
_mc("Q05", "피아식별", 2, "식별 신호 응답이 없는 항공기에 대한 올바른 판단은?", ["즉시 적기로 확정", "미확인 항공기로 분류하고 추가 확인", "아군기로 간주", "무시"], 2, [F, F, P]),
_mc("Q06", "피아식별", 2, "운항 정보와 일치하는 저속 비행체의 분류는?", ["적 드론", "민간기 가능성 높음, 추가 확인", "순항미사일", "아군 정찰기로 확정"], 2, [F, C, F]),
_mc("Q07", "피아식별", 3, "아군 드론 운용 공지가 있는 공역에서 미확인 드론을 보면 우선 확인할 것은?", ["기상 정보", "아군 운용 계획과 식별 신호", "대응 준비 상태", "주변 지형"], 2, [C, P, C]),
_mc("Q08", "피아식별", 3, "조류 무리와 소형 드론 군집 구분에 가장 도움이 되는 정보는?", ["크기만 비교", "궤적의 규칙성과 열 신호", "색상", "관측 시각"], 2, [C, C, C]),
_mc("Q09", "경보보고", 1, "공중 위협 최초 보고에 포함하지 않아도 되는 것은?", ["관측 시각", "방향과 거리", "추정 유형", "관측자 개인 연락처"], 4, [P, P, P]),
_mc("Q10", "경보보고", 2, "경보 전파 후 상황이 바뀌면?", ["최초 보고 유지", "변경 사항을 즉시 추가 보고", "상황 종료 후 일괄 보고", "개인 판단으로 경보 해제"], 2, [P, P, P]),
_mc("Q11", "경보보고", 2, "보고 내용이 불확실할 때 올바른 표현은?", ["확정 표현으로 보고", "'추정'임을 밝히고 근거와 함께 보고", "보고 보류", "다른 관측자 보고를 복사"], 2, [C, P, P]),
_mc("Q12", "경보보고", 1, "드론 탐지 후 보고 경로로 알맞은 것은?", ["인접 부대에만 전파", "지휘계통을 따라 보고", "개인 메신저로 공유", "보고 없이 대응"], 2, [P, P, P]),
_mc("Q13", "교전통제", 1, "AI 탐지 결과만으로 대응을 결정할 수 있는가?", ["가능하다", "인간 지휘관의 승인이 필요하다", "신뢰도 90% 이상이면 가능", "야간에만 가능"], 2, [C, C, C]),
_mc("Q14", "교전통제", 2, "민간 피해 가능성이 확인되면?", ["그대로 진행", "즉시 보고하고 지휘관 판단을 받는다", "조치 후 보고", "무시"], 2, [P, P, F]),
_mc("Q15", "교전통제", 3, "식별이 불확실한 표적에 대한 원칙은?", ["위협으로 간주해 즉시 대응", "식별 확인 전까지 승인 절차를 따른다", "무조건 무시", "다수결로 결정"], 2, [F, F, C]),
_mc("Q16", "교전통제", 2, "판단 근거를 기록해야 하는 이유는?", ["형식상 필요", "사후 검토와 책임 추적", "보고 생략", "기록 불필요"], 2, [C, P, P]),
_mc("Q17", "대드론일반", 1, "대드론 체계의 일반적 처리 순서는?", ["대응-탐지-식별", "탐지-식별-보고·판단-대응", "식별-대응-탐지", "보고-탐지-대응"], 2, [P, P, P]),
_mc("Q18", "대드론일반", 3, "레이더만으로 조류와 드론 구분이 어려운 이유는?", ["반사 신호 크기가 비슷할 수 있어서", "레이더는 야간에 못 써서", "드론에는 금속이 없어서", "조류는 탐지되지 않아서"], 1, [C, C, C]),
_mc("Q19", "대드론일반", 2, "전자광학(EO) 센서의 약점이 커지는 조건은?", ["맑은 주간", "안개·야간", "저속 표적", "근거리"], 2, [C, C, C]),
_mc("Q20", "대드론일반", 2, "경보가 반복해서 오경보로 판명되면?", ["경보 장치를 끈다", "사례를 기록·보고하고 기준 재검토 요청", "무시한다", "모두 실제 위협으로 간주"], 2, [P, P, F]),
_sa("Q21", "피아식별", 1, "아군·적·민간을 구분하는 절차를 무엇이라 하는가?", ["피아식별", "IFF"], {"표적획득": C, "적기판정": F, "경계": C}),
_sa("Q22", "형상식별", 2, "꼬리날개 없는 삼각형 날개 형태를 무엇이라 하는가?", ["삼각익", "델타익", "delta"], {"직선익": C, "후퇴익": C, "쿼드콥터": C}),
_sa("Q23", "경보보고", 2, "불확실한 관측을 보고할 때 붙이는 표현은?", ["추정"], {"확정": C, "확인": C, "생략": P}),
_sa("Q24", "교전통제", 1, "AI 탐지 결과에 대해 최종 결정을 내리는 주체는?", ["지휘관"], {"AI": C, "운용병": P, "레이더": C}),
_sa("Q25", "대드론일반", 1, "조류를 드론으로 잘못 경보하는 것을 무엇이라 하는가?", ["오경보", "오탐", "false alarm"], {"미탐": C, "재경보": C, "적기": F}),
_sa("Q26", "피아식별", 2, "운항 정보가 확인된 여객기는 어떻게 분류하는가?", ["민간기", "민항기"], {"적기": F, "미확인기": P, "아군기": F}),
_sa("Q27", "형상식별", 1, "회전날개 4개를 가진 드론 형태는?", ["쿼드콥터", "quadcopter"], {"헥사콥터": C, "고정익": C, "헬기": F}),
_sa("Q28", "경보보고", 1, "보고는 무엇을 따라 올라가는가?", ["지휘계통"], {"인접부대": P, "개인메신저": P, "통신망": C}),
_order("Q29", "경보보고", 2, "최초 보고 순서: A 관측 시각, B 방향·거리, C 추정 유형, D 관측자 조치"),
_order("Q30", "대드론일반", 2, "처리 순서: A 탐지, B 식별, C 보고·판단, D 대응"),
_order("Q31", "교전통제", 3, "통제 순서: A 식별 확인, B 상급 승인 요청, C 승인 수령, D 조치 기록"),
_order("Q32", "피아식별", 3, "확인 순서: A 운항정보 대조, B 식별 신호 확인, C 미확인 분류, D 보고"),
]
QIDX = {q["qid"]: q for q in BANK}
UNITS = ["가상1중대", "가상2중대", "가상3중대"]
_CIRC = "①②③④"
PARA = {"Q21": "아군적군 구분", "Q22": "세모 날개", "Q23": "추정됨으로 표기", "Q24": "중대장(지휘관)", "Q25": "잘못된 경보",
"Q26": "민간 여객기", "Q27": "4발 드론", "Q28": "지휘 라인"} # 뜻은 맞지만 인정 답안 목록에 없는 표현
def make_soldiers(n_per_unit, seed):
rng = np.random.default_rng(seed)
out = []
for u, unit in enumerate(UNITS):
base = [0.3, -0.1, 0.0][u]
for i in range(n_per_unit):
off = rng.normal(0, 0.5, len(AREAS)) + (np.array([0, 0, 0, -0.6, 0]) if u == 1 else 0)
out.append({"sid": f"S{len(out) + 1:03d}", "unit": unit, "theta": dict(zip(AREAS, base + rng.normal(0, 0.6) + off)),
"tend": dict(zip(ETYPES, rng.dirichlet([1.2, 1.2, 1.2])))})
return out
def p_correct(theta, diff):
return 1 / (1 + np.exp(-1.7 * (theta - (diff - 2) * 0.9 + 0.9)))
def _typo(s, rng):
if len(s) < 2:
return s
i = int(rng.integers(len(s) - 1))
return s[:i] + s[i + 1] + s[i] + s[i + 2:] if len(s) <= 3 or rng.random() < 0.5 else s[:i] + s[i + 1:]
def _fmt_mc(k, rng):
r = rng.random()
if r < 0.55:
return str(k)
return rng.choice([_CIRC[k - 1], f"{k}번", "ABCD"[k - 1], "abcd"[k - 1] + ")", f" {k} ", chr(0xFF10 + k), f"{k}."])
def _fmt_sa(s, rng):
r = rng.random()
if r < 0.45:
return s
if r < 0.6:
return " ".join(s) if len(s) <= 4 else s[:2] + " " + s[2:]
if r < 0.72:
return s + rng.choice(["입니다", "이다", ".", "임"])
if r < 0.8:
return s.upper() if s.isascii() else "".join(chr(ord(ch) + 0xFEE0) if "!" <= ch <= "~" else ch for ch in s) + " "
return _typo(s, rng)
def answer(q, sol, rng):
"""한 문항 응답: (원본 응답 문자열, 의도한 정답 여부, 의도한 답, 오답 유형)."""
ok = rng.random() < p_correct(sol["theta"][q["area"]], q["diff"])
if q["type"] == "MC":
if ok:
k, et = q["answer"], None
else:
ks = list(q["dist"]); w = np.array([sol["tend"][q["dist"][k]] for k in ks])
k = int(rng.choice(ks, p=w / w.sum())); et = q["dist"][k]
return _fmt_mc(k, rng), ok, str(k), et
if q["type"] == "SA":
if ok and rng.random() < 0.06:
return PARA[q["qid"]], ok, PARA[q["qid"]], None
if ok:
s, et = str(rng.choice(q["accept"])), None
else:
ws = list(q["wrong"]); w = np.array([sol["tend"][q["wrong"][x]] for x in ws])
s = str(rng.choice(ws, p=w / w.sum())); et = q["wrong"][s]
return _fmt_sa(s, rng), ok, s, et
if ok:
s = "ABCD"
elif rng.random() < 0.5:
s = "ABCD".replace("ABCD"[rng.integers(1, 4)], "") # 단계 하나 빠뜨림
else:
i = int(rng.integers(0, 3)); s = list("ABCD"); s[i], s[i + 1] = s[i + 1], s[i]; s = "".join(s) # 순서 뒤바뀜
raw = s if rng.random() < 0.6 else rng.choice(["-".join(s), " ".join(s), s.lower(), ",".join(s)])
return raw, ok, s, (None if ok else P)
def make_responses(n_per_unit=40, seed=7, blank=0.02):
"""응시자 x 문항 응답표(DataFrame)를 만든다. truth_* 열은 교관 기준 정답(평가용)이다."""
rng = np.random.default_rng(seed)
rows = []
for sol in make_soldiers(n_per_unit, seed):
for q in BANK:
raw, ok, intended, et = answer(q, sol, rng)
if rng.random() < blank:
raw, ok, intended, et = "", False, "", None
rows.append({"rid": f"R{len(rows):05d}", "sid": sol["sid"], "unit": sol["unit"], "qid": q["qid"], "raw": raw,
"truth_correct": ok, "truth_answer": intended, "truth_etype": et})
return pd.DataFrame(rows)
# ---------------------------------------------------------------------------
# ---- 응답 정제·파싱·채점 공통 함수 ----
TAILS = ("입니다", "이다", "임")
def norm_text(s):
"""전각->반각(NFKC), 소문자, 공백·문장부호 제거, 끝맺음 어미 제거."""
s = unicodedata.normalize("NFKC", str(s)).strip().lower()
s = re.sub(r"[\s\.\,\-\(\)·]", "", s)
for t in TAILS:
if s.endswith(t) and len(s) > len(t) + 1:
s = s[:-len(t)]
return s
def parse(q, raw):
"""원본 응답 -> (정제값, 상태). 상태: 정상 / 미응답 / 형식오류"""
if raw is None or str(raw).strip() == "":
return "", "미응답"
s = unicodedata.normalize("NFKC", str(raw)).strip()
if q["type"] == "MC":
t = re.sub(r"[\s번\.\)]", "", s).lower()
t = {"a": "1", "b": "2", "c": "3", "d": "4"}.get(t, t)
return (t, "정상") if t in ("1", "2", "3", "4") else (t, "형식오류")
if q["type"] == "ORDER":
t = re.sub(r"[^A-Za-z]", "", s).upper()
return (t, "정상") if t and set(t) <= set("ABCD") and len(set(t)) == len(t) else (t, "형식오류")
return norm_text(s), "정상"
def grade_exact(q, val):
if q["type"] == "MC":
return val == str(q["answer"])
if q["type"] == "ORDER":
return val == q["answer"]
return val in {norm_text(a) for a in q["accept"]}
def grade_fuzzy(q, val, cut=0.75):
"""단답형은 오탈자(글자 순서 바뀜·한 글자 누락)를 허용(유사도 cut 이상)하되, 알려진 오답과 더 가까우면 오답으로 본다."""
if grade_exact(q, val) or q["type"] != "SA" or len(val) < 2:
return grade_exact(q, val)
best = max(1.0 if sorted(val) == sorted(norm_text(a)) else difflib.SequenceMatcher(None, val, norm_text(a)).ratio() for a in q["accept"])
worst = max(difflib.SequenceMatcher(None, val, norm_text(w)).ratio() for w in q["wrong"])
return best >= cut and best > worst
def score_of(q, correct):
return POINTS[q["diff"]] if correct else 0
# ---- 오답 유형 분류기(규칙 + 문자 n-gram 로지스틱 회귀) ----
SEED = {"피아 오인": ["적기", "적 드론", "아군기", "적 헬기", "적기판정", "헬기", "적 항공기 확정", "아군 드론"],
"절차 누락": ["생략", "인접부대", "개인메신저", "보고 안 함", "운용병", "미확인기", "단계 건너뜀", "직접 전파"],
"개념 혼동": ["직선익", "후퇴익", "헥사콥터", "미탐", "재경보", "확정", "확인", "레이더", "AI", "표적획득", "경계", "통신망", "고정익"]}
def build_text_model():
texts, labels = [], []
for q in BANK:
if q["type"] == "SA":
for w, et in q["wrong"].items():
texts.append(w); labels.append(et)
for et, ws in SEED.items():
texts += ws; labels += [et] * len(ws)
m = make_pipeline(TfidfVectorizer(analyzer="char_wb", ngram_range=(1, 3)), LogisticRegression(max_iter=2000, C=5))
return m.fit([norm_text(t) for t in texts], labels)
def classify_error(q, val, text_model):
"""오답 1건의 유형과 판단 근거(rule/text)를 돌려준다."""
if q["type"] == "MC":
return q["dist"].get(int(val), "개념 혼동") if val in "1234" and val else "개념 혼동", "보기 매핑"
if q["type"] == "ORDER":
return "절차 누락", ("단계 누락" if len(val) < 4 else "순서 뒤바뀜")
known = {norm_text(w): et for w, et in q["wrong"].items()}
if val in known:
return known[val], "오답 목록"
near = max(known, key=lambda w: difflib.SequenceMatcher(None, val, w).ratio())
if difflib.SequenceMatcher(None, val, near).ratio() >= 0.75 or sorted(val) == sorted(near):
return known[near], "오답 목록(오탈자)"
return str(text_model.predict([val])[0]), "문자 모델"
PASS_RATE = 0.7
ALERT_AREA = 0.55 # 중대 영역 정답률이 이보다 낮으면 경보 하이라이트
TM = build_text_model()
def grade_batch(df):
"""원본 응답 묶음 -> 정제값·정답 여부·점수·오답 유형 열 추가."""
out = df.copy()
pv = [parse(QIDX[q], r) for q, r in zip(out["qid"], out["raw"])]
out["val"], out["status"] = [p[0] for p in pv], [p[1] for p in pv]
out["area"] = out["qid"].map(lambda q: QIDX[q]["area"])
out["correct"] = [s == "정상" and grade_fuzzy(QIDX[q], v) for q, v, s in zip(out["qid"], out["val"], out["status"])]
out["score"] = [score_of(QIDX[q], c) for q, c in zip(out["qid"], out["correct"])]
out["etype"] = [None if c or s != "정상" else classify_error(QIDX[q], v, TM)[0]
for q, v, c, s in zip(out["qid"], out["val"], out["correct"], out["status"])]
return out
class Board:
"""대시보드 상태: 누적 집계만 갱신해 매번 전체를 다시 계산하지 않는다."""
def __init__(self):
self.data = pd.DataFrame()
def update(self, batch):
self.data = pd.concat([self.data, grade_batch(batch)], ignore_index=True)
d = self.data
self.area = d.pivot_table(index="unit", columns="area", values="correct", aggfunc="mean")
self.etype = d.dropna(subset=["etype"]).groupby(["unit", "etype"]).size().unstack(fill_value=0)
self.person = d.groupby(["sid", "unit"])["score"].sum().reset_index()
self.item = d.groupby("qid")["correct"].mean()
def query(self, unit=None, area=None):
d = self.data
if unit:
d = d[d["unit"] == unit]
if area:
d = d[d["area"] == area]
return d.groupby("qid")["correct"].mean().sort_values().head(3)
full = make_responses(40, seed=7)
FULL_SCORE = sum(POINTS[q["diff"]] for q in BANK)
board = Board()
# 1) 실시간 갱신 모의: 응시자 응답이 30명 단위로 4번 들어온다
times = []
for k, sids in enumerate(np.array_split(full["sid"].unique(), 4), 1):
t0 = time.perf_counter()
board.update(full[full["sid"].isin(sids)])
times.append((time.perf_counter() - t0) * 1000)
print(f"갱신 {k}: 누적 응답 {len(board.data):,}건, 집계 갱신 {times[-1]:.0f} ms")
print(f"최대 갱신 시간 {max(times):.0f} ms (목표 1초 이내: {'달성' if max(times) < 1000 else '미달'})")
# 2) 필터 조회 응답성(교수자가 중대·영역을 바꿔 가며 조회)
q_times = []
for u in UNITS + [None]:
for a in AREAS + [None]:
t0 = time.perf_counter(); board.query(u, a); q_times.append((time.perf_counter() - t0) * 1000)
print(f"필터 조회 {len(q_times)}회, 평균 {np.mean(q_times):.1f} ms, 최대 {max(q_times):.1f} ms (목표 100 ms 이내: {'달성' if max(q_times) < 100 else '미달'})")
# 3) 대시보드 구성요소 4종
fig, ax = plt.subplots(2, 2, figsize=(12, 8))
AREA_EN = {"형상식별": "Shape", "피아식별": "IFF", "경보보고": "Report", "교전통제": "Control", "대드론일반": "C-UAS"}
UNIT_EN = {"가상1중대": "Co.1", "가상2중대": "Co.2", "가상3중대": "Co.3"}
ET_EN = {"개념 혼동": "Concept", "피아 오인": "IFF error", "절차 누락": "Procedure"}
A = board.area[AREAS]
im = ax[0, 0].imshow(A.values, cmap="RdYlGn", vmin=0.3, vmax=1.0)
ax[0, 0].set_xticks(range(len(AREAS)), [AREA_EN[a] for a in AREAS]); ax[0, 0].set_yticks(range(3), [UNIT_EN[u] for u in A.index])
for i in range(A.shape[0]):
for j in range(A.shape[1]):
v = A.values[i, j]
ax[0, 0].text(j, i, f"{v:.2f}", ha="center", va="center", fontweight="bold" if v < ALERT_AREA else "normal",
color="white" if v < ALERT_AREA else "black")
ax[0, 0].set_title("Accuracy by company x area (bold = alert)"); fig.colorbar(im, ax=ax[0, 0])
E = board.etype.rename(index=UNIT_EN, columns=ET_EN)
E.div(E.sum(axis=1), axis=0).plot.bar(stacked=True, ax=ax[0, 1], rot=0)
ax[0, 1].set_title("Error type share by company"); ax[0, 1].set_ylabel("share")
for u in UNITS:
ax[1, 0].hist(board.person.loc[board.person["unit"] == u, "score"], bins=12, alpha=0.5, label=UNIT_EN[u])
ax[1, 0].axvline(FULL_SCORE * PASS_RATE, color="k", ls="--", label="pass line"); ax[1, 0].legend()
ax[1, 0].set_title("Score distribution"); ax[1, 0].set_xlabel("score")
it = board.item.sort_values()
ax[1, 1].barh(it.index[:10], it.values[:10], color="tab:red"); ax[1, 1].set_xlim(0, 1)
ax[1, 1].set_title("10 hardest items (accuracy)")
plt.tight_layout(); plt.show()
print("대시보드 구성요소 4종 렌더링: 중대x영역 정답률, 오답 유형 비율, 점수 분포, 고난도 문항")
# 4) 요약 지표와 경보 하이라이트
p = board.person.assign(passed=board.person["score"] >= FULL_SCORE * PASS_RATE)
print("\n[중대별 요약]")
print(p.groupby("unit").agg(응시=("sid", "size"), 평균점수=("score", "mean"), 합격률=("passed", "mean")).round(2).to_string())
alerts = [(u, a, A.loc[u, a]) for u in A.index for a in AREAS if A.loc[u, a] < ALERT_AREA]
print(f"\n[경보 하이라이트] 영역 정답률 {ALERT_AREA} 미만 {len(alerts)}건")
for u, a, v in sorted(alerts, key=lambda x: x[2]):
print(f" - {u} {a}: 정답률 {v:.2f}, 최저 문항 {list(board.query(u, a).index)}")
top = board.etype.sum().sort_values(ascending=False)
print("부대 전체 오답 유형:", {k: int(v) for k, v in top.items()})
print("야간 교육장 사용 시 plt.style.use('dark_background')로 어두운 화면 모드를 적용할 수 있다.")
실행 결과 예시
갱신 1: 누적 응답 960건, 집계 갱신 39 ms
갱신 2: 누적 응답 1,920건, 집계 갱신 35 ms
갱신 3: 누적 응답 2,880건, 집계 갱신 39 ms
갱신 4: 누적 응답 3,840건, 집계 갱신 38 ms
최대 갱신 시간 39 ms (목표 1초 이내: 달성)
필터 조회 24회, 평균 2.7 ms, 최대 3.4 ms (목표 100 ms 이내: 달성)
대시보드 구성요소 4종 렌더링: 중대x영역 정답률, 오답 유형 비율, 점수 분포, 고난도 문항
[중대별 요약]
응시 평균점수 합격률
unit
가상1중대 40 75.40 0.68
가상2중대 40 58.55 0.28
가상3중대 40 68.18 0.57
[경보 하이라이트] 영역 정답률 0.55 미만 1건
- 가상2중대 교전통제: 정답률 0.50, 최저 문항 ['Q31', 'Q15', 'Q14']
부대 전체 오답 유형: {'절차 누락': 419, '개념 혼동': 367, '피아 오인': 186}
야간 교육장 사용 시 plt.style.use('dark_background')로 어두운 화면 모드를 적용할 수 있다.
미션 7. 이의 제기 문항 및 서술형 답변에 대한 교관 수동 채점(HITL) 검토 인터페이스 설계
- 난이도: 고급 / 예상 소요시간: 3.0시간
- 코드 설명: 이의 제기된 단답형 문항과 서술형 답변을 교관 검토 대기열(HITL)에 올려, 자동 채점은 '제안'으로만 두고 교관의 인정·기각·수정 결정과 사유를 해시 체인 감사 로그에 남기는 검토 흐름을 설계한다. 합격선 근처 응시자를 먼저 검토하는 우선순위 큐의 대기시간, 로깅 성공률, 무결성 검증을 확인한다.
"""이의 제기된 단답형 문항과 서술형 답변을 교관 검토 대기열(HITL)에 올려, 자동 채점은 '제안'으로만 두고 교관의 인정·기각·수정 결정과 사유를 해시 체인 감사 로그에 남기는 검토 흐름을 설계한다. 합격선 근처 응시자를 먼저 검토하는 우선순위 큐의 대기시간, 로깅 성공률, 무결성 검증을 확인한다."""
import difflib
import hashlib
import heapq
import json
import re
import unicodedata
import numpy as np
import pandas as pd
# ---------------------------------------------------------------------------
# [교육용가상자료] 방공·드론 식별 퀴즈 문항 은행과 가상 응시자 응답 생성기
# 문항은 일반 원칙 수준의 가상 문항이며 실제 교범·교전규칙 원문이 아니다. 응시자는 가명(S001~)이다.
# ---------------------------------------------------------------------------
AREAS = ["형상식별", "피아식별", "경보보고", "교전통제", "대드론일반"]
ETYPES = ["개념 혼동", "피아 오인", "절차 누락"]
POINTS = {1: 2, 2: 3, 3: 5}
C, F, P = ETYPES
def _mc(qid, area, diff, stem, opts, ans, dist):
return {"qid": qid, "type": "MC", "area": area, "diff": diff, "stem": stem, "options": opts, "answer": ans,
"dist": dict(zip([i for i in range(1, 5) if i != ans], dist))}
def _sa(qid, area, diff, stem, accept, wrong):
return {"qid": qid, "type": "SA", "area": area, "diff": diff, "stem": stem, "accept": accept, "wrong": wrong}
def _order(qid, area, diff, stem):
return {"qid": qid, "type": "ORDER", "area": area, "diff": diff, "stem": stem, "answer": "ABCD"}
BANK = [
_mc("Q01", "형상식별", 1, "꼬리날개 없이 삼각형 날개를 가진 소형 고정익 실루엣은?", ["쿼드콥터형", "삼각익 고정익형", "회전익 헬기형", "조류"], 2, [C, C, F]),
_mc("Q02", "형상식별", 1, "회전날개 4개가 십자로 배치된 실루엣은?", ["고정익 드론형", "쿼드콥터형", "순항미사일형", "탄도미사일형"], 2, [C, C, C]),
_mc("Q03", "형상식별", 2, "가늘고 긴 동체 중간에 작은 날개가 붙은 실루엣은?", ["순항미사일형", "탄도미사일형", "민간 경비행기형", "조류"], 1, [C, F, F]),
_mc("Q04", "형상식별", 3, "원거리라 실루엣 식별이 어려울 때 먼저 할 행동은?", ["추정 유형으로 확정 보고", "관측 정보를 그대로 보고하고 추가 관측", "관측 중단", "민간기로 간주"], 2, [P, P, F]),
_mc("Q05", "피아식별", 2, "식별 신호 응답이 없는 항공기에 대한 올바른 판단은?", ["즉시 적기로 확정", "미확인 항공기로 분류하고 추가 확인", "아군기로 간주", "무시"], 2, [F, F, P]),
_mc("Q06", "피아식별", 2, "운항 정보와 일치하는 저속 비행체의 분류는?", ["적 드론", "민간기 가능성 높음, 추가 확인", "순항미사일", "아군 정찰기로 확정"], 2, [F, C, F]),
_mc("Q07", "피아식별", 3, "아군 드론 운용 공지가 있는 공역에서 미확인 드론을 보면 우선 확인할 것은?", ["기상 정보", "아군 운용 계획과 식별 신호", "대응 준비 상태", "주변 지형"], 2, [C, P, C]),
_mc("Q08", "피아식별", 3, "조류 무리와 소형 드론 군집 구분에 가장 도움이 되는 정보는?", ["크기만 비교", "궤적의 규칙성과 열 신호", "색상", "관측 시각"], 2, [C, C, C]),
_mc("Q09", "경보보고", 1, "공중 위협 최초 보고에 포함하지 않아도 되는 것은?", ["관측 시각", "방향과 거리", "추정 유형", "관측자 개인 연락처"], 4, [P, P, P]),
_mc("Q10", "경보보고", 2, "경보 전파 후 상황이 바뀌면?", ["최초 보고 유지", "변경 사항을 즉시 추가 보고", "상황 종료 후 일괄 보고", "개인 판단으로 경보 해제"], 2, [P, P, P]),
_mc("Q11", "경보보고", 2, "보고 내용이 불확실할 때 올바른 표현은?", ["확정 표현으로 보고", "'추정'임을 밝히고 근거와 함께 보고", "보고 보류", "다른 관측자 보고를 복사"], 2, [C, P, P]),
_mc("Q12", "경보보고", 1, "드론 탐지 후 보고 경로로 알맞은 것은?", ["인접 부대에만 전파", "지휘계통을 따라 보고", "개인 메신저로 공유", "보고 없이 대응"], 2, [P, P, P]),
_mc("Q13", "교전통제", 1, "AI 탐지 결과만으로 대응을 결정할 수 있는가?", ["가능하다", "인간 지휘관의 승인이 필요하다", "신뢰도 90% 이상이면 가능", "야간에만 가능"], 2, [C, C, C]),
_mc("Q14", "교전통제", 2, "민간 피해 가능성이 확인되면?", ["그대로 진행", "즉시 보고하고 지휘관 판단을 받는다", "조치 후 보고", "무시"], 2, [P, P, F]),
_mc("Q15", "교전통제", 3, "식별이 불확실한 표적에 대한 원칙은?", ["위협으로 간주해 즉시 대응", "식별 확인 전까지 승인 절차를 따른다", "무조건 무시", "다수결로 결정"], 2, [F, F, C]),
_mc("Q16", "교전통제", 2, "판단 근거를 기록해야 하는 이유는?", ["형식상 필요", "사후 검토와 책임 추적", "보고 생략", "기록 불필요"], 2, [C, P, P]),
_mc("Q17", "대드론일반", 1, "대드론 체계의 일반적 처리 순서는?", ["대응-탐지-식별", "탐지-식별-보고·판단-대응", "식별-대응-탐지", "보고-탐지-대응"], 2, [P, P, P]),
_mc("Q18", "대드론일반", 3, "레이더만으로 조류와 드론 구분이 어려운 이유는?", ["반사 신호 크기가 비슷할 수 있어서", "레이더는 야간에 못 써서", "드론에는 금속이 없어서", "조류는 탐지되지 않아서"], 1, [C, C, C]),
_mc("Q19", "대드론일반", 2, "전자광학(EO) 센서의 약점이 커지는 조건은?", ["맑은 주간", "안개·야간", "저속 표적", "근거리"], 2, [C, C, C]),
_mc("Q20", "대드론일반", 2, "경보가 반복해서 오경보로 판명되면?", ["경보 장치를 끈다", "사례를 기록·보고하고 기준 재검토 요청", "무시한다", "모두 실제 위협으로 간주"], 2, [P, P, F]),
_sa("Q21", "피아식별", 1, "아군·적·민간을 구분하는 절차를 무엇이라 하는가?", ["피아식별", "IFF"], {"표적획득": C, "적기판정": F, "경계": C}),
_sa("Q22", "형상식별", 2, "꼬리날개 없는 삼각형 날개 형태를 무엇이라 하는가?", ["삼각익", "델타익", "delta"], {"직선익": C, "후퇴익": C, "쿼드콥터": C}),
_sa("Q23", "경보보고", 2, "불확실한 관측을 보고할 때 붙이는 표현은?", ["추정"], {"확정": C, "확인": C, "생략": P}),
_sa("Q24", "교전통제", 1, "AI 탐지 결과에 대해 최종 결정을 내리는 주체는?", ["지휘관"], {"AI": C, "운용병": P, "레이더": C}),
_sa("Q25", "대드론일반", 1, "조류를 드론으로 잘못 경보하는 것을 무엇이라 하는가?", ["오경보", "오탐", "false alarm"], {"미탐": C, "재경보": C, "적기": F}),
_sa("Q26", "피아식별", 2, "운항 정보가 확인된 여객기는 어떻게 분류하는가?", ["민간기", "민항기"], {"적기": F, "미확인기": P, "아군기": F}),
_sa("Q27", "형상식별", 1, "회전날개 4개를 가진 드론 형태는?", ["쿼드콥터", "quadcopter"], {"헥사콥터": C, "고정익": C, "헬기": F}),
_sa("Q28", "경보보고", 1, "보고는 무엇을 따라 올라가는가?", ["지휘계통"], {"인접부대": P, "개인메신저": P, "통신망": C}),
_order("Q29", "경보보고", 2, "최초 보고 순서: A 관측 시각, B 방향·거리, C 추정 유형, D 관측자 조치"),
_order("Q30", "대드론일반", 2, "처리 순서: A 탐지, B 식별, C 보고·판단, D 대응"),
_order("Q31", "교전통제", 3, "통제 순서: A 식별 확인, B 상급 승인 요청, C 승인 수령, D 조치 기록"),
_order("Q32", "피아식별", 3, "확인 순서: A 운항정보 대조, B 식별 신호 확인, C 미확인 분류, D 보고"),
]
QIDX = {q["qid"]: q for q in BANK}
UNITS = ["가상1중대", "가상2중대", "가상3중대"]
_CIRC = "①②③④"
PARA = {"Q21": "아군적군 구분", "Q22": "세모 날개", "Q23": "추정됨으로 표기", "Q24": "중대장(지휘관)", "Q25": "잘못된 경보",
"Q26": "민간 여객기", "Q27": "4발 드론", "Q28": "지휘 라인"} # 뜻은 맞지만 인정 답안 목록에 없는 표현
def make_soldiers(n_per_unit, seed):
rng = np.random.default_rng(seed)
out = []
for u, unit in enumerate(UNITS):
base = [0.3, -0.1, 0.0][u]
for i in range(n_per_unit):
off = rng.normal(0, 0.5, len(AREAS)) + (np.array([0, 0, 0, -0.6, 0]) if u == 1 else 0)
out.append({"sid": f"S{len(out) + 1:03d}", "unit": unit, "theta": dict(zip(AREAS, base + rng.normal(0, 0.6) + off)),
"tend": dict(zip(ETYPES, rng.dirichlet([1.2, 1.2, 1.2])))})
return out
def p_correct(theta, diff):
return 1 / (1 + np.exp(-1.7 * (theta - (diff - 2) * 0.9 + 0.9)))
def _typo(s, rng):
if len(s) < 2:
return s
i = int(rng.integers(len(s) - 1))
return s[:i] + s[i + 1] + s[i] + s[i + 2:] if len(s) <= 3 or rng.random() < 0.5 else s[:i] + s[i + 1:]
def _fmt_mc(k, rng):
r = rng.random()
if r < 0.55:
return str(k)
return rng.choice([_CIRC[k - 1], f"{k}번", "ABCD"[k - 1], "abcd"[k - 1] + ")", f" {k} ", chr(0xFF10 + k), f"{k}."])
def _fmt_sa(s, rng):
r = rng.random()
if r < 0.45:
return s
if r < 0.6:
return " ".join(s) if len(s) <= 4 else s[:2] + " " + s[2:]
if r < 0.72:
return s + rng.choice(["입니다", "이다", ".", "임"])
if r < 0.8:
return s.upper() if s.isascii() else "".join(chr(ord(ch) + 0xFEE0) if "!" <= ch <= "~" else ch for ch in s) + " "
return _typo(s, rng)
def answer(q, sol, rng):
"""한 문항 응답: (원본 응답 문자열, 의도한 정답 여부, 의도한 답, 오답 유형)."""
ok = rng.random() < p_correct(sol["theta"][q["area"]], q["diff"])
if q["type"] == "MC":
if ok:
k, et = q["answer"], None
else:
ks = list(q["dist"]); w = np.array([sol["tend"][q["dist"][k]] for k in ks])
k = int(rng.choice(ks, p=w / w.sum())); et = q["dist"][k]
return _fmt_mc(k, rng), ok, str(k), et
if q["type"] == "SA":
if ok and rng.random() < 0.06:
return PARA[q["qid"]], ok, PARA[q["qid"]], None
if ok:
s, et = str(rng.choice(q["accept"])), None
else:
ws = list(q["wrong"]); w = np.array([sol["tend"][q["wrong"][x]] for x in ws])
s = str(rng.choice(ws, p=w / w.sum())); et = q["wrong"][s]
return _fmt_sa(s, rng), ok, s, et
if ok:
s = "ABCD"
elif rng.random() < 0.5:
s = "ABCD".replace("ABCD"[rng.integers(1, 4)], "") # 단계 하나 빠뜨림
else:
i = int(rng.integers(0, 3)); s = list("ABCD"); s[i], s[i + 1] = s[i + 1], s[i]; s = "".join(s) # 순서 뒤바뀜
raw = s if rng.random() < 0.6 else rng.choice(["-".join(s), " ".join(s), s.lower(), ",".join(s)])
return raw, ok, s, (None if ok else P)
def make_responses(n_per_unit=40, seed=7, blank=0.02):
"""응시자 x 문항 응답표(DataFrame)를 만든다. truth_* 열은 교관 기준 정답(평가용)이다."""
rng = np.random.default_rng(seed)
rows = []
for sol in make_soldiers(n_per_unit, seed):
for q in BANK:
raw, ok, intended, et = answer(q, sol, rng)
if rng.random() < blank:
raw, ok, intended, et = "", False, "", None
rows.append({"rid": f"R{len(rows):05d}", "sid": sol["sid"], "unit": sol["unit"], "qid": q["qid"], "raw": raw,
"truth_correct": ok, "truth_answer": intended, "truth_etype": et})
return pd.DataFrame(rows)
# ---------------------------------------------------------------------------
# ---- 응답 정제·파싱·채점 공통 함수 ----
TAILS = ("입니다", "이다", "임")
def norm_text(s):
"""전각->반각(NFKC), 소문자, 공백·문장부호 제거, 끝맺음 어미 제거."""
s = unicodedata.normalize("NFKC", str(s)).strip().lower()
s = re.sub(r"[\s\.\,\-\(\)·]", "", s)
for t in TAILS:
if s.endswith(t) and len(s) > len(t) + 1:
s = s[:-len(t)]
return s
def parse(q, raw):
"""원본 응답 -> (정제값, 상태). 상태: 정상 / 미응답 / 형식오류"""
if raw is None or str(raw).strip() == "":
return "", "미응답"
s = unicodedata.normalize("NFKC", str(raw)).strip()
if q["type"] == "MC":
t = re.sub(r"[\s번\.\)]", "", s).lower()
t = {"a": "1", "b": "2", "c": "3", "d": "4"}.get(t, t)
return (t, "정상") if t in ("1", "2", "3", "4") else (t, "형식오류")
if q["type"] == "ORDER":
t = re.sub(r"[^A-Za-z]", "", s).upper()
return (t, "정상") if t and set(t) <= set("ABCD") and len(set(t)) == len(t) else (t, "형식오류")
return norm_text(s), "정상"
def grade_exact(q, val):
if q["type"] == "MC":
return val == str(q["answer"])
if q["type"] == "ORDER":
return val == q["answer"]
return val in {norm_text(a) for a in q["accept"]}
def grade_fuzzy(q, val, cut=0.75):
"""단답형은 오탈자(글자 순서 바뀜·한 글자 누락)를 허용(유사도 cut 이상)하되, 알려진 오답과 더 가까우면 오답으로 본다."""
if grade_exact(q, val) or q["type"] != "SA" or len(val) < 2:
return grade_exact(q, val)
best = max(1.0 if sorted(val) == sorted(norm_text(a)) else difflib.SequenceMatcher(None, val, norm_text(a)).ratio() for a in q["accept"])
worst = max(difflib.SequenceMatcher(None, val, norm_text(w)).ratio() for w in q["wrong"])
return best >= cut and best > worst
def score_of(q, correct):
return POINTS[q["diff"]] if correct else 0
# ---- 서술형 문항(10점): 핵심 요소 5개 x 2점 ----
ESSAY = "미확인 드론을 관측했을 때 보고와 대응 절차를 서술하시오."
ELEM = {"관측정보": (["관측 시각과 방향, 거리를 적는다", "언제 어느 방향 몇 km인지 기록한다"], ["시각", "방향", "거리"]),
"추정표현": (["확실하지 않으면 추정이라고 밝힌다", "모르는 부분은 추정임을 명시한다"], ["추정"]),
"지휘계통": (["지휘계통을 따라 즉시 보고한다", "소대장에게 보고하고 상급으로 올린다"], ["지휘계통"]),
"승인대기": (["지휘관 승인 전에는 대응하지 않는다", "승인이 나기 전까지 조치하지 않고 대기한다"], ["승인"]),
"근거기록": (["판단 근거를 기록한다", "조치 내용을 일지에 남긴다"], ["기록", "근거"])}
WRONG_SENT = ["승인 없이 바로 대응한다", "기록은 상황 종료 후 생략한다", "확정된 적 드론으로 보고한다"] # 키워드는 있지만 틀린 진술
def make_essays(sids, seed):
rng = np.random.default_rng(seed)
out = []
for s in sids:
have = [e for e in ELEM if rng.random() < 0.65]
sents = [str(rng.choice(ELEM[e][0])) for e in have]
if rng.random() < 0.2:
sents.append(str(rng.choice(WRONG_SENT)))
rng.shuffle(sents)
out.append({"sid": s, "text": " ".join(sents), "truth": 2 * len(have)})
return out
def auto_suggest(text):
"""키워드 포함 여부로 점수를 '제안'한다(확정 아님). 부정 문맥을 구분하지 못하는 한계가 있다."""
hit = [e for e, (_, kws) in ELEM.items() if any(k in text for k in kws)]
conf = "낮음" if any(w in text for w in ("없이", "생략", "확정")) or len(text) < 25 else "보통"
return 2 * len(hit), hit, conf
class ReviewQueue:
"""우선순위 큐 + 해시 체인 감사 로그. 교관 결정 없이는 점수를 확정할 수 없다."""
def __init__(self):
self.heap, self.items, self.log, self.final = [], {}, [], {}
def _audit(self, event, **kw):
prev = self.log[-1]["hash"] if self.log else "GENESIS"
body = {"seq": len(self.log), "event": event, **kw}
self.log.append({**body, "prev": prev, "hash": hashlib.sha256((prev + json.dumps(body, sort_keys=True, ensure_ascii=False)).encode()).hexdigest()})
def submit(self, iid, prio, t, payload):
self.items[iid] = {**payload, "t_in": t, "status": "PENDING"}
heapq.heappush(self.heap, (prio, t, iid))
self._audit("SUBMIT", iid=iid, prio=prio, suggest=payload.get("suggest"))
def pop(self):
return heapq.heappop(self.heap)[2] if self.heap else None
def decide(self, iid, reviewer, decision, score, reason, t):
if not reason:
raise ValueError("사유 없는 결정은 기록할 수 없다")
it = self.items[iid]
it.update(status=decision, score=score, reviewer=reviewer, t_out=t)
self.final[iid] = score
self._audit("DECIDE", iid=iid, reviewer=reviewer, decision=decision, score=score, reason=reason)
def finalize_without_review(self, iid):
if self.items[iid]["status"] == "PENDING":
raise PermissionError("교관 결정 전 자동 확정 금지")
def verify(self):
prev = "GENESIS"
for e in self.log:
body = {k: v for k, v in e.items() if k not in ("prev", "hash")}
if e["prev"] != prev or hashlib.sha256((prev + json.dumps(body, sort_keys=True, ensure_ascii=False)).encode()).hexdigest() != e["hash"]:
return False, e["seq"]
prev = e["hash"]
return True, None
rng = np.random.default_rng(3)
df = make_responses(40, seed=7)
df["val"] = [parse(QIDX[q], r)[0] for q, r in zip(df["qid"], df["raw"])]
df["correct"] = [grade_fuzzy(QIDX[q], v) for q, v in zip(df["qid"], df["val"])]
df["score"] = [score_of(QIDX[q], c) for q, c in zip(df["qid"], df["correct"])]
total = df.groupby("sid")["score"].sum()
PASS = 0.7 * (sum(POINTS[q["diff"]] for q in BANK) + 10)
# 1) 이의 제기: 자동 채점 오답 처리된 단답형 중 응시자가 이의를 낸 건(실제 맞은 답은 대부분 이의 제기, 틀린 답도 일부 제기)
sa_wrong = df[(df["qid"].map(lambda q: QIDX[q]["type"]) == "SA") & ~df["correct"] & (df["raw"] != "")]
appeal = sa_wrong[sa_wrong["truth_correct"] | (rng.random(len(sa_wrong)) < 0.08)]
essays = make_essays(sorted(df["sid"].unique()), seed=5)
print(f"[교육용가상자료] 이의 제기 {len(appeal)}건, 서술형 답안 {len(essays)}건이 교관 검토 대기열에 들어간다.")
q = ReviewQueue()
t = 0.0
for e in essays:
sug, hit, conf = auto_suggest(e["text"])
est = total[e["sid"]] + sug
prio = (0 if abs(est - PASS) <= 6 else 1) * 10 + (0 if conf == "낮음" else 1) # 합격선 근처·저신뢰 먼저
q.submit(f"E-{e['sid']}", prio, t, {"kind": "서술형", "sid": e["sid"], "suggest": sug, "conf": conf, "truth": e["truth"], "text": e["text"]})
t += 0.05
for _, r in appeal.iterrows():
est = total[r["sid"]]
prio = (0 if abs(est - PASS) <= 6 else 1) * 10 + 2
q.submit(f"A-{r['rid']}", prio, t, {"kind": "이의 제기", "sid": r["sid"], "qid": r["qid"], "raw": r["raw"], "suggest": 0,
"truth": score_of(QIDX[r["qid"]], r["truth_correct"])})
t += 0.05
# 2) 자동 확정 시도는 차단되어야 한다
blocked = 0
for iid in list(q.items)[:5]:
try:
q.finalize_without_review(iid)
except PermissionError:
blocked += 1
try:
q.decide(list(q.items)[0], "교관A", "인정", 2, "", t)
no_reason = "통과(오류)"
except ValueError:
no_reason = "차단"
print(f"결정 전 자동 확정 시도 5건 중 차단 {blocked}건, 사유 없는 결정 {no_reason}")
# 3) 교관 2명이 우선순위대로 검토(모의 교관은 기준 답안으로 판단: 건당 서술형 2분, 이의 제기 1분)
clock = {"교관A": 0.0, "교관B": 0.0}
while True:
iid = q.pop()
if iid is None:
break
it = q.items[iid]
who = min(clock, key=clock.get)
clock[who] = max(clock[who], it["t_in"]) + (2.0 if it["kind"] == "서술형" else 1.0)
if it["kind"] == "서술형":
dec = "승인" if it["truth"] == it["suggest"] else "수정"
reason = "제안 점수와 요소 판단 일치" if dec == "승인" else f"요소 재판단(제안 {it['suggest']} -> {it['truth']})"
else:
dec = "인정" if it["truth"] > 0 else "기각"
reason = f"'{it['raw']}' 의미상 정답 인정" if dec == "인정" else f"'{it['raw']}'는 오답 유지"
q.decide(iid, who, dec, it["truth"], reason, clock[who])
R = pd.DataFrame(q.items.values())
R["wait"] = R["t_out"] - R["t_in"]
R["border"] = [abs(total[s] + (sg if k == "서술형" else 0) - PASS) <= 6 for s, sg, k in zip(R["sid"], R["suggest"], R["kind"])]
print("\n[검토 결과]")
print(R.groupby(["kind", "status"]).size().to_string())
ess = R[R["kind"] == "서술형"]
print(f"서술형 자동 제안 점수와 교관 판단 일치 {(ess['status'] == '승인').mean():.1%}, 수정 {(ess['status'] == '수정').sum()}건"
f"(틀린 진술 속 키워드에 속은 제안 포함)")
print(f"대기시간(분): 합격선 근처 평균 {R.loc[R['border'], 'wait'].mean():.1f}, 그 외 평균 {R.loc[~R['border'], 'wait'].mean():.1f}, 최대 {R['wait'].max():.1f}")
n_dec = sum(e["event"] == "DECIDE" for e in q.log)
print(f"승인/반려 처리 {len(R)}건, 감사 로그 기록 {n_dec}건, 로깅 성공률 {n_dec / len(R):.0%} (목표 100%: {'달성' if n_dec == len(R) else '미달'})")
ok, _ = q.verify()
q.log[7]["score"] = 10 # 감사 로그 사후 변조 시도
ok2, where = q.verify()
print(f"무결성 검증: 원본 {'정상' if ok else '이상'}, 변조 후 {'정상' if ok2 else f'이상 탐지(seq {where})'}")
changed = R[(R["kind"] == "이의 제기") & (R["status"] == "인정")].groupby("sid")["score"].sum()
newtot = total.add(changed, fill_value=0) + ess.set_index("sid")["score"]
flip = int(((total + ess.set_index("sid")["suggest"] >= PASS) != (newtot >= PASS)).sum())
print(f"교관 검토로 합격/불합격이 바뀐 응시자 {flip}명 -> 자동 채점만으로 확정하면 안 되는 이유")
print("\n[HITL 검토 지침 요약] 1) 자동 점수는 제안일 뿐 교관이 확정 2) 인정·기각·수정 모두 사유 필수"
" 3) 합격선 근처·저신뢰 답안 우선 4) 감사 로그는 수정 불가, 이의 재심은 상급 교관(지휘 책임선)이 판단")
실행 결과 예시
[교육용가상자료] 이의 제기 45건, 서술형 답안 120건이 교관 검토 대기열에 들어간다.
결정 전 자동 확정 시도 5건 중 차단 5건, 사유 없는 결정 차단
[검토 결과]
kind status
서술형 수정 54
승인 66
이의 제기 기각 7
인정 38
서술형 자동 제안 점수와 교관 판단 일치 55.0%, 수정 54건(틀린 진술 속 키워드에 속은 제안 포함)
대기시간(분): 합격선 근처 평균 20.0, 그 외 평균 94.7, 최대 137.4
승인/반려 처리 165건, 감사 로그 기록 165건, 로깅 성공률 100% (목표 100%: 달성)
무결성 검증: 원본 정상, 변조 후 이상 탐지(seq 7)
교관 검토로 합격/불합격이 바뀐 응시자 3명 -> 자동 채점만으로 확정하면 안 되는 이유
[HITL 검토 지침 요약] 1) 자동 점수는 제안일 뿐 교관이 확정 2) 인정·기각·수정 모두 사유 필수 3) 합격선 근처·저신뢰 답안 우선 4) 감사 로그는 수정 불가, 이의 재심은 상급 교관(지휘 책임선)이 판단
미션 8. 전 부대원 동시 응시 모의 대규모 트래픽 채점 부하 테스트 및 최적화
- 난이도: 고급 / 예상 소요시간: 3.5시간
- 코드 설명: 전 부대원 동시 응시를 모의해 평시(120명) 대비 최대 1,000% 늘어난 제출량과 손상 답안·채점기 장애를 주입하는 채점 부하 테스트를 수행한다. 처리량, 시스템 다운, 데이터 유실을 측정하고, 장애 시 '채점 보류'로 보존하는 안전 실패 동작과 캐시 최적화 전후 처리량을 비교한다.
"""전 부대원 동시 응시를 모의해 평시(120명) 대비 최대 1,000% 늘어난 제출량과 손상 답안·채점기 장애를 주입하는 채점 부하 테스트를 수행한다. 처리량, 시스템 다운, 데이터 유실을 측정하고, 장애 시 '채점 보류'로 보존하는 안전 실패 동작과 캐시 최적화 전후 처리량을 비교한다."""
import difflib
import functools
import re
import time
import tracemalloc
import unicodedata
from collections import deque
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
# ---------------------------------------------------------------------------
# [교육용가상자료] 방공·드론 식별 퀴즈 문항 은행과 가상 응시자 응답 생성기
# 문항은 일반 원칙 수준의 가상 문항이며 실제 교범·교전규칙 원문이 아니다. 응시자는 가명(S001~)이다.
# ---------------------------------------------------------------------------
AREAS = ["형상식별", "피아식별", "경보보고", "교전통제", "대드론일반"]
ETYPES = ["개념 혼동", "피아 오인", "절차 누락"]
POINTS = {1: 2, 2: 3, 3: 5}
C, F, P = ETYPES
def _mc(qid, area, diff, stem, opts, ans, dist):
return {"qid": qid, "type": "MC", "area": area, "diff": diff, "stem": stem, "options": opts, "answer": ans,
"dist": dict(zip([i for i in range(1, 5) if i != ans], dist))}
def _sa(qid, area, diff, stem, accept, wrong):
return {"qid": qid, "type": "SA", "area": area, "diff": diff, "stem": stem, "accept": accept, "wrong": wrong}
def _order(qid, area, diff, stem):
return {"qid": qid, "type": "ORDER", "area": area, "diff": diff, "stem": stem, "answer": "ABCD"}
BANK = [
_mc("Q01", "형상식별", 1, "꼬리날개 없이 삼각형 날개를 가진 소형 고정익 실루엣은?", ["쿼드콥터형", "삼각익 고정익형", "회전익 헬기형", "조류"], 2, [C, C, F]),
_mc("Q02", "형상식별", 1, "회전날개 4개가 십자로 배치된 실루엣은?", ["고정익 드론형", "쿼드콥터형", "순항미사일형", "탄도미사일형"], 2, [C, C, C]),
_mc("Q03", "형상식별", 2, "가늘고 긴 동체 중간에 작은 날개가 붙은 실루엣은?", ["순항미사일형", "탄도미사일형", "민간 경비행기형", "조류"], 1, [C, F, F]),
_mc("Q04", "형상식별", 3, "원거리라 실루엣 식별이 어려울 때 먼저 할 행동은?", ["추정 유형으로 확정 보고", "관측 정보를 그대로 보고하고 추가 관측", "관측 중단", "민간기로 간주"], 2, [P, P, F]),
_mc("Q05", "피아식별", 2, "식별 신호 응답이 없는 항공기에 대한 올바른 판단은?", ["즉시 적기로 확정", "미확인 항공기로 분류하고 추가 확인", "아군기로 간주", "무시"], 2, [F, F, P]),
_mc("Q06", "피아식별", 2, "운항 정보와 일치하는 저속 비행체의 분류는?", ["적 드론", "민간기 가능성 높음, 추가 확인", "순항미사일", "아군 정찰기로 확정"], 2, [F, C, F]),
_mc("Q07", "피아식별", 3, "아군 드론 운용 공지가 있는 공역에서 미확인 드론을 보면 우선 확인할 것은?", ["기상 정보", "아군 운용 계획과 식별 신호", "대응 준비 상태", "주변 지형"], 2, [C, P, C]),
_mc("Q08", "피아식별", 3, "조류 무리와 소형 드론 군집 구분에 가장 도움이 되는 정보는?", ["크기만 비교", "궤적의 규칙성과 열 신호", "색상", "관측 시각"], 2, [C, C, C]),
_mc("Q09", "경보보고", 1, "공중 위협 최초 보고에 포함하지 않아도 되는 것은?", ["관측 시각", "방향과 거리", "추정 유형", "관측자 개인 연락처"], 4, [P, P, P]),
_mc("Q10", "경보보고", 2, "경보 전파 후 상황이 바뀌면?", ["최초 보고 유지", "변경 사항을 즉시 추가 보고", "상황 종료 후 일괄 보고", "개인 판단으로 경보 해제"], 2, [P, P, P]),
_mc("Q11", "경보보고", 2, "보고 내용이 불확실할 때 올바른 표현은?", ["확정 표현으로 보고", "'추정'임을 밝히고 근거와 함께 보고", "보고 보류", "다른 관측자 보고를 복사"], 2, [C, P, P]),
_mc("Q12", "경보보고", 1, "드론 탐지 후 보고 경로로 알맞은 것은?", ["인접 부대에만 전파", "지휘계통을 따라 보고", "개인 메신저로 공유", "보고 없이 대응"], 2, [P, P, P]),
_mc("Q13", "교전통제", 1, "AI 탐지 결과만으로 대응을 결정할 수 있는가?", ["가능하다", "인간 지휘관의 승인이 필요하다", "신뢰도 90% 이상이면 가능", "야간에만 가능"], 2, [C, C, C]),
_mc("Q14", "교전통제", 2, "민간 피해 가능성이 확인되면?", ["그대로 진행", "즉시 보고하고 지휘관 판단을 받는다", "조치 후 보고", "무시"], 2, [P, P, F]),
_mc("Q15", "교전통제", 3, "식별이 불확실한 표적에 대한 원칙은?", ["위협으로 간주해 즉시 대응", "식별 확인 전까지 승인 절차를 따른다", "무조건 무시", "다수결로 결정"], 2, [F, F, C]),
_mc("Q16", "교전통제", 2, "판단 근거를 기록해야 하는 이유는?", ["형식상 필요", "사후 검토와 책임 추적", "보고 생략", "기록 불필요"], 2, [C, P, P]),
_mc("Q17", "대드론일반", 1, "대드론 체계의 일반적 처리 순서는?", ["대응-탐지-식별", "탐지-식별-보고·판단-대응", "식별-대응-탐지", "보고-탐지-대응"], 2, [P, P, P]),
_mc("Q18", "대드론일반", 3, "레이더만으로 조류와 드론 구분이 어려운 이유는?", ["반사 신호 크기가 비슷할 수 있어서", "레이더는 야간에 못 써서", "드론에는 금속이 없어서", "조류는 탐지되지 않아서"], 1, [C, C, C]),
_mc("Q19", "대드론일반", 2, "전자광학(EO) 센서의 약점이 커지는 조건은?", ["맑은 주간", "안개·야간", "저속 표적", "근거리"], 2, [C, C, C]),
_mc("Q20", "대드론일반", 2, "경보가 반복해서 오경보로 판명되면?", ["경보 장치를 끈다", "사례를 기록·보고하고 기준 재검토 요청", "무시한다", "모두 실제 위협으로 간주"], 2, [P, P, F]),
_sa("Q21", "피아식별", 1, "아군·적·민간을 구분하는 절차를 무엇이라 하는가?", ["피아식별", "IFF"], {"표적획득": C, "적기판정": F, "경계": C}),
_sa("Q22", "형상식별", 2, "꼬리날개 없는 삼각형 날개 형태를 무엇이라 하는가?", ["삼각익", "델타익", "delta"], {"직선익": C, "후퇴익": C, "쿼드콥터": C}),
_sa("Q23", "경보보고", 2, "불확실한 관측을 보고할 때 붙이는 표현은?", ["추정"], {"확정": C, "확인": C, "생략": P}),
_sa("Q24", "교전통제", 1, "AI 탐지 결과에 대해 최종 결정을 내리는 주체는?", ["지휘관"], {"AI": C, "운용병": P, "레이더": C}),
_sa("Q25", "대드론일반", 1, "조류를 드론으로 잘못 경보하는 것을 무엇이라 하는가?", ["오경보", "오탐", "false alarm"], {"미탐": C, "재경보": C, "적기": F}),
_sa("Q26", "피아식별", 2, "운항 정보가 확인된 여객기는 어떻게 분류하는가?", ["민간기", "민항기"], {"적기": F, "미확인기": P, "아군기": F}),
_sa("Q27", "형상식별", 1, "회전날개 4개를 가진 드론 형태는?", ["쿼드콥터", "quadcopter"], {"헥사콥터": C, "고정익": C, "헬기": F}),
_sa("Q28", "경보보고", 1, "보고는 무엇을 따라 올라가는가?", ["지휘계통"], {"인접부대": P, "개인메신저": P, "통신망": C}),
_order("Q29", "경보보고", 2, "최초 보고 순서: A 관측 시각, B 방향·거리, C 추정 유형, D 관측자 조치"),
_order("Q30", "대드론일반", 2, "처리 순서: A 탐지, B 식별, C 보고·판단, D 대응"),
_order("Q31", "교전통제", 3, "통제 순서: A 식별 확인, B 상급 승인 요청, C 승인 수령, D 조치 기록"),
_order("Q32", "피아식별", 3, "확인 순서: A 운항정보 대조, B 식별 신호 확인, C 미확인 분류, D 보고"),
]
QIDX = {q["qid"]: q for q in BANK}
UNITS = ["가상1중대", "가상2중대", "가상3중대"]
_CIRC = "①②③④"
PARA = {"Q21": "아군적군 구분", "Q22": "세모 날개", "Q23": "추정됨으로 표기", "Q24": "중대장(지휘관)", "Q25": "잘못된 경보",
"Q26": "민간 여객기", "Q27": "4발 드론", "Q28": "지휘 라인"} # 뜻은 맞지만 인정 답안 목록에 없는 표현
def make_soldiers(n_per_unit, seed):
rng = np.random.default_rng(seed)
out = []
for u, unit in enumerate(UNITS):
base = [0.3, -0.1, 0.0][u]
for i in range(n_per_unit):
off = rng.normal(0, 0.5, len(AREAS)) + (np.array([0, 0, 0, -0.6, 0]) if u == 1 else 0)
out.append({"sid": f"S{len(out) + 1:03d}", "unit": unit, "theta": dict(zip(AREAS, base + rng.normal(0, 0.6) + off)),
"tend": dict(zip(ETYPES, rng.dirichlet([1.2, 1.2, 1.2])))})
return out
def p_correct(theta, diff):
return 1 / (1 + np.exp(-1.7 * (theta - (diff - 2) * 0.9 + 0.9)))
def _typo(s, rng):
if len(s) < 2:
return s
i = int(rng.integers(len(s) - 1))
return s[:i] + s[i + 1] + s[i] + s[i + 2:] if len(s) <= 3 or rng.random() < 0.5 else s[:i] + s[i + 1:]
def _fmt_mc(k, rng):
r = rng.random()
if r < 0.55:
return str(k)
return rng.choice([_CIRC[k - 1], f"{k}번", "ABCD"[k - 1], "abcd"[k - 1] + ")", f" {k} ", chr(0xFF10 + k), f"{k}."])
def _fmt_sa(s, rng):
r = rng.random()
if r < 0.45:
return s
if r < 0.6:
return " ".join(s) if len(s) <= 4 else s[:2] + " " + s[2:]
if r < 0.72:
return s + rng.choice(["입니다", "이다", ".", "임"])
if r < 0.8:
return s.upper() if s.isascii() else "".join(chr(ord(ch) + 0xFEE0) if "!" <= ch <= "~" else ch for ch in s) + " "
return _typo(s, rng)
def answer(q, sol, rng):
"""한 문항 응답: (원본 응답 문자열, 의도한 정답 여부, 의도한 답, 오답 유형)."""
ok = rng.random() < p_correct(sol["theta"][q["area"]], q["diff"])
if q["type"] == "MC":
if ok:
k, et = q["answer"], None
else:
ks = list(q["dist"]); w = np.array([sol["tend"][q["dist"][k]] for k in ks])
k = int(rng.choice(ks, p=w / w.sum())); et = q["dist"][k]
return _fmt_mc(k, rng), ok, str(k), et
if q["type"] == "SA":
if ok and rng.random() < 0.06:
return PARA[q["qid"]], ok, PARA[q["qid"]], None
if ok:
s, et = str(rng.choice(q["accept"])), None
else:
ws = list(q["wrong"]); w = np.array([sol["tend"][q["wrong"][x]] for x in ws])
s = str(rng.choice(ws, p=w / w.sum())); et = q["wrong"][s]
return _fmt_sa(s, rng), ok, s, et
if ok:
s = "ABCD"
elif rng.random() < 0.5:
s = "ABCD".replace("ABCD"[rng.integers(1, 4)], "") # 단계 하나 빠뜨림
else:
i = int(rng.integers(0, 3)); s = list("ABCD"); s[i], s[i + 1] = s[i + 1], s[i]; s = "".join(s) # 순서 뒤바뀜
raw = s if rng.random() < 0.6 else rng.choice(["-".join(s), " ".join(s), s.lower(), ",".join(s)])
return raw, ok, s, (None if ok else P)
def make_responses(n_per_unit=40, seed=7, blank=0.02):
"""응시자 x 문항 응답표(DataFrame)를 만든다. truth_* 열은 교관 기준 정답(평가용)이다."""
rng = np.random.default_rng(seed)
rows = []
for sol in make_soldiers(n_per_unit, seed):
for q in BANK:
raw, ok, intended, et = answer(q, sol, rng)
if rng.random() < blank:
raw, ok, intended, et = "", False, "", None
rows.append({"rid": f"R{len(rows):05d}", "sid": sol["sid"], "unit": sol["unit"], "qid": q["qid"], "raw": raw,
"truth_correct": ok, "truth_answer": intended, "truth_etype": et})
return pd.DataFrame(rows)
# ---------------------------------------------------------------------------
# ---- 응답 정제·파싱·채점 공통 함수 ----
TAILS = ("입니다", "이다", "임")
def norm_text(s):
"""전각->반각(NFKC), 소문자, 공백·문장부호 제거, 끝맺음 어미 제거."""
s = unicodedata.normalize("NFKC", str(s)).strip().lower()
s = re.sub(r"[\s\.\,\-\(\)·]", "", s)
for t in TAILS:
if s.endswith(t) and len(s) > len(t) + 1:
s = s[:-len(t)]
return s
def parse(q, raw):
"""원본 응답 -> (정제값, 상태). 상태: 정상 / 미응답 / 형식오류"""
if raw is None or str(raw).strip() == "":
return "", "미응답"
s = unicodedata.normalize("NFKC", str(raw)).strip()
if q["type"] == "MC":
t = re.sub(r"[\s번\.\)]", "", s).lower()
t = {"a": "1", "b": "2", "c": "3", "d": "4"}.get(t, t)
return (t, "정상") if t in ("1", "2", "3", "4") else (t, "형식오류")
if q["type"] == "ORDER":
t = re.sub(r"[^A-Za-z]", "", s).upper()
return (t, "정상") if t and set(t) <= set("ABCD") and len(set(t)) == len(t) else (t, "형식오류")
return norm_text(s), "정상"
def grade_exact(q, val):
if q["type"] == "MC":
return val == str(q["answer"])
if q["type"] == "ORDER":
return val == q["answer"]
return val in {norm_text(a) for a in q["accept"]}
def grade_fuzzy(q, val, cut=0.75):
"""단답형은 오탈자(글자 순서 바뀜·한 글자 누락)를 허용(유사도 cut 이상)하되, 알려진 오답과 더 가까우면 오답으로 본다."""
if grade_exact(q, val) or q["type"] != "SA" or len(val) < 2:
return grade_exact(q, val)
best = max(1.0 if sorted(val) == sorted(norm_text(a)) else difflib.SequenceMatcher(None, val, norm_text(a)).ratio() for a in q["accept"])
worst = max(difflib.SequenceMatcher(None, val, norm_text(w)).ratio() for w in q["wrong"])
return best >= cut and best > worst
def score_of(q, correct):
return POINTS[q["diff"]] if correct else 0
def grade_one(qid, raw):
q = QIDX[qid]
val, st = parse(q, raw)
return st, (st == "정상" and grade_fuzzy(q, val))
@functools.lru_cache(maxsize=50000)
def grade_cached(qid, raw):
"""같은 (문항, 원본 응답)은 다시 계산하지 않는다(대규모 응시에서 같은 답이 반복됨)."""
return grade_one(qid, raw)
def corrupt(df, rng, rate):
"""손상 답안 주입: 초장문, 제어문자, 존재하지 않는 문항, None."""
df = df.copy()
idx = rng.choice(len(df), int(len(df) * rate), replace=False)
kinds = rng.integers(0, 4, len(idx))
for i, k in zip(idx, kinds):
df.at[df.index[i], "raw"] = ["가" * 20000, "\x00\x07삼각익", None, "1"][k]
if k == 3:
df.at[df.index[i], "qid"] = "Q99"
return df
def run(df, fn, fail_rate=0.0, seed=0, cap=2000):
"""제출 큐에서 cap건씩 꺼내 채점. 채점 중 예외·장애가 나면 '채점 보류' 큐로 옮기고 계속 처리한다(안전 실패)."""
rng = np.random.default_rng(seed)
inbox, result, hold = deque(df[["rid", "qid", "raw"]].itertuples(index=False)), {}, {}
t0, crashes, max_q = time.perf_counter(), 0, len(inbox)
while inbox:
batch = [inbox.popleft() for _ in range(min(cap, len(inbox)))]
for rid, qid, raw in batch:
try:
if qid not in QIDX:
raise KeyError("문항 없음")
if raw is not None and len(str(raw)) > 200:
raise ValueError("응답 길이 초과")
if rng.random() < fail_rate:
raise TimeoutError("채점기 응답 없음")
result[rid] = fn(qid, "" if raw is None else str(raw).replace("\x00", "").replace("\x07", ""))
except Exception as e: # 시스템은 멈추지 않고 보류 큐에 원본을 보존
hold[rid] = type(e).__name__
sec = time.perf_counter() - t0
lost = len(df) - len(result) - len(hold)
return {"건수": len(df), "처리(초)": round(sec, 2), "처리량(건/초)": int(len(df) / sec), "채점 완료": len(result),
"보류": len(hold), "유실": lost, "다운": crashes, "최대 대기열": max_q}, hold
base = make_responses(40, seed=7) # 평시: 120명 x 32문항
rng = np.random.default_rng(1)
rows, holds = [], {}
for mult, fail in [(1, 0.0), (3, 0.0), (6, 0.0), (6, 0.02), (11, 0.02)]:
df = pd.concat([base.assign(rid=base["rid"] + f"-{k}") for k in range(mult)], ignore_index=True)
df = corrupt(df, rng, 0.003)
grade_cached.cache_clear()
tracemalloc.start()
r, hold = run(df, grade_cached, fail_rate=fail, seed=mult)
peak = tracemalloc.get_traced_memory()[1] / 1e6; tracemalloc.stop()
rows.append({"부하": f"평시 x{mult}" + (" +장애2%" if fail else ""), **r, "메모리 최대(MB)": round(peak, 1)})
holds[rows[-1]["부하"]] = hold
T = pd.DataFrame(rows)
print(T.drop(columns=["다운", "최대 대기열"]).to_string(index=False))
print("(위 표의 처리량은 메모리 추적을 켠 상태라 실제보다 낮게 나온다)")
w = T[T["부하"] == "평시 x6"].iloc[0]
print(f"\n동시 응시 500% 증가(평시 x6): 시스템 다운 {int(T['다운'].sum())}건, 데이터 유실 {int(T['유실'].sum())}건 "
f"(목표 다운 0건·유실률 0%: {'달성' if T['다운'].sum() == 0 and T['유실'].sum() == 0 else '미달'})")
h = pd.Series(holds["평시 x11 +장애2%"]).value_counts()
print("보류 사유(평시 x11 +장애2%):", {k: int(v) for k, v in h.items()}, "-> 원본 보존, 장애 복구 후 재채점 또는 교관 확인")
# 병목 프로파일링과 최적화: 캐시 없음 vs 캐시
df6 = pd.concat([base.assign(rid=base["rid"] + f"-{k}") for k in range(6)], ignore_index=True)
prof = {}
t0 = time.perf_counter(); [parse(QIDX[q], r) for q, r in zip(df6["qid"], df6["raw"])]; prof["파싱"] = time.perf_counter() - t0
sa = df6[df6["qid"].map(lambda q: QIDX[q]["type"]) == "SA"]
t0 = time.perf_counter(); [grade_fuzzy(QIDX[q], parse(QIDX[q], r)[0]) for q, r in zip(sa["qid"], sa["raw"])]; prof["단답 유사도 채점"] = time.perf_counter() - t0
print("\n[병목 프로파일] " + ", ".join(f"{k} {v * 1000:.0f} ms" for k, v in prof.items()) + f" (단답형은 전체의 {len(sa) / len(df6):.0%})")
r0, _ = run(df6, grade_one)
grade_cached.cache_clear(); r1, _ = run(df6, grade_cached)
print(f"최적화 전 {r0['처리량(건/초)']:,}건/초 -> 캐시 적용 후 {r1['처리량(건/초)']:,}건/초 ({r1['처리량(건/초)'] / r0['처리량(건/초)']:.1f}배)")
print(f"기능 축소 운용: 장애율이 5%를 넘으면 단답형 유사도 채점을 끄고 정확 일치만 채점, 나머지는 교관 검토로 넘긴다.")
plt.figure(figsize=(8, 4))
plt.bar(T["부하"].str.replace("평시", "base").str.replace("+장애2%", "+fail2%"), T["처리량(건/초)"])
plt.ylabel("responses / sec"); plt.title("Grading throughput under load (cached)"); plt.xticks(rotation=15)
plt.tight_layout(); plt.show()
실행 결과 예시
부하 건수 처리(초) 처리량(건/초) 채점 완료 보류 유실 메모리 최대(MB)
평시 x1 3840 0.11 33625 3834 6 0 0.5
평시 x3 11520 0.19 59703 11501 19 0 1.1
평시 x6 23040 0.27 86855 23004 36 0 2.0
평시 x6 +장애2% 23040 0.24 96047 22558 482 0 2.0
평시 x11 +장애2% 42240 0.46 91783 41326 914 0 3.6
(위 표의 처리량은 메모리 추적을 켠 상태라 실제보다 낮게 나온다)
동시 응시 500% 증가(평시 x6): 시스템 다운 0건, 데이터 유실 0건 (목표 다운 0건·유실률 0%: 달성)
보류 사유(평시 x11 +장애2%): {'TimeoutError': 857, 'ValueError': 30, 'KeyError': 27} -> 원본 보존, 장애 복구 후 재채점 또는 교관 확인
[병목 프로파일] 파싱 52 ms, 단답 유사도 채점 150 ms (단답형은 전체의 25%)
최적화 전 89,083건/초 -> 캐시 적용 후 498,682건/초 (5.6배)
기능 축소 운용: 장애율이 5%를 넘으면 단답형 유사도 채점을 끄고 정확 일치만 채점, 나머지는 교관 검토로 넘긴다.
미션 9. AAR 기반 문항 변별력 분석 및 난이도별 가중치 파라미터 재보정
- 난이도: 고급 / 예상 소요시간: 3.0시간
- 코드 설명: 시행 후 검토(AAR)로 문항별 정답률·변별도(점이연 상관)·오답 보기 선택 분포를 분석해 정답 키 오류와 변별력 없는 문항을 찾아내고, 난이도·변별도 가중치를 재보정한다. 실제 숙달도 대비 합격 판정 정확도의 개선과 새 응시 집단에서의 재발 오류를 확인하고 재보정 파라미터를 YAML로 출력한다.
"""시행 후 검토(AAR)로 문항별 정답률·변별도(점이연 상관)·오답 보기 선택 분포를 분석해 정답 키 오류와 변별력 없는 문항을 찾아내고, 난이도·변별도 가중치를 재보정한다. 실제 숙달도 대비 합격 판정 정확도의 개선과 새 응시 집단에서의 재발 오류를 확인하고 재보정 파라미터를 YAML로 출력한다."""
import difflib
import re
import unicodedata
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
# ---------------------------------------------------------------------------
# [교육용가상자료] 방공·드론 식별 퀴즈 문항 은행과 가상 응시자 응답 생성기
# 문항은 일반 원칙 수준의 가상 문항이며 실제 교범·교전규칙 원문이 아니다. 응시자는 가명(S001~)이다.
# ---------------------------------------------------------------------------
AREAS = ["형상식별", "피아식별", "경보보고", "교전통제", "대드론일반"]
ETYPES = ["개념 혼동", "피아 오인", "절차 누락"]
POINTS = {1: 2, 2: 3, 3: 5}
C, F, P = ETYPES
def _mc(qid, area, diff, stem, opts, ans, dist):
return {"qid": qid, "type": "MC", "area": area, "diff": diff, "stem": stem, "options": opts, "answer": ans,
"dist": dict(zip([i for i in range(1, 5) if i != ans], dist))}
def _sa(qid, area, diff, stem, accept, wrong):
return {"qid": qid, "type": "SA", "area": area, "diff": diff, "stem": stem, "accept": accept, "wrong": wrong}
def _order(qid, area, diff, stem):
return {"qid": qid, "type": "ORDER", "area": area, "diff": diff, "stem": stem, "answer": "ABCD"}
BANK = [
_mc("Q01", "형상식별", 1, "꼬리날개 없이 삼각형 날개를 가진 소형 고정익 실루엣은?", ["쿼드콥터형", "삼각익 고정익형", "회전익 헬기형", "조류"], 2, [C, C, F]),
_mc("Q02", "형상식별", 1, "회전날개 4개가 십자로 배치된 실루엣은?", ["고정익 드론형", "쿼드콥터형", "순항미사일형", "탄도미사일형"], 2, [C, C, C]),
_mc("Q03", "형상식별", 2, "가늘고 긴 동체 중간에 작은 날개가 붙은 실루엣은?", ["순항미사일형", "탄도미사일형", "민간 경비행기형", "조류"], 1, [C, F, F]),
_mc("Q04", "형상식별", 3, "원거리라 실루엣 식별이 어려울 때 먼저 할 행동은?", ["추정 유형으로 확정 보고", "관측 정보를 그대로 보고하고 추가 관측", "관측 중단", "민간기로 간주"], 2, [P, P, F]),
_mc("Q05", "피아식별", 2, "식별 신호 응답이 없는 항공기에 대한 올바른 판단은?", ["즉시 적기로 확정", "미확인 항공기로 분류하고 추가 확인", "아군기로 간주", "무시"], 2, [F, F, P]),
_mc("Q06", "피아식별", 2, "운항 정보와 일치하는 저속 비행체의 분류는?", ["적 드론", "민간기 가능성 높음, 추가 확인", "순항미사일", "아군 정찰기로 확정"], 2, [F, C, F]),
_mc("Q07", "피아식별", 3, "아군 드론 운용 공지가 있는 공역에서 미확인 드론을 보면 우선 확인할 것은?", ["기상 정보", "아군 운용 계획과 식별 신호", "대응 준비 상태", "주변 지형"], 2, [C, P, C]),
_mc("Q08", "피아식별", 3, "조류 무리와 소형 드론 군집 구분에 가장 도움이 되는 정보는?", ["크기만 비교", "궤적의 규칙성과 열 신호", "색상", "관측 시각"], 2, [C, C, C]),
_mc("Q09", "경보보고", 1, "공중 위협 최초 보고에 포함하지 않아도 되는 것은?", ["관측 시각", "방향과 거리", "추정 유형", "관측자 개인 연락처"], 4, [P, P, P]),
_mc("Q10", "경보보고", 2, "경보 전파 후 상황이 바뀌면?", ["최초 보고 유지", "변경 사항을 즉시 추가 보고", "상황 종료 후 일괄 보고", "개인 판단으로 경보 해제"], 2, [P, P, P]),
_mc("Q11", "경보보고", 2, "보고 내용이 불확실할 때 올바른 표현은?", ["확정 표현으로 보고", "'추정'임을 밝히고 근거와 함께 보고", "보고 보류", "다른 관측자 보고를 복사"], 2, [C, P, P]),
_mc("Q12", "경보보고", 1, "드론 탐지 후 보고 경로로 알맞은 것은?", ["인접 부대에만 전파", "지휘계통을 따라 보고", "개인 메신저로 공유", "보고 없이 대응"], 2, [P, P, P]),
_mc("Q13", "교전통제", 1, "AI 탐지 결과만으로 대응을 결정할 수 있는가?", ["가능하다", "인간 지휘관의 승인이 필요하다", "신뢰도 90% 이상이면 가능", "야간에만 가능"], 2, [C, C, C]),
_mc("Q14", "교전통제", 2, "민간 피해 가능성이 확인되면?", ["그대로 진행", "즉시 보고하고 지휘관 판단을 받는다", "조치 후 보고", "무시"], 2, [P, P, F]),
_mc("Q15", "교전통제", 3, "식별이 불확실한 표적에 대한 원칙은?", ["위협으로 간주해 즉시 대응", "식별 확인 전까지 승인 절차를 따른다", "무조건 무시", "다수결로 결정"], 2, [F, F, C]),
_mc("Q16", "교전통제", 2, "판단 근거를 기록해야 하는 이유는?", ["형식상 필요", "사후 검토와 책임 추적", "보고 생략", "기록 불필요"], 2, [C, P, P]),
_mc("Q17", "대드론일반", 1, "대드론 체계의 일반적 처리 순서는?", ["대응-탐지-식별", "탐지-식별-보고·판단-대응", "식별-대응-탐지", "보고-탐지-대응"], 2, [P, P, P]),
_mc("Q18", "대드론일반", 3, "레이더만으로 조류와 드론 구분이 어려운 이유는?", ["반사 신호 크기가 비슷할 수 있어서", "레이더는 야간에 못 써서", "드론에는 금속이 없어서", "조류는 탐지되지 않아서"], 1, [C, C, C]),
_mc("Q19", "대드론일반", 2, "전자광학(EO) 센서의 약점이 커지는 조건은?", ["맑은 주간", "안개·야간", "저속 표적", "근거리"], 2, [C, C, C]),
_mc("Q20", "대드론일반", 2, "경보가 반복해서 오경보로 판명되면?", ["경보 장치를 끈다", "사례를 기록·보고하고 기준 재검토 요청", "무시한다", "모두 실제 위협으로 간주"], 2, [P, P, F]),
_sa("Q21", "피아식별", 1, "아군·적·민간을 구분하는 절차를 무엇이라 하는가?", ["피아식별", "IFF"], {"표적획득": C, "적기판정": F, "경계": C}),
_sa("Q22", "형상식별", 2, "꼬리날개 없는 삼각형 날개 형태를 무엇이라 하는가?", ["삼각익", "델타익", "delta"], {"직선익": C, "후퇴익": C, "쿼드콥터": C}),
_sa("Q23", "경보보고", 2, "불확실한 관측을 보고할 때 붙이는 표현은?", ["추정"], {"확정": C, "확인": C, "생략": P}),
_sa("Q24", "교전통제", 1, "AI 탐지 결과에 대해 최종 결정을 내리는 주체는?", ["지휘관"], {"AI": C, "운용병": P, "레이더": C}),
_sa("Q25", "대드론일반", 1, "조류를 드론으로 잘못 경보하는 것을 무엇이라 하는가?", ["오경보", "오탐", "false alarm"], {"미탐": C, "재경보": C, "적기": F}),
_sa("Q26", "피아식별", 2, "운항 정보가 확인된 여객기는 어떻게 분류하는가?", ["민간기", "민항기"], {"적기": F, "미확인기": P, "아군기": F}),
_sa("Q27", "형상식별", 1, "회전날개 4개를 가진 드론 형태는?", ["쿼드콥터", "quadcopter"], {"헥사콥터": C, "고정익": C, "헬기": F}),
_sa("Q28", "경보보고", 1, "보고는 무엇을 따라 올라가는가?", ["지휘계통"], {"인접부대": P, "개인메신저": P, "통신망": C}),
_order("Q29", "경보보고", 2, "최초 보고 순서: A 관측 시각, B 방향·거리, C 추정 유형, D 관측자 조치"),
_order("Q30", "대드론일반", 2, "처리 순서: A 탐지, B 식별, C 보고·판단, D 대응"),
_order("Q31", "교전통제", 3, "통제 순서: A 식별 확인, B 상급 승인 요청, C 승인 수령, D 조치 기록"),
_order("Q32", "피아식별", 3, "확인 순서: A 운항정보 대조, B 식별 신호 확인, C 미확인 분류, D 보고"),
]
QIDX = {q["qid"]: q for q in BANK}
UNITS = ["가상1중대", "가상2중대", "가상3중대"]
_CIRC = "①②③④"
PARA = {"Q21": "아군적군 구분", "Q22": "세모 날개", "Q23": "추정됨으로 표기", "Q24": "중대장(지휘관)", "Q25": "잘못된 경보",
"Q26": "민간 여객기", "Q27": "4발 드론", "Q28": "지휘 라인"} # 뜻은 맞지만 인정 답안 목록에 없는 표현
def make_soldiers(n_per_unit, seed):
rng = np.random.default_rng(seed)
out = []
for u, unit in enumerate(UNITS):
base = [0.3, -0.1, 0.0][u]
for i in range(n_per_unit):
off = rng.normal(0, 0.5, len(AREAS)) + (np.array([0, 0, 0, -0.6, 0]) if u == 1 else 0)
out.append({"sid": f"S{len(out) + 1:03d}", "unit": unit, "theta": dict(zip(AREAS, base + rng.normal(0, 0.6) + off)),
"tend": dict(zip(ETYPES, rng.dirichlet([1.2, 1.2, 1.2])))})
return out
def p_correct(theta, diff):
return 1 / (1 + np.exp(-1.7 * (theta - (diff - 2) * 0.9 + 0.9)))
def _typo(s, rng):
if len(s) < 2:
return s
i = int(rng.integers(len(s) - 1))
return s[:i] + s[i + 1] + s[i] + s[i + 2:] if len(s) <= 3 or rng.random() < 0.5 else s[:i] + s[i + 1:]
def _fmt_mc(k, rng):
r = rng.random()
if r < 0.55:
return str(k)
return rng.choice([_CIRC[k - 1], f"{k}번", "ABCD"[k - 1], "abcd"[k - 1] + ")", f" {k} ", chr(0xFF10 + k), f"{k}."])
def _fmt_sa(s, rng):
r = rng.random()
if r < 0.45:
return s
if r < 0.6:
return " ".join(s) if len(s) <= 4 else s[:2] + " " + s[2:]
if r < 0.72:
return s + rng.choice(["입니다", "이다", ".", "임"])
if r < 0.8:
return s.upper() if s.isascii() else "".join(chr(ord(ch) + 0xFEE0) if "!" <= ch <= "~" else ch for ch in s) + " "
return _typo(s, rng)
def answer(q, sol, rng):
"""한 문항 응답: (원본 응답 문자열, 의도한 정답 여부, 의도한 답, 오답 유형)."""
ok = rng.random() < p_correct(sol["theta"][q["area"]], q["diff"])
if q["type"] == "MC":
if ok:
k, et = q["answer"], None
else:
ks = list(q["dist"]); w = np.array([sol["tend"][q["dist"][k]] for k in ks])
k = int(rng.choice(ks, p=w / w.sum())); et = q["dist"][k]
return _fmt_mc(k, rng), ok, str(k), et
if q["type"] == "SA":
if ok and rng.random() < 0.06:
return PARA[q["qid"]], ok, PARA[q["qid"]], None
if ok:
s, et = str(rng.choice(q["accept"])), None
else:
ws = list(q["wrong"]); w = np.array([sol["tend"][q["wrong"][x]] for x in ws])
s = str(rng.choice(ws, p=w / w.sum())); et = q["wrong"][s]
return _fmt_sa(s, rng), ok, s, et
if ok:
s = "ABCD"
elif rng.random() < 0.5:
s = "ABCD".replace("ABCD"[rng.integers(1, 4)], "") # 단계 하나 빠뜨림
else:
i = int(rng.integers(0, 3)); s = list("ABCD"); s[i], s[i + 1] = s[i + 1], s[i]; s = "".join(s) # 순서 뒤바뀜
raw = s if rng.random() < 0.6 else rng.choice(["-".join(s), " ".join(s), s.lower(), ",".join(s)])
return raw, ok, s, (None if ok else P)
def make_responses(n_per_unit=40, seed=7, blank=0.02):
"""응시자 x 문항 응답표(DataFrame)를 만든다. truth_* 열은 교관 기준 정답(평가용)이다."""
rng = np.random.default_rng(seed)
rows = []
for sol in make_soldiers(n_per_unit, seed):
for q in BANK:
raw, ok, intended, et = answer(q, sol, rng)
if rng.random() < blank:
raw, ok, intended, et = "", False, "", None
rows.append({"rid": f"R{len(rows):05d}", "sid": sol["sid"], "unit": sol["unit"], "qid": q["qid"], "raw": raw,
"truth_correct": ok, "truth_answer": intended, "truth_etype": et})
return pd.DataFrame(rows)
# ---------------------------------------------------------------------------
# ---- 응답 정제·파싱·채점 공통 함수 ----
TAILS = ("입니다", "이다", "임")
def norm_text(s):
"""전각->반각(NFKC), 소문자, 공백·문장부호 제거, 끝맺음 어미 제거."""
s = unicodedata.normalize("NFKC", str(s)).strip().lower()
s = re.sub(r"[\s\.\,\-\(\)·]", "", s)
for t in TAILS:
if s.endswith(t) and len(s) > len(t) + 1:
s = s[:-len(t)]
return s
def parse(q, raw):
"""원본 응답 -> (정제값, 상태). 상태: 정상 / 미응답 / 형식오류"""
if raw is None or str(raw).strip() == "":
return "", "미응답"
s = unicodedata.normalize("NFKC", str(raw)).strip()
if q["type"] == "MC":
t = re.sub(r"[\s번\.\)]", "", s).lower()
t = {"a": "1", "b": "2", "c": "3", "d": "4"}.get(t, t)
return (t, "정상") if t in ("1", "2", "3", "4") else (t, "형식오류")
if q["type"] == "ORDER":
t = re.sub(r"[^A-Za-z]", "", s).upper()
return (t, "정상") if t and set(t) <= set("ABCD") and len(set(t)) == len(t) else (t, "형식오류")
return norm_text(s), "정상"
def grade_exact(q, val):
if q["type"] == "MC":
return val == str(q["answer"])
if q["type"] == "ORDER":
return val == q["answer"]
return val in {norm_text(a) for a in q["accept"]}
def grade_fuzzy(q, val, cut=0.75):
"""단답형은 오탈자(글자 순서 바뀜·한 글자 누락)를 허용(유사도 cut 이상)하되, 알려진 오답과 더 가까우면 오답으로 본다."""
if grade_exact(q, val) or q["type"] != "SA" or len(val) < 2:
return grade_exact(q, val)
best = max(1.0 if sorted(val) == sorted(norm_text(a)) else difflib.SequenceMatcher(None, val, norm_text(a)).ratio() for a in q["accept"])
worst = max(difflib.SequenceMatcher(None, val, norm_text(w)).ratio() for w in q["wrong"])
return best >= cut and best > worst
def score_of(q, correct):
return POINTS[q["diff"]] if correct else 0
KEY_REG = {"Q11": 3} # [교육용가상자료] 문항 등록 시 정답 키를 잘못 입력한 문항(정답은 2)
AMBIG = "Q08" # 보기 2·3이 모두 그럴듯해 실력과 무관하게 갈리는 문항
def field_data(seed):
"""현장 시행 데이터: Q08은 실력과 무관하게 2·3이 반반, 나머지는 생성기 그대로."""
df = make_responses(40, seed=seed)
rng = np.random.default_rng(seed + 100)
m = df["qid"] == AMBIG
df.loc[m, "raw"] = rng.choice(["2", "3"], m.sum())
df["val"] = [parse(QIDX[q], r)[0] for q, r in zip(df["qid"], df["raw"])]
return df
def grade(df, key_fix=False, drop=(), weights=None):
def ok(q, v):
if q["qid"] in KEY_REG and not key_fix:
return v == str(KEY_REG[q["qid"]])
return grade_fuzzy(q, v)
d = df[~df["qid"].isin(drop)].copy()
d["correct"] = [ok(QIDX[q], v) for q, v in zip(d["qid"], d["val"])]
w = weights or {}
d["pts"] = [w.get(q, POINTS[QIDX[q]["diff"]]) for q in d["qid"]]
d["score"] = d["pts"] * d["correct"]
tot = d.groupby("sid")[["score", "pts"]].sum()
return d, tot["score"] / tot["pts"]
def item_analysis(d, key_fix=False):
"""정답률(p), 점이연 상관(해당 문항 제외 총점과의 상관), 오답 보기 선택자의 평균 총점이 정답 선택자보다 높은지(키 오류 의심)."""
tot = d.groupby("sid")["correct"].mean()
rows = []
for qid, g in d.groupby("qid"):
rest = tot[g["sid"]].values - g["correct"].values / len(BANK)
r = np.corrcoef(g["correct"].astype(float), rest)[0, 1] if g["correct"].std() > 0 else 0.0
key_sus = False
if QIDX[qid]["type"] == "MC":
by = pd.Series(rest, index=g["val"].values).groupby(level=0).mean()
key = KEY_REG[qid] if qid in KEY_REG and not key_fix else QIDX[qid]["answer"]
right = by.get(str(key), np.nan)
key_sus = bool(by.drop(index=[k for k in by.index if k not in "1234" or k == ""], errors="ignore").max() > right + 0.08)
rows.append({"qid": qid, "p": round(g["correct"].mean(), 2), "r_pb": round(r, 2), "key_suspect": key_sus})
t = pd.DataFrame(rows).set_index("qid")
t["flag"] = np.where(t["key_suspect"], "정답 키 오류 의심", np.where(t["r_pb"] < 0.1, "변별력 부족", ""))
return t
def pass_accuracy(df, ratio, cut=0.7):
"""합격 판정(점수 비율 cut 이상)과 가상 실제 숙달도(결함 없는 시험에서의 기대 득점률 0.7 이상)의 일치율."""
sol = {s["sid"]: s for s in make_soldiers(40, SEED_OF[id(df)])}
pts = np.array([POINTS[q["diff"]] for q in BANK])
exp = {s: float(np.dot(pts, [p_correct(sol[s]["theta"][q["area"]], q["diff"]) for q in BANK]) / pts.sum()) for s in ratio.index}
truth = pd.Series(exp) >= 0.7
return float(((ratio >= cut) == truth[ratio.index]).mean())
SEED_OF = {}
A = field_data(7); SEED_OF[id(A)] = 7
d0, r0 = grade(A)
acc0 = pass_accuracy(A, r0)
ia = item_analysis(d0)
print("[AAR 1. 문항 분석 - 경고 문항]")
print(ia[ia["flag"] != ""].to_string())
print(f"전체 문항 변별도 중앙값 {ia['r_pb'].median():.2f}, 정답률 0.9 초과(너무 쉬움) {int((ia['p'] > 0.9).sum())}개, 0.3 미만(너무 어려움) {int((ia['p'] < 0.3).sum())}개")
print("원인 판정: Q11은 상위권이 고른 보기(2)가 등록 키(3)와 달라 키 입력 오류, Q08은 보기 2·3이 실력과 무관하게 갈려 문항 자체 결함.")
# AAR 2. 재보정: 키 수정 -> 결함 문항 제외 -> 배점 가중치 탐색
flagged = list(ia[ia["flag"] == "변별력 부족"].index)
rows = [{"단계": "재보정 전", "합격 판정 정확도": acc0}]
d1, r1 = grade(A, key_fix=True)
rows.append({"단계": "1) 정답 키 수정", "합격 판정 정확도": pass_accuracy(A, r1)})
d2, r2 = grade(A, key_fix=True, drop=flagged)
rows.append({"단계": "2) 변별력 부족 문항 제외", "합격 판정 정확도": pass_accuracy(A, r2)})
ia2 = item_analysis(d2, key_fix=True)
best = None
for base in [(2, 3, 5), (1, 2, 3), (3, 3, 3)]:
for alpha in [0.0, 0.5, 1.0, 2.0]:
w = {q: base[QIDX[q]["diff"] - 1] * (1 + alpha * max(ia2.loc[q, "r_pb"], 0)) for q in ia2.index}
_, r = grade(A, key_fix=True, drop=flagged, weights=w)
a = pass_accuracy(A, r) # 합격선 70%는 정책값이라 튜닝하지 않는다
if best is None or a > best[0] + 1e-9:
best = (a, base, alpha, 0.7, w)
acc3, base, alpha, cut, W = best
rows.append({"단계": f"3) 배점{base}·변별도 가중 {alpha}", "합격 판정 정확도": acc3})
S = pd.DataFrame(rows); S["합격 판정 정확도"] = S["합격 판정 정확도"].round(3)
print("\n[AAR 2. 재보정 단계별 효과(응시 집단 A)]")
print(S.to_string(index=False))
gain = (acc3 - acc0) * 100
print(f"종합 정확도 {acc0:.1%} -> {acc3:.1%} (+{gain:.1f}%p, 목표 10%p 이상: {'달성' if gain >= 10 else '미달'})")
print("-> 결함 문항이 32개 중 2개라 개선 폭이 제한된다. 나머지 불일치는 합격선 근처 응시자의 측정 오차로, 문항 수를 늘려야 줄어든다.")
# AAR 3. 새 응시 집단 B(다른 인원)에서 검증·재발 확인
B = field_data(21); SEED_OF[id(B)] = 21
_, rb0 = grade(B)
dB, rb = grade(B, key_fix=True, drop=flagged, weights=W)
accB0, accB = pass_accuracy(B, rb0), pass_accuracy(B, rb, cut)
iaB = item_analysis(dB, key_fix=True)
rec = iaB[iaB["key_suspect"] | iaB.index.isin(flagged + list(KEY_REG))]
rec = rec[rec["flag"] != ""]
new = iaB[(iaB["flag"] != "") & ~iaB.index.isin(rec.index)]
print(f"\n[AAR 3. 응시 집단 B 검증] 합격 판정 정확도 {accB0:.1%} -> {accB:.1%} (+{(accB - accB0) * 100:.1f}%p)")
print(f"재발 오류(조치한 키 오류·결함 문항의 재검출) {len(rec)}건 (목표 0건: {'달성' if len(rec) == 0 else '미달'})")
print(f"신규 점검 대상 {len(new)}건: {list(new.index)} -> 다음 AAR에서 문항 개정 여부 판단")
yaml = "\n".join(["# configs/trn02_tuned_params.yaml [교육용가상자료]", "answer_key_fix:", " Q11: 2", "excluded_items:"]
+ [f" - {q} # 변별력 부족, 문항 개정 후 재투입" for q in flagged]
+ [f"points_by_difficulty: {{1: {base[0]}, 2: {base[1]}, 3: {base[2]}}}", f"discrimination_alpha: {alpha}",
f"pass_ratio: {cut}", "review: 다음 시행 후 AAR에서 변별도 0.1 미만 문항 재점검"])
print("\n" + yaml)
plt.figure(figsize=(7, 4))
c = ["tab:red" if f else "tab:blue" for f in ia["flag"]]
plt.scatter(ia["p"], ia["r_pb"], c=c)
for q in ia[ia["flag"] != ""].index:
plt.annotate(q, (ia.loc[q, "p"], ia.loc[q, "r_pb"]))
plt.axhline(0.1, ls="--", color="gray"); plt.xlabel("item difficulty p (accuracy)"); plt.ylabel("discrimination r_pb")
plt.title("AAR item analysis (red = flagged)"); plt.tight_layout(); plt.show()
실행 결과 예시
[AAR 1. 문항 분석 - 경고 문항]
p r_pb key_suspect flag
qid
Q08 0.56 -0.10 False 변별력 부족
Q11 0.08 -0.26 True 정답 키 오류 의심
Q27 0.89 0.08 False 변별력 부족
Q28 0.87 0.07 False 변별력 부족
전체 문항 변별도 중앙값 0.30, 정답률 0.9 초과(너무 쉬움) 2개, 0.3 미만(너무 어려움) 1개
원인 판정: Q11은 상위권이 고른 보기(2)가 등록 키(3)와 달라 키 입력 오류, Q08은 보기 2·3이 실력과 무관하게 갈려 문항 자체 결함.
[AAR 2. 재보정 단계별 효과(응시 집단 A)]
단계 합격 판정 정확도
재보정 전 0.833
1) 정답 키 수정 0.858
2) 변별력 부족 문항 제외 0.875
3) 배점(2, 3, 5)·변별도 가중 0.0 0.875
종합 정확도 83.3% -> 87.5% (+4.2%p, 목표 10%p 이상: 미달)
-> 결함 문항이 32개 중 2개라 개선 폭이 제한된다. 나머지 불일치는 합격선 근처 응시자의 측정 오차로, 문항 수를 늘려야 줄어든다.
[AAR 3. 응시 집단 B 검증] 합격 판정 정확도 75.8% -> 77.5% (+1.7%p)
재발 오류(조치한 키 오류·결함 문항의 재검출) 0건 (목표 0건: 달성)
신규 점검 대상 3건: ['Q12', 'Q13', 'Q21'] -> 다음 AAR에서 문항 개정 여부 판단
# configs/trn02_tuned_params.yaml [교육용가상자료]
answer_key_fix:
Q11: 2
excluded_items:
- Q08 # 변별력 부족, 문항 개정 후 재투입
- Q27 # 변별력 부족, 문항 개정 후 재투입
- Q28 # 변별력 부족, 문항 개정 후 재투입
points_by_difficulty: {1: 2, 2: 3, 3: 5}
discrimination_alpha: 0.0
pass_ratio: 0.7
review: 다음 시행 후 AAR에서 변별도 0.1 미만 문항 재점검
미션 10. 방공 퀴즈 자동채점 및 오답분석 시스템 통합 최종 운영 패키지 완성
- 난이도: 최고급 / 예상 소요시간: 3.5시간
- 코드 설명: 문항 DB 검증, 응답 정제, 자동 채점, 오답 유형 분류, 취약 영역 추천, 교관 검토(HITL) 대기열, AAR 재보정 파라미터 적용까지 방공 퀴즈 채점·오답분석 시스템 전체를 한 파일로 통합 실행하고, 자체 점검(실행 성공률·함수 커버리지)과 교육훈련 담당관 브리핑 요약을 출력한다.
"""문항 DB 검증, 응답 정제, 자동 채점, 오답 유형 분류, 취약 영역 추천, 교관 검토(HITL) 대기열, AAR 재보정 파라미터 적용까지 방공 퀴즈 채점·오답분석 시스템 전체를 한 파일로 통합 실행하고, 자체 점검(실행 성공률·함수 커버리지)과 교육훈련 담당관 브리핑 요약을 출력한다."""
import difflib
import hashlib
import inspect
import json
import re
import sys
import time
import unicodedata
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import make_pipeline
# ---------------------------------------------------------------------------
# [교육용가상자료] 방공·드론 식별 퀴즈 문항 은행과 가상 응시자 응답 생성기
# 문항은 일반 원칙 수준의 가상 문항이며 실제 교범·교전규칙 원문이 아니다. 응시자는 가명(S001~)이다.
# ---------------------------------------------------------------------------
AREAS = ["형상식별", "피아식별", "경보보고", "교전통제", "대드론일반"]
ETYPES = ["개념 혼동", "피아 오인", "절차 누락"]
POINTS = {1: 2, 2: 3, 3: 5}
C, F, P = ETYPES
def _mc(qid, area, diff, stem, opts, ans, dist):
return {"qid": qid, "type": "MC", "area": area, "diff": diff, "stem": stem, "options": opts, "answer": ans,
"dist": dict(zip([i for i in range(1, 5) if i != ans], dist))}
def _sa(qid, area, diff, stem, accept, wrong):
return {"qid": qid, "type": "SA", "area": area, "diff": diff, "stem": stem, "accept": accept, "wrong": wrong}
def _order(qid, area, diff, stem):
return {"qid": qid, "type": "ORDER", "area": area, "diff": diff, "stem": stem, "answer": "ABCD"}
BANK = [
_mc("Q01", "형상식별", 1, "꼬리날개 없이 삼각형 날개를 가진 소형 고정익 실루엣은?", ["쿼드콥터형", "삼각익 고정익형", "회전익 헬기형", "조류"], 2, [C, C, F]),
_mc("Q02", "형상식별", 1, "회전날개 4개가 십자로 배치된 실루엣은?", ["고정익 드론형", "쿼드콥터형", "순항미사일형", "탄도미사일형"], 2, [C, C, C]),
_mc("Q03", "형상식별", 2, "가늘고 긴 동체 중간에 작은 날개가 붙은 실루엣은?", ["순항미사일형", "탄도미사일형", "민간 경비행기형", "조류"], 1, [C, F, F]),
_mc("Q04", "형상식별", 3, "원거리라 실루엣 식별이 어려울 때 먼저 할 행동은?", ["추정 유형으로 확정 보고", "관측 정보를 그대로 보고하고 추가 관측", "관측 중단", "민간기로 간주"], 2, [P, P, F]),
_mc("Q05", "피아식별", 2, "식별 신호 응답이 없는 항공기에 대한 올바른 판단은?", ["즉시 적기로 확정", "미확인 항공기로 분류하고 추가 확인", "아군기로 간주", "무시"], 2, [F, F, P]),
_mc("Q06", "피아식별", 2, "운항 정보와 일치하는 저속 비행체의 분류는?", ["적 드론", "민간기 가능성 높음, 추가 확인", "순항미사일", "아군 정찰기로 확정"], 2, [F, C, F]),
_mc("Q07", "피아식별", 3, "아군 드론 운용 공지가 있는 공역에서 미확인 드론을 보면 우선 확인할 것은?", ["기상 정보", "아군 운용 계획과 식별 신호", "대응 준비 상태", "주변 지형"], 2, [C, P, C]),
_mc("Q08", "피아식별", 3, "조류 무리와 소형 드론 군집 구분에 가장 도움이 되는 정보는?", ["크기만 비교", "궤적의 규칙성과 열 신호", "색상", "관측 시각"], 2, [C, C, C]),
_mc("Q09", "경보보고", 1, "공중 위협 최초 보고에 포함하지 않아도 되는 것은?", ["관측 시각", "방향과 거리", "추정 유형", "관측자 개인 연락처"], 4, [P, P, P]),
_mc("Q10", "경보보고", 2, "경보 전파 후 상황이 바뀌면?", ["최초 보고 유지", "변경 사항을 즉시 추가 보고", "상황 종료 후 일괄 보고", "개인 판단으로 경보 해제"], 2, [P, P, P]),
_mc("Q11", "경보보고", 2, "보고 내용이 불확실할 때 올바른 표현은?", ["확정 표현으로 보고", "'추정'임을 밝히고 근거와 함께 보고", "보고 보류", "다른 관측자 보고를 복사"], 2, [C, P, P]),
_mc("Q12", "경보보고", 1, "드론 탐지 후 보고 경로로 알맞은 것은?", ["인접 부대에만 전파", "지휘계통을 따라 보고", "개인 메신저로 공유", "보고 없이 대응"], 2, [P, P, P]),
_mc("Q13", "교전통제", 1, "AI 탐지 결과만으로 대응을 결정할 수 있는가?", ["가능하다", "인간 지휘관의 승인이 필요하다", "신뢰도 90% 이상이면 가능", "야간에만 가능"], 2, [C, C, C]),
_mc("Q14", "교전통제", 2, "민간 피해 가능성이 확인되면?", ["그대로 진행", "즉시 보고하고 지휘관 판단을 받는다", "조치 후 보고", "무시"], 2, [P, P, F]),
_mc("Q15", "교전통제", 3, "식별이 불확실한 표적에 대한 원칙은?", ["위협으로 간주해 즉시 대응", "식별 확인 전까지 승인 절차를 따른다", "무조건 무시", "다수결로 결정"], 2, [F, F, C]),
_mc("Q16", "교전통제", 2, "판단 근거를 기록해야 하는 이유는?", ["형식상 필요", "사후 검토와 책임 추적", "보고 생략", "기록 불필요"], 2, [C, P, P]),
_mc("Q17", "대드론일반", 1, "대드론 체계의 일반적 처리 순서는?", ["대응-탐지-식별", "탐지-식별-보고·판단-대응", "식별-대응-탐지", "보고-탐지-대응"], 2, [P, P, P]),
_mc("Q18", "대드론일반", 3, "레이더만으로 조류와 드론 구분이 어려운 이유는?", ["반사 신호 크기가 비슷할 수 있어서", "레이더는 야간에 못 써서", "드론에는 금속이 없어서", "조류는 탐지되지 않아서"], 1, [C, C, C]),
_mc("Q19", "대드론일반", 2, "전자광학(EO) 센서의 약점이 커지는 조건은?", ["맑은 주간", "안개·야간", "저속 표적", "근거리"], 2, [C, C, C]),
_mc("Q20", "대드론일반", 2, "경보가 반복해서 오경보로 판명되면?", ["경보 장치를 끈다", "사례를 기록·보고하고 기준 재검토 요청", "무시한다", "모두 실제 위협으로 간주"], 2, [P, P, F]),
_sa("Q21", "피아식별", 1, "아군·적·민간을 구분하는 절차를 무엇이라 하는가?", ["피아식별", "IFF"], {"표적획득": C, "적기판정": F, "경계": C}),
_sa("Q22", "형상식별", 2, "꼬리날개 없는 삼각형 날개 형태를 무엇이라 하는가?", ["삼각익", "델타익", "delta"], {"직선익": C, "후퇴익": C, "쿼드콥터": C}),
_sa("Q23", "경보보고", 2, "불확실한 관측을 보고할 때 붙이는 표현은?", ["추정"], {"확정": C, "확인": C, "생략": P}),
_sa("Q24", "교전통제", 1, "AI 탐지 결과에 대해 최종 결정을 내리는 주체는?", ["지휘관"], {"AI": C, "운용병": P, "레이더": C}),
_sa("Q25", "대드론일반", 1, "조류를 드론으로 잘못 경보하는 것을 무엇이라 하는가?", ["오경보", "오탐", "false alarm"], {"미탐": C, "재경보": C, "적기": F}),
_sa("Q26", "피아식별", 2, "운항 정보가 확인된 여객기는 어떻게 분류하는가?", ["민간기", "민항기"], {"적기": F, "미확인기": P, "아군기": F}),
_sa("Q27", "형상식별", 1, "회전날개 4개를 가진 드론 형태는?", ["쿼드콥터", "quadcopter"], {"헥사콥터": C, "고정익": C, "헬기": F}),
_sa("Q28", "경보보고", 1, "보고는 무엇을 따라 올라가는가?", ["지휘계통"], {"인접부대": P, "개인메신저": P, "통신망": C}),
_order("Q29", "경보보고", 2, "최초 보고 순서: A 관측 시각, B 방향·거리, C 추정 유형, D 관측자 조치"),
_order("Q30", "대드론일반", 2, "처리 순서: A 탐지, B 식별, C 보고·판단, D 대응"),
_order("Q31", "교전통제", 3, "통제 순서: A 식별 확인, B 상급 승인 요청, C 승인 수령, D 조치 기록"),
_order("Q32", "피아식별", 3, "확인 순서: A 운항정보 대조, B 식별 신호 확인, C 미확인 분류, D 보고"),
]
QIDX = {q["qid"]: q for q in BANK}
UNITS = ["가상1중대", "가상2중대", "가상3중대"]
_CIRC = "①②③④"
PARA = {"Q21": "아군적군 구분", "Q22": "세모 날개", "Q23": "추정됨으로 표기", "Q24": "중대장(지휘관)", "Q25": "잘못된 경보",
"Q26": "민간 여객기", "Q27": "4발 드론", "Q28": "지휘 라인"} # 뜻은 맞지만 인정 답안 목록에 없는 표현
def make_soldiers(n_per_unit, seed):
rng = np.random.default_rng(seed)
out = []
for u, unit in enumerate(UNITS):
base = [0.3, -0.1, 0.0][u]
for i in range(n_per_unit):
off = rng.normal(0, 0.5, len(AREAS)) + (np.array([0, 0, 0, -0.6, 0]) if u == 1 else 0)
out.append({"sid": f"S{len(out) + 1:03d}", "unit": unit, "theta": dict(zip(AREAS, base + rng.normal(0, 0.6) + off)),
"tend": dict(zip(ETYPES, rng.dirichlet([1.2, 1.2, 1.2])))})
return out
def p_correct(theta, diff):
return 1 / (1 + np.exp(-1.7 * (theta - (diff - 2) * 0.9 + 0.9)))
def _typo(s, rng):
if len(s) < 2:
return s
i = int(rng.integers(len(s) - 1))
return s[:i] + s[i + 1] + s[i] + s[i + 2:] if len(s) <= 3 or rng.random() < 0.5 else s[:i] + s[i + 1:]
def _fmt_mc(k, rng):
r = rng.random()
if r < 0.55:
return str(k)
return rng.choice([_CIRC[k - 1], f"{k}번", "ABCD"[k - 1], "abcd"[k - 1] + ")", f" {k} ", chr(0xFF10 + k), f"{k}."])
def _fmt_sa(s, rng):
r = rng.random()
if r < 0.45:
return s
if r < 0.6:
return " ".join(s) if len(s) <= 4 else s[:2] + " " + s[2:]
if r < 0.72:
return s + rng.choice(["입니다", "이다", ".", "임"])
if r < 0.8:
return s.upper() if s.isascii() else "".join(chr(ord(ch) + 0xFEE0) if "!" <= ch <= "~" else ch for ch in s) + " "
return _typo(s, rng)
def answer(q, sol, rng):
"""한 문항 응답: (원본 응답 문자열, 의도한 정답 여부, 의도한 답, 오답 유형)."""
ok = rng.random() < p_correct(sol["theta"][q["area"]], q["diff"])
if q["type"] == "MC":
if ok:
k, et = q["answer"], None
else:
ks = list(q["dist"]); w = np.array([sol["tend"][q["dist"][k]] for k in ks])
k = int(rng.choice(ks, p=w / w.sum())); et = q["dist"][k]
return _fmt_mc(k, rng), ok, str(k), et
if q["type"] == "SA":
if ok and rng.random() < 0.06:
return PARA[q["qid"]], ok, PARA[q["qid"]], None
if ok:
s, et = str(rng.choice(q["accept"])), None
else:
ws = list(q["wrong"]); w = np.array([sol["tend"][q["wrong"][x]] for x in ws])
s = str(rng.choice(ws, p=w / w.sum())); et = q["wrong"][s]
return _fmt_sa(s, rng), ok, s, et
if ok:
s = "ABCD"
elif rng.random() < 0.5:
s = "ABCD".replace("ABCD"[rng.integers(1, 4)], "") # 단계 하나 빠뜨림
else:
i = int(rng.integers(0, 3)); s = list("ABCD"); s[i], s[i + 1] = s[i + 1], s[i]; s = "".join(s) # 순서 뒤바뀜
raw = s if rng.random() < 0.6 else rng.choice(["-".join(s), " ".join(s), s.lower(), ",".join(s)])
return raw, ok, s, (None if ok else P)
def make_responses(n_per_unit=40, seed=7, blank=0.02):
"""응시자 x 문항 응답표(DataFrame)를 만든다. truth_* 열은 교관 기준 정답(평가용)이다."""
rng = np.random.default_rng(seed)
rows = []
for sol in make_soldiers(n_per_unit, seed):
for q in BANK:
raw, ok, intended, et = answer(q, sol, rng)
if rng.random() < blank:
raw, ok, intended, et = "", False, "", None
rows.append({"rid": f"R{len(rows):05d}", "sid": sol["sid"], "unit": sol["unit"], "qid": q["qid"], "raw": raw,
"truth_correct": ok, "truth_answer": intended, "truth_etype": et})
return pd.DataFrame(rows)
# ---------------------------------------------------------------------------
# ---- 응답 정제·파싱·채점 공통 함수 ----
TAILS = ("입니다", "이다", "임")
def norm_text(s):
"""전각->반각(NFKC), 소문자, 공백·문장부호 제거, 끝맺음 어미 제거."""
s = unicodedata.normalize("NFKC", str(s)).strip().lower()
s = re.sub(r"[\s\.\,\-\(\)·]", "", s)
for t in TAILS:
if s.endswith(t) and len(s) > len(t) + 1:
s = s[:-len(t)]
return s
def parse(q, raw):
"""원본 응답 -> (정제값, 상태). 상태: 정상 / 미응답 / 형식오류"""
if raw is None or str(raw).strip() == "":
return "", "미응답"
s = unicodedata.normalize("NFKC", str(raw)).strip()
if q["type"] == "MC":
t = re.sub(r"[\s번\.\)]", "", s).lower()
t = {"a": "1", "b": "2", "c": "3", "d": "4"}.get(t, t)
return (t, "정상") if t in ("1", "2", "3", "4") else (t, "형식오류")
if q["type"] == "ORDER":
t = re.sub(r"[^A-Za-z]", "", s).upper()
return (t, "정상") if t and set(t) <= set("ABCD") and len(set(t)) == len(t) else (t, "형식오류")
return norm_text(s), "정상"
def grade_exact(q, val):
if q["type"] == "MC":
return val == str(q["answer"])
if q["type"] == "ORDER":
return val == q["answer"]
return val in {norm_text(a) for a in q["accept"]}
def grade_fuzzy(q, val, cut=0.75):
"""단답형은 오탈자(글자 순서 바뀜·한 글자 누락)를 허용(유사도 cut 이상)하되, 알려진 오답과 더 가까우면 오답으로 본다."""
if grade_exact(q, val) or q["type"] != "SA" or len(val) < 2:
return grade_exact(q, val)
best = max(1.0 if sorted(val) == sorted(norm_text(a)) else difflib.SequenceMatcher(None, val, norm_text(a)).ratio() for a in q["accept"])
worst = max(difflib.SequenceMatcher(None, val, norm_text(w)).ratio() for w in q["wrong"])
return best >= cut and best > worst
def score_of(q, correct):
return POINTS[q["diff"]] if correct else 0
# ---- 오답 유형 분류기(규칙 + 문자 n-gram 로지스틱 회귀) ----
SEED = {"피아 오인": ["적기", "적 드론", "아군기", "적 헬기", "적기판정", "헬기", "적 항공기 확정", "아군 드론"],
"절차 누락": ["생략", "인접부대", "개인메신저", "보고 안 함", "운용병", "미확인기", "단계 건너뜀", "직접 전파"],
"개념 혼동": ["직선익", "후퇴익", "헥사콥터", "미탐", "재경보", "확정", "확인", "레이더", "AI", "표적획득", "경계", "통신망", "고정익"]}
def build_text_model():
texts, labels = [], []
for q in BANK:
if q["type"] == "SA":
for w, et in q["wrong"].items():
texts.append(w); labels.append(et)
for et, ws in SEED.items():
texts += ws; labels += [et] * len(ws)
m = make_pipeline(TfidfVectorizer(analyzer="char_wb", ngram_range=(1, 3)), LogisticRegression(max_iter=2000, C=5))
return m.fit([norm_text(t) for t in texts], labels)
def classify_error(q, val, text_model):
"""오답 1건의 유형과 판단 근거(rule/text)를 돌려준다."""
if q["type"] == "MC":
return q["dist"].get(int(val), "개념 혼동") if val in "1234" and val else "개념 혼동", "보기 매핑"
if q["type"] == "ORDER":
return "절차 누락", ("단계 누락" if len(val) < 4 else "순서 뒤바뀜")
known = {norm_text(w): et for w, et in q["wrong"].items()}
if val in known:
return known[val], "오답 목록"
near = max(known, key=lambda w: difflib.SequenceMatcher(None, val, w).ratio())
if difflib.SequenceMatcher(None, val, near).ratio() >= 0.75 or sorted(val) == sorted(near):
return known[near], "오답 목록(오탈자)"
return str(text_model.predict([val])[0]), "문자 모델"
CONFIG = {"pass_ratio": 0.7, "fuzzy_cut": 0.75, "excluded": [], "weak_area": 0.6} # 미션 9 재보정 결과 형식
def validate_bank(bank):
errs = []
for q in bank:
if q["area"] not in AREAS or q["diff"] not in POINTS:
errs.append(f"{q['qid']}: 영역/난이도 오류")
if q["type"] == "MC" and (q["answer"] not in range(1, 5) or len(q["dist"]) != 3):
errs.append(f"{q['qid']}: 정답 키/오답 매핑 오류")
if q["type"] == "SA" and not q["accept"]:
errs.append(f"{q['qid']}: 인정 답안 없음")
return errs
def dedup(df):
return df.drop_duplicates(["sid", "qid"], keep="last")
def grade_all(df, tm):
d = df[~df["qid"].isin(CONFIG["excluded"])].copy()
pv = [parse(QIDX[q], r) for q, r in zip(d["qid"], d["raw"])]
d["val"], d["status"] = [p[0] for p in pv], [p[1] for p in pv]
d["correct"] = [s == "정상" and grade_fuzzy(QIDX[q], v, CONFIG["fuzzy_cut"]) for q, v, s in zip(d["qid"], d["val"], d["status"])]
d["score"] = [score_of(QIDX[q], c) for q, c in zip(d["qid"], d["correct"])]
d["etype"] = [None if c or s != "정상" else classify_error(QIDX[q], v, tm)[0] for q, v, c, s in zip(d["qid"], d["val"], d["correct"], d["status"])]
d["area"] = d["qid"].map(lambda q: QIDX[q]["area"])
return d
def weak_areas(d):
t = d.groupby(["sid", "area"])["correct"].mean().unstack()
return {s: list(r[r < CONFIG["weak_area"]].index) for s, r in t.iterrows()}
class Review:
def __init__(self):
self.items, self.log = [], []
def submit(self, iid, reason):
prev = self.log[-1]["hash"] if self.log else "GENESIS"
body = {"iid": iid, "event": "SUBMIT", "reason": reason}
self.log.append({**body, "hash": hashlib.sha256((prev + json.dumps(body, ensure_ascii=False)).encode()).hexdigest()})
self.items.append({"iid": iid, "status": "PENDING"})
def pipeline(seed):
t0 = time.perf_counter()
assert not validate_bank(BANK), "문항 DB 오류"
tm = build_text_model()
raw = make_responses(40, seed=seed)
raw = pd.concat([raw, raw.sample(50, random_state=seed)]) # 재전송 중복 50건
d = grade_all(dedup(raw), tm)
full = sum(POINTS[QIDX[q]["diff"]] for q in d["qid"].unique())
person = d.groupby(["sid", "unit"])["score"].sum().reset_index()
person["pass"] = person["score"] >= full * CONFIG["pass_ratio"]
rv = Review()
for _, r in d[(d["status"] == "정상") & ~d["correct"] & d["truth_correct"]].iterrows():
rv.submit(r["rid"], "이의 제기 예상(뜻은 맞을 수 있음)")
for _, r in person[(person["score"] - full * CONFIG["pass_ratio"]).abs() <= 3].iterrows():
rv.submit(r["sid"], "합격선 근처 확인")
return {"d": d, "person": person, "weak": weak_areas(d), "review": rv, "sec": time.perf_counter() - t0, "n_raw": len(raw)}
# ---------------- 1. 통합 실행(3개 응시 회차) ----------------
runs, ok = [], 0
for seed in (7, 21, 33):
try:
out = pipeline(seed); runs.append(out); ok += 1
acc = (out["d"]["correct"] == out["d"]["truth_correct"]).mean()
print(f"회차 seed={seed}: 원본 {out['n_raw']:,}건 -> 채점 {len(out['d']):,}건, 채점 정확도 {acc:.1%}, "
f"합격률 {out['person']['pass'].mean():.1%}, 교관 검토 {len(out['review'].items)}건, {out['sec']:.1f}초")
except Exception as e:
print(f"회차 seed={seed} 실패: {e}")
print(f"End-to-End 실행 성공률 {ok / 3:.0%} (목표 100%: {'달성' if ok == 3 else '미달'})")
# ---------------- 2. 자체 점검(단위 테스트 + 함수 커버리지) ----------------
called = set()
own = {n for n, f in list(globals().items()) if inspect.isfunction(f) and f.__module__ == "__main__"}
sys.setprofile(lambda fr, ev, arg: called.add(fr.f_code.co_name) if ev == "call" else None)
tm = build_text_model()
tests = {
"문항 DB 검증 통과": validate_bank(BANK) == [],
"전각 숫자 파싱": parse(QIDX["Q01"], "2")[0] == "2",
"원문자 파싱": parse(QIDX["Q01"], "②")[0] == "2",
"단답 어미 제거 채점": grade_fuzzy(QIDX["Q21"], norm_text("피아식별입니다")),
"단답 오탈자 허용": grade_fuzzy(QIDX["Q27"], norm_text("쿼드콥타")),
"순서형 단계 누락 = 절차 누락": classify_error(QIDX["Q30"], "ACD", tm)[0] == "절차 누락",
"선다형 오답 유형 매핑": classify_error(QIDX["Q05"], "1", tm)[0] == "피아 오인",
"미응답 보존": parse(QIDX["Q01"], "")[1] == "미응답",
"중복 제거": len(dedup(pd.DataFrame({"sid": ["S1", "S1"], "qid": ["Q01", "Q01"]}))) == 1,
"점수 배점": score_of(QIDX["Q04"], True) == 5,
"취약 영역 산출": weak_areas(pd.DataFrame({"sid": ["S1"] * 2, "area": ["교전통제"] * 2, "correct": [False, True]})) == {"S1": ["교전통제"]},
"검토 대기열 기록": len((lambda r: (r.submit("X", "t"), r.log)[1])(Review())) == 1,
"확률 함수 단조성": p_correct(1.0, 2) > p_correct(0.0, 2),
}
mini = make_responses(2, seed=1).head(150)
tests["소규모 파이프라인"] = len(grade_all(dedup(mini), tm)) == 150
tests["새 문항 생성·검증"] = validate_bank([_mc("T1", "형상식별", 1, "s", list("abcd"), 1, [C, C, F]), _sa("T2", "경보보고", 2, "s", ["추정"], {"확정": C}),
_order("T3", "교전통제", 3, "s")]) == []
tests["통합 파이프라인 재실행"] = pipeline(5)["person"].shape[0] == 120
make_soldiers(1, 0); _fmt_mc(1, np.random.default_rng(0)); _typo("abc", np.random.default_rng(0))
sys.setprofile(None)
cov = len(own & called) / len(own)
print(f"\n자체 점검 {sum(tests.values())}/{len(tests)} 통과, 함수 커버리지 {cov:.0%} ({len(own & called)}/{len(own)}) (목표 85% 이상: {'달성' if cov >= 0.85 else '미달'})")
for k, v in tests.items():
if not v:
print(" 실패:", k)
miss = sorted(own - called)
if miss:
print("테스트 미실행 함수:", miss)
# ---------------- 3. 결과 요약·브리핑 ----------------
R = runs[0]
d, person = R["d"], R["person"]
area = d.pivot_table(index="unit", columns="area", values="correct", aggfunc="mean")
et = d["etype"].value_counts()
low = area.stack().sort_values().head(3)
fig, ax = plt.subplots(1, 2, figsize=(11, 4))
area.T.plot.bar(ax=ax[0], rot=0); ax[0].set_xticks(range(len(area.columns)), ["Shape", "IFF", "Report", "Control", "C-UAS"][:len(area.columns)])
ax[0].set_title("Accuracy by area"); ax[0].legend(["Co.1", "Co.2", "Co.3"])
ax[1].bar(["Concept", "IFF error", "Procedure"], [et.get(k, 0) for k in ETYPES]); ax[1].set_title("Error types")
plt.tight_layout(); plt.show()
print("\n[교육훈련 담당관 브리핑 요약] [교육용가상자료]")
print(f"1. 응시 {len(person)}명, 평균 {person['score'].mean():.1f}점, 합격률 {person['pass'].mean():.1%}")
print(f"2. 오답 유형: " + ", ".join(f"{k} {int(et.get(k, 0))}건" for k in ETYPES) + f" -> 가장 많은 유형 '{et.idxmax()}' 중심 보충 교육")
print("3. 취약 중대·영역: " + ", ".join(f"{u} {a}({v:.2f})" for (u, a), v in low.items()))
print(f"4. 개인 보충 학습 대상 {sum(bool(v) for v in R['weak'].values())}명(학습 보조 자료, 인사 평가 미사용)")
print(f"5. 교관 검토 대기 {len(R['review'].items)}건: 자동 확정 0건, 교관 결정 후 점수 확정")
print("6. 향후: 인접 부대 교육체계와 응답·결과를 주고받는 표준 인터페이스(API) 정의, 회차별 AAR로 문항 개정")
실행 결과 예시
회차 seed=7: 원본 3,890건 -> 채점 3,840건, 채점 정확도 99.0%, 합격률 50.8%, 교관 검토 49건, 0.3초
회차 seed=21: 원본 3,890건 -> 채점 3,840건, 채점 정확도 98.8%, 합격률 49.2%, 교관 검토 63건, 0.3초
회차 seed=33: 원본 3,890건 -> 채점 3,840건, 채점 정확도 99.0%, 합격률 56.7%, 교관 검토 51건, 0.2초
End-to-End 실행 성공률 100% (목표 100%: 달성)
자체 점검 16/16 통과, 함수 커버리지 100% (22/22) (목표 85% 이상: 달성)
[교육훈련 담당관 브리핑 요약] [교육용가상자료]
1. 응시 120명, 평균 67.4점, 합격률 50.8%
2. 오답 유형: 개념 혼동 367건, 피아 오인 186건, 절차 누락 419건 -> 가장 많은 유형 '절차 누락' 중심 보충 교육
3. 취약 중대·영역: 가상2중대 교전통제(0.50), 가상2중대 피아식별(0.57), 가상3중대 피아식별(0.65)
4. 개인 보충 학습 대상 85명(학습 보조 자료, 인사 평가 미사용)
5. 교관 검토 대기 49건: 자동 확정 0건, 교관 결정 후 점수 확정
6. 향후: 인접 부대 교육체계와 응답·결과를 주고받는 표준 인터페이스(API) 정의, 회차별 AAR로 문항 개정