Files
pbl02/uploads/TRN02/TRN02_student.ipynb
2026-10-08 17:15:05 +09:00

4021 lines
298 KiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"cells": [
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# 🎯 [TRN02] 방공·드론 식별 퀴즈 자동채점·오답유형 분석기\n",
"### Google Colab 실습 환경 (교육·훈련 / Level 2)\n",
"\n",
"[![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/edunlab/milicontent2/blob/main/uploads/TRN02/TRN02_solution.ipynb)\n",
"\n",
"---\n",
"\n",
"## 📌 1. 프로젝트 개요 및 배경\n",
"\n",
"| 항목 | 내용 |\n",
"| :--- | :--- |\n",
"| **과정 분류** | 교육·훈련 (Level 2) |\n",
"| **학습 소요 시간** | 10개 미션 (5일 / 총 27.5시간) |\n",
"| **환경 사양** | Python 3.10+, numpy, pandas, matplotlib, scipy, scikit-learn |\n",
"\n",
"### 📖 프로젝트 목표\n",
"미국-이란 전쟁 사례에서 보듯 드론과 미사일이 섞여 날아오는 전장에서는 장병 한 사람 한 사람이 비행체 형상을 식별하고, 아군·민간기와 구분하며, 정해진 보고·통제 절차를 지키는 능력이 방공의 기초가 된다. 그러나 부대 단위 방공 식별 교육은 채점과 오답 분석에 교관의 손이 많이 들어 개인별 약점을 제때 짚어 주기 어렵다. 본 프로젝트에서는 교육용 가상 문항 은행과 가상 응시자 응답을 코드로 만들어, 문항 데이터베이스 정의, 응답 정제·파싱, 자동 채점, 오답 유형(개념 혼동·피아 오인·절차 누락) 분류, 취약 영역 추천, 교수자 대시보드, 교관 검토(HITL), 대규모 동시 응시 부하 시험, AAR 문항 분석과 재보정, 최종 통합까지 채점·피드백 체계를 단계별로 구현한다. 최종 목표는 채점은 빠르고 정확하게 자동화하되, 이의 제기와 서술형 판단은 교관이 확정하는 신뢰할 수 있는 방공 교육 평가 도구를 만드는 것이다.\n",
"\n",
"> **⚠️ [교육용 가상 데이터셋 원칙]**\n",
"> - 본 노트북의 모든 데이터(제원, 로그, 시나리오 등)는 보안 규정을 준수하여 **코드 내 난수 기반 가상 데이터로 생성**됩니다.\n",
"> - 실제 부대 위치, 작전 인가, 실제 전장 정보, 군사 기밀 데이터는 일체 포함되지 않습니다.\n",
"\n",
"---\n",
"\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# ============================================================================\n",
"# [Google Colab 환경 설정] 필수 라이브러리 설치 및 임포트\n",
"# ============================================================================\n",
"!pip install -q numpy pandas matplotlib scipy scikit-learn\n",
"\n",
"import os\n",
"import sys\n",
"import time\n",
"import math\n",
"import json\n",
"import re\n",
"import numpy as np\n",
"import pandas as pd\n",
"import matplotlib.pyplot as plt\n",
"import warnings\n",
"warnings.filterwarnings('ignore')\n",
"\n",
"# 그래프 영문 폰트 기본 설정 (환경 독립성 보장)\n",
"plt.rcParams['font.sans-serif'] = 'DejaVu Sans'\n",
"plt.rcParams['axes.unicode_minus'] = False\n",
"\n",
"print(f\"실행 환경 준비 완료 (Python {sys.version.split()[0]})\")\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"---\n",
"\n",
"## 🎯 미션 01. 방공 식별 및 대드론 교전 수칙 퀴즈 문항 데이터베이스 및 평가 기준 수립\n",
"**소요시간**: 약 1.5시간\n",
"\n",
"### 📋 미션 배경 및 수행 지침\n",
"#### 미션 배경 및 당위성\n",
"\n",
"방공 식별 및 대드론 교전 수칙 퀴즈 문항 데이터베이스 및 평가 기준을 수립한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **형상식별·피아식별·경보보고·교전통제·대드론일반 5개 영역의 퀴즈 문항 데이터베이스 구조와 난이도별 배점·합격 기준을 정하고, 잘못된 문항·응답 기록을 걸러내는 검증기를 만든다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
"\n",
"이 문제를 해결하기 위해 본 미션에서는 **선다형·단답형·순서형 문항의 필드(문항ID, 영역, 난이도, 정답, 오답 보기별 오답 유형)와 응답 기록 필드(응시자, 문항, 원본 응답, 제출 시각), 응시 상황 필드(장소·통신 상태 등) 정의하고 검증하는** 실습을 체계적으로 수행합니다.\n",
"\n",
"학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
"\n",
"**이번 결과물:** `data/schema/trn02_input_schema.json 및 docs/trn02_data_spec.md` 형태의 결과물을 완성한다.\n",
"\n",
"### 🔍 평가 및 검증 기준 (PASS 조건)\n",
"#### 미션 판정\n",
"\n",
"**평가 구분:** 필수\n",
"\n",
"**핵심 평가영역:** 교육·훈련 실무 및 미국-이란 전쟁 전장 환경과의 군사적 부합성\n",
"\n",
"#### 미션 PASS 조건\n",
"\n",
"- 정량 기준: 스키마 필드 정의 완성도 100%, 샘플 데이터 검증 오류 0건\n",
"\n",
"\n",
"- 진행 기준: 입력 데이터 유효성 검증 스크립트 통과 및 명세서 문서화 완료\n",
"\n",
"\n",
"- 검증 방법: JSON Schema 검증 도구 및 샘플 데이터 파싱 단위 테스트\n",
"\n",
"\n",
"#### 채점자 판정 메모\n",
"\n",
"> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n",
"\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"\"\"\"방공 식별·대드론 교전 통제 원칙 퀴즈의 문항 데이터베이스와 응답 기록 스키마, 난이도별 배점·합격 기준을 정의하고, 가상 문항·응답 샘플을 검증한다.\"\"\"\n",
"import copy\n",
"import re\n",
"import numpy as np\n",
"import pandas as pd\n",
"\n",
"# ---------------------------------------------------------------------------\n",
"# [교육용가상자료] 방공·드론 식별 퀴즈 문항 은행과 가상 응시자 응답 생성기\n",
"# 문항은 일반 원칙 수준의 가상 문항이며 실제 교범·교전규칙 원문이 아니다. 응시자는 가명(S001~)이다.\n",
"# ---------------------------------------------------------------------------\n",
"AREAS = [\"형상식별\", \"피아식별\", \"경보보고\", \"교전통제\", \"대드론일반\"]\n",
"ETYPES = [\"개념 혼동\", \"피아 오인\", \"절차 누락\"]\n",
"POINTS = {1: 2, 2: 3, 3: 5}\n",
"C, F, P = ETYPES\n",
"\n",
"\n",
"def _mc(qid, area, diff, stem, opts, ans, dist):\n",
" # [TODO : 학생 실습 1] 방공 식별 및 대드론 교전 수칙 퀴즈 문항 데이터베이스 및 평가 기준 수립 관련 핵심 로직을 구현하세요.\n",
" # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n",
" # <<< 여기에 코드를 작성하세요 >>>\n",
" pass # 작성 후 pass를 제거하세요.\n",
"def _sa(qid, area, diff, stem, accept, wrong):\n",
" # [TODO : 학생 실습 1] 방공 식별 및 대드론 교전 수칙 퀴즈 문항 데이터베이스 및 평가 기준 수립 관련 핵심 로직을 구현하세요.\n",
" # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n",
" # <<< 여기에 코드를 작성하세요 >>>\n",
" pass # 작성 후 pass를 제거하세요.\n",
"def _order(qid, area, diff, stem):\n",
" return {\"qid\": qid, \"type\": \"ORDER\", \"area\": area, \"diff\": diff, \"stem\": stem, \"answer\": \"ABCD\"}\n",
"\n",
"\n",
"BANK = [\n",
" _mc(\"Q01\", \"형상식별\", 1, \"꼬리날개 없이 삼각형 날개를 가진 소형 고정익 실루엣은?\", [\"쿼드콥터형\", \"삼각익 고정익형\", \"회전익 헬기형\", \"조류\"], 2, [C, C, F]),\n",
" _mc(\"Q02\", \"형상식별\", 1, \"회전날개 4개가 십자로 배치된 실루엣은?\", [\"고정익 드론형\", \"쿼드콥터형\", \"순항미사일형\", \"탄도미사일형\"], 2, [C, C, C]),\n",
" _mc(\"Q03\", \"형상식별\", 2, \"가늘고 긴 동체 중간에 작은 날개가 붙은 실루엣은?\", [\"순항미사일형\", \"탄도미사일형\", \"민간 경비행기형\", \"조류\"], 1, [C, F, F]),\n",
" _mc(\"Q04\", \"형상식별\", 3, \"원거리라 실루엣 식별이 어려울 때 먼저 할 행동은?\", [\"추정 유형으로 확정 보고\", \"관측 정보를 그대로 보고하고 추가 관측\", \"관측 중단\", \"민간기로 간주\"], 2, [P, P, F]),\n",
" _mc(\"Q05\", \"피아식별\", 2, \"식별 신호 응답이 없는 항공기에 대한 올바른 판단은?\", [\"즉시 적기로 확정\", \"미확인 항공기로 분류하고 추가 확인\", \"아군기로 간주\", \"무시\"], 2, [F, F, P]),\n",
" _mc(\"Q06\", \"피아식별\", 2, \"운항 정보와 일치하는 저속 비행체의 분류는?\", [\"적 드론\", \"민간기 가능성 높음, 추가 확인\", \"순항미사일\", \"아군 정찰기로 확정\"], 2, [F, C, F]),\n",
" _mc(\"Q07\", \"피아식별\", 3, \"아군 드론 운용 공지가 있는 공역에서 미확인 드론을 보면 우선 확인할 것은?\", [\"기상 정보\", \"아군 운용 계획과 식별 신호\", \"대응 준비 상태\", \"주변 지형\"], 2, [C, P, C]),\n",
" _mc(\"Q08\", \"피아식별\", 3, \"조류 무리와 소형 드론 군집 구분에 가장 도움이 되는 정보는?\", [\"크기만 비교\", \"궤적의 규칙성과 열 신호\", \"색상\", \"관측 시각\"], 2, [C, C, C]),\n",
" _mc(\"Q09\", \"경보보고\", 1, \"공중 위협 최초 보고에 포함하지 않아도 되는 것은?\", [\"관측 시각\", \"방향과 거리\", \"추정 유형\", \"관측자 개인 연락처\"], 4, [P, P, P]),\n",
" _mc(\"Q10\", \"경보보고\", 2, \"경보 전파 후 상황이 바뀌면?\", [\"최초 보고 유지\", \"변경 사항을 즉시 추가 보고\", \"상황 종료 후 일괄 보고\", \"개인 판단으로 경보 해제\"], 2, [P, P, P]),\n",
" _mc(\"Q11\", \"경보보고\", 2, \"보고 내용이 불확실할 때 올바른 표현은?\", [\"확정 표현으로 보고\", \"'추정'임을 밝히고 근거와 함께 보고\", \"보고 보류\", \"다른 관측자 보고를 복사\"], 2, [C, P, P]),\n",
" _mc(\"Q12\", \"경보보고\", 1, \"드론 탐지 후 보고 경로로 알맞은 것은?\", [\"인접 부대에만 전파\", \"지휘계통을 따라 보고\", \"개인 메신저로 공유\", \"보고 없이 대응\"], 2, [P, P, P]),\n",
" _mc(\"Q13\", \"교전통제\", 1, \"AI 탐지 결과만으로 대응을 결정할 수 있는가?\", [\"가능하다\", \"인간 지휘관의 승인이 필요하다\", \"신뢰도 90% 이상이면 가능\", \"야간에만 가능\"], 2, [C, C, C]),\n",
" _mc(\"Q14\", \"교전통제\", 2, \"민간 피해 가능성이 확인되면?\", [\"그대로 진행\", \"즉시 보고하고 지휘관 판단을 받는다\", \"조치 후 보고\", \"무시\"], 2, [P, P, F]),\n",
" _mc(\"Q15\", \"교전통제\", 3, \"식별이 불확실한 표적에 대한 원칙은?\", [\"위협으로 간주해 즉시 대응\", \"식별 확인 전까지 승인 절차를 따른다\", \"무조건 무시\", \"다수결로 결정\"], 2, [F, F, C]),\n",
" _mc(\"Q16\", \"교전통제\", 2, \"판단 근거를 기록해야 하는 이유는?\", [\"형식상 필요\", \"사후 검토와 책임 추적\", \"보고 생략\", \"기록 불필요\"], 2, [C, P, P]),\n",
" _mc(\"Q17\", \"대드론일반\", 1, \"대드론 체계의 일반적 처리 순서는?\", [\"대응-탐지-식별\", \"탐지-식별-보고·판단-대응\", \"식별-대응-탐지\", \"보고-탐지-대응\"], 2, [P, P, P]),\n",
" _mc(\"Q18\", \"대드론일반\", 3, \"레이더만으로 조류와 드론 구분이 어려운 이유는?\", [\"반사 신호 크기가 비슷할 수 있어서\", \"레이더는 야간에 못 써서\", \"드론에는 금속이 없어서\", \"조류는 탐지되지 않아서\"], 1, [C, C, C]),\n",
" _mc(\"Q19\", \"대드론일반\", 2, \"전자광학(EO) 센서의 약점이 커지는 조건은?\", [\"맑은 주간\", \"안개·야간\", \"저속 표적\", \"근거리\"], 2, [C, C, C]),\n",
" _mc(\"Q20\", \"대드론일반\", 2, \"경보가 반복해서 오경보로 판명되면?\", [\"경보 장치를 끈다\", \"사례를 기록·보고하고 기준 재검토 요청\", \"무시한다\", \"모두 실제 위협으로 간주\"], 2, [P, P, F]),\n",
" _sa(\"Q21\", \"피아식별\", 1, \"아군·적·민간을 구분하는 절차를 무엇이라 하는가?\", [\"피아식별\", \"IFF\"], {\"표적획득\": C, \"적기판정\": F, \"경계\": C}),\n",
" _sa(\"Q22\", \"형상식별\", 2, \"꼬리날개 없는 삼각형 날개 형태를 무엇이라 하는가?\", [\"삼각익\", \"델타익\", \"delta\"], {\"직선익\": C, \"후퇴익\": C, \"쿼드콥터\": C}),\n",
" _sa(\"Q23\", \"경보보고\", 2, \"불확실한 관측을 보고할 때 붙이는 표현은?\", [\"추정\"], {\"확정\": C, \"확인\": C, \"생략\": P}),\n",
" _sa(\"Q24\", \"교전통제\", 1, \"AI 탐지 결과에 대해 최종 결정을 내리는 주체는?\", [\"지휘관\"], {\"AI\": C, \"운용병\": P, \"레이더\": C}),\n",
" _sa(\"Q25\", \"대드론일반\", 1, \"조류를 드론으로 잘못 경보하는 것을 무엇이라 하는가?\", [\"오경보\", \"오탐\", \"false alarm\"], {\"미탐\": C, \"재경보\": C, \"적기\": F}),\n",
" _sa(\"Q26\", \"피아식별\", 2, \"운항 정보가 확인된 여객기는 어떻게 분류하는가?\", [\"민간기\", \"민항기\"], {\"적기\": F, \"미확인기\": P, \"아군기\": F}),\n",
" _sa(\"Q27\", \"형상식별\", 1, \"회전날개 4개를 가진 드론 형태는?\", [\"쿼드콥터\", \"quadcopter\"], {\"헥사콥터\": C, \"고정익\": C, \"헬기\": F}),\n",
" _sa(\"Q28\", \"경보보고\", 1, \"보고는 무엇을 따라 올라가는가?\", [\"지휘계통\"], {\"인접부대\": P, \"개인메신저\": P, \"통신망\": C}),\n",
" _order(\"Q29\", \"경보보고\", 2, \"최초 보고 순서: A 관측 시각, B 방향·거리, C 추정 유형, D 관측자 조치\"),\n",
" _order(\"Q30\", \"대드론일반\", 2, \"처리 순서: A 탐지, B 식별, C 보고·판단, D 대응\"),\n",
" _order(\"Q31\", \"교전통제\", 3, \"통제 순서: A 식별 확인, B 상급 승인 요청, C 승인 수령, D 조치 기록\"),\n",
" _order(\"Q32\", \"피아식별\", 3, \"확인 순서: A 운항정보 대조, B 식별 신호 확인, C 미확인 분류, D 보고\"),\n",
"]\n",
"QIDX = {q[\"qid\"]: q for q in BANK}\n",
"UNITS = [\"가상1중대\", \"가상2중대\", \"가상3중대\"]\n",
"_CIRC = \"①②③④\"\n",
"PARA = {\"Q21\": \"아군적군 구분\", \"Q22\": \"세모 날개\", \"Q23\": \"추정됨으로 표기\", \"Q24\": \"중대장(지휘관)\", \"Q25\": \"잘못된 경보\",\n",
" \"Q26\": \"민간 여객기\", \"Q27\": \"4발 드론\", \"Q28\": \"지휘 라인\"} # 뜻은 맞지만 인정 답안 목록에 없는 표현\n",
"\n",
"\n",
"def make_soldiers(n_per_unit, seed):\n",
" rng = np.random.default_rng(seed)\n",
" out = []\n",
" for u, unit in enumerate(UNITS):\n",
" base = [0.3, -0.1, 0.0][u]\n",
" for i in range(n_per_unit):\n",
" off = rng.normal(0, 0.5, len(AREAS)) + (np.array([0, 0, 0, -0.6, 0]) if u == 1 else 0)\n",
" out.append({\"sid\": f\"S{len(out) + 1:03d}\", \"unit\": unit, \"theta\": dict(zip(AREAS, base + rng.normal(0, 0.6) + off)),\n",
" \"tend\": dict(zip(ETYPES, rng.dirichlet([1.2, 1.2, 1.2])))})\n",
" return out\n",
"\n",
"\n",
"def p_correct(theta, diff):\n",
" return 1 / (1 + np.exp(-1.7 * (theta - (diff - 2) * 0.9 + 0.9)))\n",
"\n",
"\n",
"def _typo(s, rng):\n",
" if len(s) < 2:\n",
" return s\n",
" i = int(rng.integers(len(s) - 1))\n",
" 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:]\n",
"\n",
"\n",
"def _fmt_mc(k, rng):\n",
" r = rng.random()\n",
" if r < 0.55:\n",
" return str(k)\n",
" return rng.choice([_CIRC[k - 1], f\"{k}번\", \"ABCD\"[k - 1], \"abcd\"[k - 1] + \")\", f\" {k} \", chr(0xFF10 + k), f\"{k}.\"])\n",
"\n",
"\n",
"def _fmt_sa(s, rng):\n",
" r = rng.random()\n",
" if r < 0.45:\n",
" return s\n",
" if r < 0.6:\n",
" return \" \".join(s) if len(s) <= 4 else s[:2] + \" \" + s[2:]\n",
" if r < 0.72:\n",
" return s + rng.choice([\"입니다\", \"이다\", \".\", \"임\"])\n",
" if r < 0.8:\n",
" return s.upper() if s.isascii() else \"\".join(chr(ord(ch) + 0xFEE0) if \"!\" <= ch <= \"~\" else ch for ch in s) + \" \"\n",
" return _typo(s, rng)\n",
"\n",
"\n",
"def answer(q, sol, rng):\n",
" \"\"\"한 문항 응답: (원본 응답 문자열, 의도한 정답 여부, 의도한 답, 오답 유형).\"\"\"\n",
" ok = rng.random() < p_correct(sol[\"theta\"][q[\"area\"]], q[\"diff\"])\n",
" if q[\"type\"] == \"MC\":\n",
" if ok:\n",
" k, et = q[\"answer\"], None\n",
" else:\n",
" ks = list(q[\"dist\"]); w = np.array([sol[\"tend\"][q[\"dist\"][k]] for k in ks])\n",
" k = int(rng.choice(ks, p=w / w.sum())); et = q[\"dist\"][k]\n",
" return _fmt_mc(k, rng), ok, str(k), et\n",
" if q[\"type\"] == \"SA\":\n",
" if ok and rng.random() < 0.06:\n",
" return PARA[q[\"qid\"]], ok, PARA[q[\"qid\"]], None\n",
" if ok:\n",
" s, et = str(rng.choice(q[\"accept\"])), None\n",
" else:\n",
" ws = list(q[\"wrong\"]); w = np.array([sol[\"tend\"][q[\"wrong\"][x]] for x in ws])\n",
" s = str(rng.choice(ws, p=w / w.sum())); et = q[\"wrong\"][s]\n",
" return _fmt_sa(s, rng), ok, s, et\n",
" if ok:\n",
" s = \"ABCD\"\n",
" elif rng.random() < 0.5:\n",
" s = \"ABCD\".replace(\"ABCD\"[rng.integers(1, 4)], \"\") # 단계 하나 빠뜨림\n",
" else:\n",
" i = int(rng.integers(0, 3)); s = list(\"ABCD\"); s[i], s[i + 1] = s[i + 1], s[i]; s = \"\".join(s) # 순서 뒤바뀜\n",
" raw = s if rng.random() < 0.6 else rng.choice([\"-\".join(s), \" \".join(s), s.lower(), \",\".join(s)])\n",
" return raw, ok, s, (None if ok else P)\n",
"\n",
"\n",
"def make_responses(n_per_unit=40, seed=7, blank=0.02):\n",
" \"\"\"응시자 x 문항 응답표(DataFrame)를 만든다. truth_* 열은 교관 기준 정답(평가용)이다.\"\"\"\n",
" rng = np.random.default_rng(seed)\n",
" rows = []\n",
" for sol in make_soldiers(n_per_unit, seed):\n",
" for q in BANK:\n",
" raw, ok, intended, et = answer(q, sol, rng)\n",
" if rng.random() < blank:\n",
" raw, ok, intended, et = \"\", False, \"\", None\n",
" rows.append({\"rid\": f\"R{len(rows):05d}\", \"sid\": sol[\"sid\"], \"unit\": sol[\"unit\"], \"qid\": q[\"qid\"], \"raw\": raw,\n",
" \"truth_correct\": ok, \"truth_answer\": intended, \"truth_etype\": et})\n",
" return pd.DataFrame(rows)\n",
"# ---------------------------------------------------------------------------\n",
"\n",
"# 1) 스키마: 문항(유형·영역·난이도·정답·오답 유형 매핑·상황 조건) / 응답(응시자 가명·문항·원본 응답·제출 시각)\n",
"SCHEMA = {\n",
" \"item\": {\n",
" \"qid\": {\"type\": str, \"pattern\": r\"^Q\\d{2}$\"},\n",
" \"type\": {\"type\": str, \"enum\": [\"MC\", \"SA\", \"ORDER\", \"ESSAY\"]},\n",
" \"area\": {\"type\": str, \"enum\": AREAS},\n",
" \"diff\": {\"type\": int, \"enum\": [1, 2, 3]},\n",
" \"stem\": {\"type\": str, \"minlen\": 5},\n",
" },\n",
" \"context\": { # 보완한 전술 상황 파라미터\n",
" \"time\": {\"type\": str, \"enum\": [\"주간\", \"야간\"]},\n",
" \"weather\": {\"type\": str, \"enum\": [\"맑음\", \"안개\", \"비\", \"눈\"]},\n",
" \"comm\": {\"type\": str, \"enum\": [\"정상\", \"지연\", \"두절\"]},\n",
" \"position_error_m\": {\"type\": (int, float), \"min\": 0, \"max\": 500},\n",
" },\n",
" \"response\": {\n",
" \"rid\": {\"type\": str, \"pattern\": r\"^R\\d{5}$\"},\n",
" \"sid\": {\"type\": str, \"pattern\": r\"^S\\d{3}$\"},\n",
" \"qid\": {\"type\": str, \"pattern\": r\"^Q\\d{2}$\"},\n",
" \"raw\": {\"type\": str},\n",
" \"submitted_s\": {\"type\": (int, float), \"min\": 0, \"max\": 7200},\n",
" },\n",
"}\n",
"RUBRIC = {\"배점(난이도 1/2/3)\": \"2 / 3 / 5점\", \"서술형\": \"10점(교관 채점)\", \"합격 기준\": \"총점의 70% 이상\", \"미응답\": \"0점, 미응답으로 기록(삭제 안 함)\"}\n",
"\n",
"\n",
"def check(name, v, r):\n",
" if not isinstance(v, r[\"type\"]) or isinstance(v, bool):\n",
" return [f\"{name}: 자료형 오류({type(v).__name__})\"]\n",
" e = []\n",
" if \"enum\" in r and v not in r[\"enum\"]:\n",
" e.append(f\"{name}: 허용되지 않은 값 '{v}'\")\n",
" if \"pattern\" in r and not re.match(r[\"pattern\"], v):\n",
" e.append(f\"{name}: 형식 불일치 '{v}'\")\n",
" if \"minlen\" in r and len(v) < r[\"minlen\"]:\n",
" e.append(f\"{name}: 길이 부족\")\n",
" if \"min\" in r and v < r[\"min\"]:\n",
" e.append(f\"{name}: 최소값 미만\")\n",
" if \"max\" in r and v > r[\"max\"]:\n",
" e.append(f\"{name}: 최대값 초과\")\n",
" return e\n",
"\n",
"\n",
"def validate_item(it):\n",
" e = []\n",
" for g in (\"item\", \"context\"):\n",
" src = it if g == \"item\" else it.get(\"context\", {})\n",
" for k, r in SCHEMA[g].items():\n",
" e += [f\"{g}.{k}: 필수 필드 누락\"] if k not in src else check(f\"{g}.{k}\", src[k], r)\n",
" if it.get(\"type\") == \"MC\":\n",
" if len(it.get(\"options\", [])) != 4 or it.get(\"answer\") not in (1, 2, 3, 4):\n",
" e.append(\"MC: 보기 4개·정답 번호(1~4) 필요\")\n",
" elif set(it.get(\"dist\", {})) != {1, 2, 3, 4} - {it[\"answer\"]} or not set(it[\"dist\"].values()) <= set(ETYPES):\n",
" e.append(\"MC: 오답 보기별 오답 유형 매핑 불완전\")\n",
" if it.get(\"type\") == \"SA\" and (not it.get(\"accept\") or not set(it.get(\"wrong\", {}).values()) <= set(ETYPES)):\n",
" e.append(\"SA: 인정 답안·오답 유형 목록 필요\")\n",
" return e\n",
"\n",
"\n",
"def validate_resp(r):\n",
" e = []\n",
" for k, rule in SCHEMA[\"response\"].items():\n",
" e += [f\"response.{k}: 필수 필드 누락\"] if k not in r else check(f\"response.{k}\", r[k], rule)\n",
" if r.get(\"qid\") not in QIDX:\n",
" e.append(\"response.qid: 문항 DB에 없는 문항\")\n",
" return e\n",
"\n",
"\n",
"rng = np.random.default_rng(1)\n",
"items = []\n",
"for q in BANK:\n",
" it = copy.deepcopy(q)\n",
" it[\"context\"] = {\"time\": str(rng.choice([\"주간\", \"야간\"])), \"weather\": str(rng.choice([\"맑음\", \"안개\", \"비\", \"눈\"])),\n",
" \"comm\": str(rng.choice([\"정상\", \"지연\"])), \"position_error_m\": float(round(rng.uniform(5, 80), 1))}\n",
" items.append(it)\n",
"resp = make_responses(5, seed=3).head(50)\n",
"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())]\n",
"ni, nr = sum(not validate_item(i) for i in items), sum(not validate_resp(r) for r in recs)\n",
"print(f\"[교육용가상자료] 문항 {len(items)}개 중 검증 통과 {ni}개, 응답 {len(recs)}건 중 통과 {nr}건 (목표 오류 0건: {'달성' if ni == len(items) and nr == len(recs) else '미달'})\")\n",
"\n",
"bad = copy.deepcopy(items[0]); bad[\"qid\"] = \"Q1\"; bad[\"diff\"] = 4; bad[\"dist\"].pop(4); del bad[\"context\"][\"comm\"]\n",
"bad_r = dict(recs[0], sid=\"병사1\", qid=\"Q99\", submitted_s=9999)\n",
"print(\"\\n[오류 주입 결과]\")\n",
"for e in validate_item(bad) + validate_resp(bad_r):\n",
" print(\" -\", e)\n",
"\n",
"print(\"\\n[문항 구성]\")\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=\"합계\"))\n",
"print(\"\\n[평가 기준]\")\n",
"for k, v in RUBRIC.items():\n",
" print(f\" - {k}: {v}\")\n",
"print(f\" - 만점: {sum(POINTS[i['diff']] for i in items)}점(서술형 제외), 오답 유형: {', '.join(ETYPES)}\")\n",
"n_fields = sum(len(v) for v in SCHEMA.values())\n",
"print(f\"\\n스키마 필드 {n_fields}개 정의(문항 {len(SCHEMA['item'])}, 상황 {len(SCHEMA['context'])}, 응답 {len(SCHEMA['response'])}), 정의 완성도 100%\")\n",
"print(\"문항 예시:\", items[3][\"qid\"], items[3][\"stem\"], \"| 정답\", items[3][\"answer\"], \"| 오답 유형\", items[3][\"dist\"])\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"<details>\n",
"<summary><b>💡 [참고] 미션 01 예상 실행 결과 (Output)</b></summary>\n",
"\n",
"```text\n",
"[교육용가상자료] 문항 32개 중 검증 통과 32개, 응답 50건 중 통과 50건 (목표 오류 0건: 달성)\n",
"\n",
"[오류 주입 결과]\n",
" - item.qid: 형식 불일치 'Q1'\n",
" - item.diff: 허용되지 않은 값 '4'\n",
" - context.comm: 필수 필드 누락\n",
" - MC: 오답 보기별 오답 유형 매핑 불완전\n",
" - response.sid: 형식 불일치 '병사1'\n",
" - response.submitted_s: 최대값 초과\n",
" - response.qid: 문항 DB에 없는 문항\n",
"\n",
"[문항 구성]\n",
"유형 MC ORDER SA 합계\n",
"영역 \n",
"경보보고 4 1 2 7\n",
"교전통제 4 1 1 6\n",
"대드론일반 4 1 1 6\n",
"피아식별 4 1 2 7\n",
"형상식별 4 0 2 6\n",
"합계 20 4 8 32\n",
"\n",
"[평가 기준]\n",
" - 배점(난이도 1/2/3): 2 / 3 / 5점\n",
" - 서술형: 10점(교관 채점)\n",
" - 합격 기준: 총점의 70% 이상\n",
" - 미응답: 0점, 미응답으로 기록(삭제 안 함)\n",
" - 만점: 99점(서술형 제외), 오답 유형: 개념 혼동, 피아 오인, 절차 누락\n",
"\n",
"스키마 필드 14개 정의(문항 5, 상황 4, 응답 5), 정의 완성도 100%\n",
"문항 예시: Q04 원거리라 실루엣 식별이 어려울 때 먼저 할 행동은? | 정답 2 | 오답 유형 {1: '절차 누락', 3: '절차 누락', 4: '피아 오인'}\n",
"```\n",
"</details>\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"---\n",
"\n",
"## 🎯 미션 02. 장병 응답 텍스트/선택지 정제 및 시험 답안 파싱 전처리 모듈 구현\n",
"**소요시간**: 약 2.0시간\n",
"\n",
"### 📋 미션 배경 및 수행 지침\n",
"#### 미션 배경 및 당위성\n",
"\n",
"장병 응답 텍스트/선택지 정제 및 시험 답안 파싱 전처리 모듈을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **장병 응답의 전각 숫자·원문자·공백·어미 같은 표기 차이를 정제하고 문항 유형별로 답안을 파싱하는 전처리 모듈을 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
"\n",
"이 문제를 해결하기 위해 본 미션에서는 **유니코드 정규화(NFKC), 원문자·영문 보기·'번' 표기 통일, 단답형 공백·끝맺음 어미 제거, 순서형 구분자 제거 구현하고 동작을 검증하는** 실습을 체계적으로 수행합니다.\n",
"\n",
"학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
"\n",
"**이번 결과물:** `src/preprocess/trn02_preprocessor.py 및 전처리 전후 데이터셋` 형태의 결과물을 완성한다.\n",
"\n",
"### 🔍 평가 및 검증 기준 (PASS 조건)\n",
"#### 미션 판정\n",
"\n",
"**평가 구분:** 필수\n",
"\n",
"**핵심 평가영역:** 전술 환경 특유의 노이즈(스푸핑, 반사파, 센서 단절 등) 제거 타당성\n",
"\n",
"#### 미션 PASS 조건\n",
"\n",
"- 정량 기준: 데이터 정제 처리 속도 1,000건/초 이상, 전처리 오류율 0%\n",
"\n",
"\n",
"- 진행 기준: 정제 파이프라인 자동화 및 단위 테스트 케이스 5종 통과\n",
"\n",
"\n",
"- 검증 방법: pytest 기반 전처리 정합성 테스트 및 결측치 보정율 확인\n",
"\n",
"\n",
"#### 채점자 판정 메모\n",
"\n",
"> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n",
"\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"\"\"\"장병 응답의 전각·원문자·공백·어미 등 표기 차이를 정제하고 선다형·단답형·순서형 답안을 파싱하며, 통신 상태에 따라 재전송 중복을 제거하고 미응답·형식 오류를 분리하는 전처리 모듈을 구현한다. 처리 속도와 단위 테스트 5종을 확인한다.\"\"\"\n",
"import difflib\n",
"import re\n",
"import time\n",
"import unicodedata\n",
"import numpy as np\n",
"import pandas as pd\n",
"import matplotlib.pyplot as plt\n",
"\n",
"# ---------------------------------------------------------------------------\n",
"# [교육용가상자료] 방공·드론 식별 퀴즈 문항 은행과 가상 응시자 응답 생성기\n",
"# 문항은 일반 원칙 수준의 가상 문항이며 실제 교범·교전규칙 원문이 아니다. 응시자는 가명(S001~)이다.\n",
"# ---------------------------------------------------------------------------\n",
"AREAS = [\"형상식별\", \"피아식별\", \"경보보고\", \"교전통제\", \"대드론일반\"]\n",
"ETYPES = [\"개념 혼동\", \"피아 오인\", \"절차 누락\"]\n",
"POINTS = {1: 2, 2: 3, 3: 5}\n",
"C, F, P = ETYPES\n",
"\n",
"\n",
"def _mc(qid, area, diff, stem, opts, ans, dist):\n",
" # [TODO : 학생 실습 2] 장병 응답 텍스트/선택지 정제 및 시험 답안 파싱 전처리 모듈 구현 관련 핵심 로직을 구현하세요.\n",
" # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n",
" # <<< 여기에 코드를 작성하세요 >>>\n",
" pass # 작성 후 pass를 제거하세요.\n",
"def _sa(qid, area, diff, stem, accept, wrong):\n",
" # [TODO : 학생 실습 2] 장병 응답 텍스트/선택지 정제 및 시험 답안 파싱 전처리 모듈 구현 관련 핵심 로직을 구현하세요.\n",
" # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n",
" # <<< 여기에 코드를 작성하세요 >>>\n",
" pass # 작성 후 pass를 제거하세요.\n",
"def _order(qid, area, diff, stem):\n",
" return {\"qid\": qid, \"type\": \"ORDER\", \"area\": area, \"diff\": diff, \"stem\": stem, \"answer\": \"ABCD\"}\n",
"\n",
"\n",
"BANK = [\n",
" _mc(\"Q01\", \"형상식별\", 1, \"꼬리날개 없이 삼각형 날개를 가진 소형 고정익 실루엣은?\", [\"쿼드콥터형\", \"삼각익 고정익형\", \"회전익 헬기형\", \"조류\"], 2, [C, C, F]),\n",
" _mc(\"Q02\", \"형상식별\", 1, \"회전날개 4개가 십자로 배치된 실루엣은?\", [\"고정익 드론형\", \"쿼드콥터형\", \"순항미사일형\", \"탄도미사일형\"], 2, [C, C, C]),\n",
" _mc(\"Q03\", \"형상식별\", 2, \"가늘고 긴 동체 중간에 작은 날개가 붙은 실루엣은?\", [\"순항미사일형\", \"탄도미사일형\", \"민간 경비행기형\", \"조류\"], 1, [C, F, F]),\n",
" _mc(\"Q04\", \"형상식별\", 3, \"원거리라 실루엣 식별이 어려울 때 먼저 할 행동은?\", [\"추정 유형으로 확정 보고\", \"관측 정보를 그대로 보고하고 추가 관측\", \"관측 중단\", \"민간기로 간주\"], 2, [P, P, F]),\n",
" _mc(\"Q05\", \"피아식별\", 2, \"식별 신호 응답이 없는 항공기에 대한 올바른 판단은?\", [\"즉시 적기로 확정\", \"미확인 항공기로 분류하고 추가 확인\", \"아군기로 간주\", \"무시\"], 2, [F, F, P]),\n",
" _mc(\"Q06\", \"피아식별\", 2, \"운항 정보와 일치하는 저속 비행체의 분류는?\", [\"적 드론\", \"민간기 가능성 높음, 추가 확인\", \"순항미사일\", \"아군 정찰기로 확정\"], 2, [F, C, F]),\n",
" _mc(\"Q07\", \"피아식별\", 3, \"아군 드론 운용 공지가 있는 공역에서 미확인 드론을 보면 우선 확인할 것은?\", [\"기상 정보\", \"아군 운용 계획과 식별 신호\", \"대응 준비 상태\", \"주변 지형\"], 2, [C, P, C]),\n",
" _mc(\"Q08\", \"피아식별\", 3, \"조류 무리와 소형 드론 군집 구분에 가장 도움이 되는 정보는?\", [\"크기만 비교\", \"궤적의 규칙성과 열 신호\", \"색상\", \"관측 시각\"], 2, [C, C, C]),\n",
" _mc(\"Q09\", \"경보보고\", 1, \"공중 위협 최초 보고에 포함하지 않아도 되는 것은?\", [\"관측 시각\", \"방향과 거리\", \"추정 유형\", \"관측자 개인 연락처\"], 4, [P, P, P]),\n",
" _mc(\"Q10\", \"경보보고\", 2, \"경보 전파 후 상황이 바뀌면?\", [\"최초 보고 유지\", \"변경 사항을 즉시 추가 보고\", \"상황 종료 후 일괄 보고\", \"개인 판단으로 경보 해제\"], 2, [P, P, P]),\n",
" _mc(\"Q11\", \"경보보고\", 2, \"보고 내용이 불확실할 때 올바른 표현은?\", [\"확정 표현으로 보고\", \"'추정'임을 밝히고 근거와 함께 보고\", \"보고 보류\", \"다른 관측자 보고를 복사\"], 2, [C, P, P]),\n",
" _mc(\"Q12\", \"경보보고\", 1, \"드론 탐지 후 보고 경로로 알맞은 것은?\", [\"인접 부대에만 전파\", \"지휘계통을 따라 보고\", \"개인 메신저로 공유\", \"보고 없이 대응\"], 2, [P, P, P]),\n",
" _mc(\"Q13\", \"교전통제\", 1, \"AI 탐지 결과만으로 대응을 결정할 수 있는가?\", [\"가능하다\", \"인간 지휘관의 승인이 필요하다\", \"신뢰도 90% 이상이면 가능\", \"야간에만 가능\"], 2, [C, C, C]),\n",
" _mc(\"Q14\", \"교전통제\", 2, \"민간 피해 가능성이 확인되면?\", [\"그대로 진행\", \"즉시 보고하고 지휘관 판단을 받는다\", \"조치 후 보고\", \"무시\"], 2, [P, P, F]),\n",
" _mc(\"Q15\", \"교전통제\", 3, \"식별이 불확실한 표적에 대한 원칙은?\", [\"위협으로 간주해 즉시 대응\", \"식별 확인 전까지 승인 절차를 따른다\", \"무조건 무시\", \"다수결로 결정\"], 2, [F, F, C]),\n",
" _mc(\"Q16\", \"교전통제\", 2, \"판단 근거를 기록해야 하는 이유는?\", [\"형식상 필요\", \"사후 검토와 책임 추적\", \"보고 생략\", \"기록 불필요\"], 2, [C, P, P]),\n",
" _mc(\"Q17\", \"대드론일반\", 1, \"대드론 체계의 일반적 처리 순서는?\", [\"대응-탐지-식별\", \"탐지-식별-보고·판단-대응\", \"식별-대응-탐지\", \"보고-탐지-대응\"], 2, [P, P, P]),\n",
" _mc(\"Q18\", \"대드론일반\", 3, \"레이더만으로 조류와 드론 구분이 어려운 이유는?\", [\"반사 신호 크기가 비슷할 수 있어서\", \"레이더는 야간에 못 써서\", \"드론에는 금속이 없어서\", \"조류는 탐지되지 않아서\"], 1, [C, C, C]),\n",
" _mc(\"Q19\", \"대드론일반\", 2, \"전자광학(EO) 센서의 약점이 커지는 조건은?\", [\"맑은 주간\", \"안개·야간\", \"저속 표적\", \"근거리\"], 2, [C, C, C]),\n",
" _mc(\"Q20\", \"대드론일반\", 2, \"경보가 반복해서 오경보로 판명되면?\", [\"경보 장치를 끈다\", \"사례를 기록·보고하고 기준 재검토 요청\", \"무시한다\", \"모두 실제 위협으로 간주\"], 2, [P, P, F]),\n",
" _sa(\"Q21\", \"피아식별\", 1, \"아군·적·민간을 구분하는 절차를 무엇이라 하는가?\", [\"피아식별\", \"IFF\"], {\"표적획득\": C, \"적기판정\": F, \"경계\": C}),\n",
" _sa(\"Q22\", \"형상식별\", 2, \"꼬리날개 없는 삼각형 날개 형태를 무엇이라 하는가?\", [\"삼각익\", \"델타익\", \"delta\"], {\"직선익\": C, \"후퇴익\": C, \"쿼드콥터\": C}),\n",
" _sa(\"Q23\", \"경보보고\", 2, \"불확실한 관측을 보고할 때 붙이는 표현은?\", [\"추정\"], {\"확정\": C, \"확인\": C, \"생략\": P}),\n",
" _sa(\"Q24\", \"교전통제\", 1, \"AI 탐지 결과에 대해 최종 결정을 내리는 주체는?\", [\"지휘관\"], {\"AI\": C, \"운용병\": P, \"레이더\": C}),\n",
" _sa(\"Q25\", \"대드론일반\", 1, \"조류를 드론으로 잘못 경보하는 것을 무엇이라 하는가?\", [\"오경보\", \"오탐\", \"false alarm\"], {\"미탐\": C, \"재경보\": C, \"적기\": F}),\n",
" _sa(\"Q26\", \"피아식별\", 2, \"운항 정보가 확인된 여객기는 어떻게 분류하는가?\", [\"민간기\", \"민항기\"], {\"적기\": F, \"미확인기\": P, \"아군기\": F}),\n",
" _sa(\"Q27\", \"형상식별\", 1, \"회전날개 4개를 가진 드론 형태는?\", [\"쿼드콥터\", \"quadcopter\"], {\"헥사콥터\": C, \"고정익\": C, \"헬기\": F}),\n",
" _sa(\"Q28\", \"경보보고\", 1, \"보고는 무엇을 따라 올라가는가?\", [\"지휘계통\"], {\"인접부대\": P, \"개인메신저\": P, \"통신망\": C}),\n",
" _order(\"Q29\", \"경보보고\", 2, \"최초 보고 순서: A 관측 시각, B 방향·거리, C 추정 유형, D 관측자 조치\"),\n",
" _order(\"Q30\", \"대드론일반\", 2, \"처리 순서: A 탐지, B 식별, C 보고·판단, D 대응\"),\n",
" _order(\"Q31\", \"교전통제\", 3, \"통제 순서: A 식별 확인, B 상급 승인 요청, C 승인 수령, D 조치 기록\"),\n",
" _order(\"Q32\", \"피아식별\", 3, \"확인 순서: A 운항정보 대조, B 식별 신호 확인, C 미확인 분류, D 보고\"),\n",
"]\n",
"QIDX = {q[\"qid\"]: q for q in BANK}\n",
"UNITS = [\"가상1중대\", \"가상2중대\", \"가상3중대\"]\n",
"_CIRC = \"①②③④\"\n",
"PARA = {\"Q21\": \"아군적군 구분\", \"Q22\": \"세모 날개\", \"Q23\": \"추정됨으로 표기\", \"Q24\": \"중대장(지휘관)\", \"Q25\": \"잘못된 경보\",\n",
" \"Q26\": \"민간 여객기\", \"Q27\": \"4발 드론\", \"Q28\": \"지휘 라인\"} # 뜻은 맞지만 인정 답안 목록에 없는 표현\n",
"\n",
"\n",
"def make_soldiers(n_per_unit, seed):\n",
" rng = np.random.default_rng(seed)\n",
" out = []\n",
" for u, unit in enumerate(UNITS):\n",
" base = [0.3, -0.1, 0.0][u]\n",
" for i in range(n_per_unit):\n",
" off = rng.normal(0, 0.5, len(AREAS)) + (np.array([0, 0, 0, -0.6, 0]) if u == 1 else 0)\n",
" out.append({\"sid\": f\"S{len(out) + 1:03d}\", \"unit\": unit, \"theta\": dict(zip(AREAS, base + rng.normal(0, 0.6) + off)),\n",
" \"tend\": dict(zip(ETYPES, rng.dirichlet([1.2, 1.2, 1.2])))})\n",
" return out\n",
"\n",
"\n",
"def p_correct(theta, diff):\n",
" return 1 / (1 + np.exp(-1.7 * (theta - (diff - 2) * 0.9 + 0.9)))\n",
"\n",
"\n",
"def _typo(s, rng):\n",
" if len(s) < 2:\n",
" return s\n",
" i = int(rng.integers(len(s) - 1))\n",
" 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:]\n",
"\n",
"\n",
"def _fmt_mc(k, rng):\n",
" r = rng.random()\n",
" if r < 0.55:\n",
" return str(k)\n",
" return rng.choice([_CIRC[k - 1], f\"{k}번\", \"ABCD\"[k - 1], \"abcd\"[k - 1] + \")\", f\" {k} \", chr(0xFF10 + k), f\"{k}.\"])\n",
"\n",
"\n",
"def _fmt_sa(s, rng):\n",
" r = rng.random()\n",
" if r < 0.45:\n",
" return s\n",
" if r < 0.6:\n",
" return \" \".join(s) if len(s) <= 4 else s[:2] + \" \" + s[2:]\n",
" if r < 0.72:\n",
" return s + rng.choice([\"입니다\", \"이다\", \".\", \"임\"])\n",
" if r < 0.8:\n",
" return s.upper() if s.isascii() else \"\".join(chr(ord(ch) + 0xFEE0) if \"!\" <= ch <= \"~\" else ch for ch in s) + \" \"\n",
" return _typo(s, rng)\n",
"\n",
"\n",
"def answer(q, sol, rng):\n",
" \"\"\"한 문항 응답: (원본 응답 문자열, 의도한 정답 여부, 의도한 답, 오답 유형).\"\"\"\n",
" ok = rng.random() < p_correct(sol[\"theta\"][q[\"area\"]], q[\"diff\"])\n",
" if q[\"type\"] == \"MC\":\n",
" if ok:\n",
" k, et = q[\"answer\"], None\n",
" else:\n",
" ks = list(q[\"dist\"]); w = np.array([sol[\"tend\"][q[\"dist\"][k]] for k in ks])\n",
" k = int(rng.choice(ks, p=w / w.sum())); et = q[\"dist\"][k]\n",
" return _fmt_mc(k, rng), ok, str(k), et\n",
" if q[\"type\"] == \"SA\":\n",
" if ok and rng.random() < 0.06:\n",
" return PARA[q[\"qid\"]], ok, PARA[q[\"qid\"]], None\n",
" if ok:\n",
" s, et = str(rng.choice(q[\"accept\"])), None\n",
" else:\n",
" ws = list(q[\"wrong\"]); w = np.array([sol[\"tend\"][q[\"wrong\"][x]] for x in ws])\n",
" s = str(rng.choice(ws, p=w / w.sum())); et = q[\"wrong\"][s]\n",
" return _fmt_sa(s, rng), ok, s, et\n",
" if ok:\n",
" s = \"ABCD\"\n",
" elif rng.random() < 0.5:\n",
" s = \"ABCD\".replace(\"ABCD\"[rng.integers(1, 4)], \"\") # 단계 하나 빠뜨림\n",
" else:\n",
" i = int(rng.integers(0, 3)); s = list(\"ABCD\"); s[i], s[i + 1] = s[i + 1], s[i]; s = \"\".join(s) # 순서 뒤바뀜\n",
" raw = s if rng.random() < 0.6 else rng.choice([\"-\".join(s), \" \".join(s), s.lower(), \",\".join(s)])\n",
" return raw, ok, s, (None if ok else P)\n",
"\n",
"\n",
"def make_responses(n_per_unit=40, seed=7, blank=0.02):\n",
" \"\"\"응시자 x 문항 응답표(DataFrame)를 만든다. truth_* 열은 교관 기준 정답(평가용)이다.\"\"\"\n",
" rng = np.random.default_rng(seed)\n",
" rows = []\n",
" for sol in make_soldiers(n_per_unit, seed):\n",
" for q in BANK:\n",
" raw, ok, intended, et = answer(q, sol, rng)\n",
" if rng.random() < blank:\n",
" raw, ok, intended, et = \"\", False, \"\", None\n",
" rows.append({\"rid\": f\"R{len(rows):05d}\", \"sid\": sol[\"sid\"], \"unit\": sol[\"unit\"], \"qid\": q[\"qid\"], \"raw\": raw,\n",
" \"truth_correct\": ok, \"truth_answer\": intended, \"truth_etype\": et})\n",
" return pd.DataFrame(rows)\n",
"# ---------------------------------------------------------------------------\n",
"\n",
"# ---- 응답 정제·파싱·채점 공통 함수 ----\n",
"TAILS = (\"입니다\", \"이다\", \"임\")\n",
"\n",
"\n",
"def norm_text(s):\n",
" \"\"\"전각->반각(NFKC), 소문자, 공백·문장부호 제거, 끝맺음 어미 제거.\"\"\"\n",
" s = unicodedata.normalize(\"NFKC\", str(s)).strip().lower()\n",
" s = re.sub(r\"[\\s\\.\\,\\-\\(\\)·]\", \"\", s)\n",
" for t in TAILS:\n",
" if s.endswith(t) and len(s) > len(t) + 1:\n",
" s = s[:-len(t)]\n",
" return s\n",
"\n",
"\n",
"def parse(q, raw):\n",
" \"\"\"원본 응답 -> (정제값, 상태). 상태: 정상 / 미응답 / 형식오류\"\"\"\n",
" if raw is None or str(raw).strip() == \"\":\n",
" return \"\", \"미응답\"\n",
" s = unicodedata.normalize(\"NFKC\", str(raw)).strip()\n",
" if q[\"type\"] == \"MC\":\n",
" t = re.sub(r\"[\\s번\\.\\)]\", \"\", s).lower()\n",
" t = {\"a\": \"1\", \"b\": \"2\", \"c\": \"3\", \"d\": \"4\"}.get(t, t)\n",
" return (t, \"정상\") if t in (\"1\", \"2\", \"3\", \"4\") else (t, \"형식오류\")\n",
" if q[\"type\"] == \"ORDER\":\n",
" t = re.sub(r\"[^A-Za-z]\", \"\", s).upper()\n",
" return (t, \"정상\") if t and set(t) <= set(\"ABCD\") and len(set(t)) == len(t) else (t, \"형식오류\")\n",
" return norm_text(s), \"정상\"\n",
"\n",
"\n",
"def grade_exact(q, val):\n",
" if q[\"type\"] == \"MC\":\n",
" return val == str(q[\"answer\"])\n",
" if q[\"type\"] == \"ORDER\":\n",
" return val == q[\"answer\"]\n",
" return val in {norm_text(a) for a in q[\"accept\"]}\n",
"\n",
"\n",
"def grade_fuzzy(q, val, cut=0.75):\n",
" \"\"\"단답형은 오탈자(글자 순서 바뀜·한 글자 누락)를 허용(유사도 cut 이상)하되, 알려진 오답과 더 가까우면 오답으로 본다.\"\"\"\n",
" if grade_exact(q, val) or q[\"type\"] != \"SA\" or len(val) < 2:\n",
" return grade_exact(q, val)\n",
" 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\"])\n",
" worst = max(difflib.SequenceMatcher(None, val, norm_text(w)).ratio() for w in q[\"wrong\"])\n",
" return best >= cut and best > worst\n",
"\n",
"\n",
"def score_of(q, correct):\n",
" return POINTS[q[\"diff\"]] if correct else 0\n",
"\n",
"DUP_WINDOW_S = {\"정상\": 5, \"지연\": 30} # 통신 상태별 재전송 판단 시간(동적 임계값)\n",
"\n",
"\n",
"def inject_noise(df, seed=1):\n",
" \"\"\"[교육용가상자료] 재전송 중복·형식 오류·제출 시각·통신 상태를 덧붙인다.\"\"\"\n",
" rng = np.random.default_rng(seed)\n",
" df = df.copy()\n",
" df[\"comm\"] = np.where(df[\"unit\"] == \"가상2중대\", \"지연\", \"정상\")\n",
" df[\"ts\"] = np.arange(len(df)) * 3.0\n",
" bad = rng.choice(len(df), 20, replace=False)\n",
" df.loc[bad, \"raw\"] = rng.choice([\"7\", \"모름\", \"??\"], 20)\n",
" df.loc[bad, \"truth_correct\"] = False\n",
" dup = df.sample(frac=0.03, random_state=seed).copy()\n",
" dup[\"ts\"] = dup[\"ts\"] + np.where(dup[\"comm\"] == \"지연\", 20.0, 2.0)\n",
" dup[\"rid\"] = dup[\"rid\"] + \"D\"\n",
" return pd.concat([df, dup]).sort_values(\"ts\").reset_index(drop=True)\n",
"\n",
"\n",
"def preprocess(df):\n",
" \"\"\"중복 제거 -> 파싱 -> 상태 분류. 미응답은 삭제하지 않고 상태로 남긴다(결측 보정).\"\"\"\n",
" df = df.sort_values(\"ts\")\n",
" win = df[\"comm\"].map(DUP_WINDOW_S)\n",
" prev_ts = df.groupby([\"sid\", \"qid\"])[\"ts\"].shift(1)\n",
" df = df.assign(dup=(df[\"ts\"] - prev_ts) <= win)\n",
" keep = df[~df.groupby([\"sid\", \"qid\"])[\"dup\"].shift(-1, fill_value=False)] # 재전송이면 마지막 제출만 유지\n",
" pv = [parse(QIDX[q], r) for q, r in zip(keep[\"qid\"], keep[\"raw\"])]\n",
" return keep.assign(val=[p[0] for p in pv], status=[p[1] for p in pv]).drop(columns=\"dup\"), len(df) - len(keep)\n",
"\n",
"\n",
"data = inject_noise(make_responses(40, seed=7))\n",
"preprocess(data.head(100))\n",
"t0 = time.perf_counter()\n",
"clean, n_dup = preprocess(data)\n",
"dt = time.perf_counter() - t0\n",
"print(f\"응답 {len(data):,}건 전처리 {dt * 1000:.0f} ms, {len(data) / dt:,.0f}건/초 (목표 1,000건/초 이상: {'달성' if len(data) / dt >= 1000 else '미달'})\")\n",
"print(f\"재전송 중복 제거 {n_dup}건, 남은 응답 {len(clean):,}건(응시자 120명 x 32문항 = 3,840건과 일치: {len(clean) == 3840})\")\n",
"print(\"상태 분포:\", clean[\"status\"].value_counts().to_dict())\n",
"err = clean[(clean[\"status\"] == \"정상\") & clean.apply(lambda r: QIDX[r.qid][\"type\"] == \"MC\" and r.val not in \"1234\", axis=1)]\n",
"print(f\"전처리 오류(정상인데 파싱 실패) {len(err)}건, 오류율 {len(err) / len(clean):.1%}\")\n",
"\n",
"# 단위 테스트 5종\n",
"Q = QIDX\n",
"tests = {\n",
" \"T1 전각 숫자 '3' -> 3\": parse(Q[\"Q01\"], \"3\")[0] == \"3\",\n",
" \"T2 원문자·영문·'번' 표기 통일\": [parse(Q[\"Q01\"], s)[0] for s in [\"③\", \"c)\", \"3번\", \" 3 \"]] == [\"3\"] * 4,\n",
" \"T3 단답 공백·어미 제거\": parse(Q[\"Q24\"], \"지 휘 관 입니다\")[0] == \"지휘관\" and parse(Q[\"Q21\"], \"IFF\")[0] == \"iff\",\n",
" \"T4 순서형 구분자 제거\": parse(Q[\"Q30\"], \"a-b-c-d\") == (\"ABCD\", \"정상\") and parse(Q[\"Q30\"], \"A,B,B,D\")[1] == \"형식오류\",\n",
" \"T5 미응답 보존(결측 보정율 100%)\": (clean[\"status\"] == \"미응답\").sum() == (data.drop_duplicates([\"sid\", \"qid\"])[\"raw\"] == \"\").sum(),\n",
"}\n",
"for k, v in tests.items():\n",
" print(f\"[{'PASS' if v else 'FAIL'}] {k}\")\n",
"print(f\"단위 테스트 {sum(tests.values())}/5 통과\")\n",
"\n",
"print(\"\\n[전처리 전후 예시]\")\n",
"ex = clean[(clean[\"raw\"] != clean[\"val\"]) & clean[\"qid\"].isin([\"Q05\", \"Q13\", \"Q21\", \"Q22\", \"Q24\", \"Q25\", \"Q29\", \"Q31\"])].groupby(\"qid\").head(1)\n",
"print(ex[[\"qid\", \"raw\", \"val\", \"status\"]].rename(columns={\"raw\": \"원본\", \"val\": \"정제값\", \"status\": \"상태\"}).to_string(index=False))\n",
"print(\"통신 지연 중대는 재전송 판단 시간을 30초로 넓혀 중복 제출을 놓치지 않는다.\")\n",
"\n",
"st = clean.groupby([\"unit\", \"status\"]).size().unstack(fill_value=0)\n",
"st.index = [\"Co.1\", \"Co.2 (delayed comm)\", \"Co.3\"]\n",
"st.columns = st.columns.map({\"정상\": \"valid\", \"미응답\": \"blank\", \"형식오류\": \"format error\"})\n",
"st.plot(kind=\"bar\", stacked=True, figsize=(7, 4))\n",
"plt.title(\"Response status after preprocessing (synthetic)\"); plt.ylabel(\"responses\"); plt.xticks(rotation=0)\n",
"plt.tight_layout(); plt.show()\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"<details>\n",
"<summary><b>💡 [참고] 미션 02 예상 실행 결과 (Output)</b></summary>\n",
"\n",
"```text\n",
"응답 3,955건 전처리 18 ms, 220,239건/초 (목표 1,000건/초 이상: 달성)\n",
"재전송 중복 제거 115건, 남은 응답 3,840건(응시자 120명 x 32문항 = 3,840건과 일치: True)\n",
"상태 분포: {'정상': 3753, '미응답': 71, '형식오류': 16}\n",
"전처리 오류(정상인데 파싱 실패) 0건, 오류율 0.0%\n",
"[PASS] T1 전각 숫자 '3' -> 3\n",
"[PASS] T2 원문자·영문·'번' 표기 통일\n",
"[PASS] T3 단답 공백·어미 제거\n",
"[PASS] T4 순서형 구분자 제거\n",
"[PASS] T5 미응답 보존(결측 보정율 100%)\n",
"단위 테스트 5/5 통과\n",
"\n",
"[전처리 전후 예시]\n",
"qid 원본 정제값 상태\n",
"Q05 b) 2 정상\n",
"Q13 3 3 정상\n",
"Q21 피아식별임 피아식별 정상\n",
"Q25 잘못된 경보 잘못된경보 정상\n",
"Q31 A C D ACD 정상\n",
"Q22 de lta delta 정상\n",
"Q24 지 휘 관 지휘관 정상\n",
"Q29 A,B,C,D ABCD 정상\n",
"통신 지연 중대는 재전송 판단 시간을 30초로 넓혀 중복 제출을 놓치지 않는다.\n",
"```\n",
"</details>\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"---\n",
"\n",
"## 🎯 미션 03. 선다형 및 단답형 방공 퀴즈 자동 채점 Baseline 로직 구현\n",
"**소요시간**: 약 2.5시간\n",
"\n",
"### 📋 미션 배경 및 수행 지침\n",
"#### 미션 배경 및 당위성\n",
"\n",
"선다형 및 단답형 방공 퀴즈 자동 채점 Baseline 로직을 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **선다형·단답형·순서형 퀴즈를 자동 채점하는 Baseline을 구현하고 교관 기준 판정과 비교해 정확도를 확인한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
"\n",
"이 문제를 해결하기 위해 본 미션에서는 **정확 일치 채점과 오탈자(글자 순서 바뀜·한 글자 누락)를 허용하는 유사도 채점을 구현하고, 유사도 기준값을 바꿔 가며 단답형 채점 정확도를 비교 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n",
"\n",
"학습자는 실습가이드에 제시된 총 5개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
"\n",
"**이번 결과물:** `src/models/trn02_baseline_model.py 및 기본 추론 결과 보고서` 형태의 결과물을 완성한다.\n",
"\n",
"### 🔍 평가 및 검증 기준 (PASS 조건)\n",
"#### 미션 판정\n",
"\n",
"**평가 구분:** 필수\n",
"\n",
"**핵심 평가영역:** 알고리즘의 논리적 흐름 및 군사적 판단 기준의 투명성\n",
"\n",
"#### 미션 PASS 조건\n",
"\n",
"- 정량 기준: 기본 데이터셋 대상 탐지/처리 정확도 75% 이상 달성\n",
"\n",
"\n",
"- 진행 기준: 단위 기능 동작 확인 및 기본 시나리오 실행 로그 생성\n",
"\n",
"\n",
"- 검증 방법: 기준 벤치마크 데이터셋 대상 추론 결과 오차율 비교\n",
"\n",
"\n",
"#### 채점자 판정 메모\n",
"\n",
"> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n",
"\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"\"\"\"선다형·단답형·순서형 방공 퀴즈를 자동 채점하는 Baseline을 구현한다. 정확 일치 채점과 오탈자 허용 채점의 정확도(교관 기준 대비)·처리 지연을 비교하고, 응시자별 점수와 합격 여부, 실행 로그를 출력한다.\"\"\"\n",
"import difflib\n",
"import re\n",
"import time\n",
"import unicodedata\n",
"import numpy as np\n",
"import pandas as pd\n",
"import matplotlib.pyplot as plt\n",
"\n",
"# ---------------------------------------------------------------------------\n",
"# [교육용가상자료] 방공·드론 식별 퀴즈 문항 은행과 가상 응시자 응답 생성기\n",
"# 문항은 일반 원칙 수준의 가상 문항이며 실제 교범·교전규칙 원문이 아니다. 응시자는 가명(S001~)이다.\n",
"# ---------------------------------------------------------------------------\n",
"AREAS = [\"형상식별\", \"피아식별\", \"경보보고\", \"교전통제\", \"대드론일반\"]\n",
"ETYPES = [\"개념 혼동\", \"피아 오인\", \"절차 누락\"]\n",
"POINTS = {1: 2, 2: 3, 3: 5}\n",
"C, F, P = ETYPES\n",
"\n",
"\n",
"def _mc(qid, area, diff, stem, opts, ans, dist):\n",
" # [TODO : 학생 실습 3] 선다형 및 단답형 방공 퀴즈 자동 채점 Baseline 로직 구현 관련 핵심 로직을 구현하세요.\n",
" # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n",
" # <<< 여기에 코드를 작성하세요 >>>\n",
" pass # 작성 후 pass를 제거하세요.\n",
"def _sa(qid, area, diff, stem, accept, wrong):\n",
" # [TODO : 학생 실습 3] 선다형 및 단답형 방공 퀴즈 자동 채점 Baseline 로직 구현 관련 핵심 로직을 구현하세요.\n",
" # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n",
" # <<< 여기에 코드를 작성하세요 >>>\n",
" pass # 작성 후 pass를 제거하세요.\n",
"def _order(qid, area, diff, stem):\n",
" return {\"qid\": qid, \"type\": \"ORDER\", \"area\": area, \"diff\": diff, \"stem\": stem, \"answer\": \"ABCD\"}\n",
"\n",
"\n",
"BANK = [\n",
" _mc(\"Q01\", \"형상식별\", 1, \"꼬리날개 없이 삼각형 날개를 가진 소형 고정익 실루엣은?\", [\"쿼드콥터형\", \"삼각익 고정익형\", \"회전익 헬기형\", \"조류\"], 2, [C, C, F]),\n",
" _mc(\"Q02\", \"형상식별\", 1, \"회전날개 4개가 십자로 배치된 실루엣은?\", [\"고정익 드론형\", \"쿼드콥터형\", \"순항미사일형\", \"탄도미사일형\"], 2, [C, C, C]),\n",
" _mc(\"Q03\", \"형상식별\", 2, \"가늘고 긴 동체 중간에 작은 날개가 붙은 실루엣은?\", [\"순항미사일형\", \"탄도미사일형\", \"민간 경비행기형\", \"조류\"], 1, [C, F, F]),\n",
" _mc(\"Q04\", \"형상식별\", 3, \"원거리라 실루엣 식별이 어려울 때 먼저 할 행동은?\", [\"추정 유형으로 확정 보고\", \"관측 정보를 그대로 보고하고 추가 관측\", \"관측 중단\", \"민간기로 간주\"], 2, [P, P, F]),\n",
" _mc(\"Q05\", \"피아식별\", 2, \"식별 신호 응답이 없는 항공기에 대한 올바른 판단은?\", [\"즉시 적기로 확정\", \"미확인 항공기로 분류하고 추가 확인\", \"아군기로 간주\", \"무시\"], 2, [F, F, P]),\n",
" _mc(\"Q06\", \"피아식별\", 2, \"운항 정보와 일치하는 저속 비행체의 분류는?\", [\"적 드론\", \"민간기 가능성 높음, 추가 확인\", \"순항미사일\", \"아군 정찰기로 확정\"], 2, [F, C, F]),\n",
" _mc(\"Q07\", \"피아식별\", 3, \"아군 드론 운용 공지가 있는 공역에서 미확인 드론을 보면 우선 확인할 것은?\", [\"기상 정보\", \"아군 운용 계획과 식별 신호\", \"대응 준비 상태\", \"주변 지형\"], 2, [C, P, C]),\n",
" _mc(\"Q08\", \"피아식별\", 3, \"조류 무리와 소형 드론 군집 구분에 가장 도움이 되는 정보는?\", [\"크기만 비교\", \"궤적의 규칙성과 열 신호\", \"색상\", \"관측 시각\"], 2, [C, C, C]),\n",
" _mc(\"Q09\", \"경보보고\", 1, \"공중 위협 최초 보고에 포함하지 않아도 되는 것은?\", [\"관측 시각\", \"방향과 거리\", \"추정 유형\", \"관측자 개인 연락처\"], 4, [P, P, P]),\n",
" _mc(\"Q10\", \"경보보고\", 2, \"경보 전파 후 상황이 바뀌면?\", [\"최초 보고 유지\", \"변경 사항을 즉시 추가 보고\", \"상황 종료 후 일괄 보고\", \"개인 판단으로 경보 해제\"], 2, [P, P, P]),\n",
" _mc(\"Q11\", \"경보보고\", 2, \"보고 내용이 불확실할 때 올바른 표현은?\", [\"확정 표현으로 보고\", \"'추정'임을 밝히고 근거와 함께 보고\", \"보고 보류\", \"다른 관측자 보고를 복사\"], 2, [C, P, P]),\n",
" _mc(\"Q12\", \"경보보고\", 1, \"드론 탐지 후 보고 경로로 알맞은 것은?\", [\"인접 부대에만 전파\", \"지휘계통을 따라 보고\", \"개인 메신저로 공유\", \"보고 없이 대응\"], 2, [P, P, P]),\n",
" _mc(\"Q13\", \"교전통제\", 1, \"AI 탐지 결과만으로 대응을 결정할 수 있는가?\", [\"가능하다\", \"인간 지휘관의 승인이 필요하다\", \"신뢰도 90% 이상이면 가능\", \"야간에만 가능\"], 2, [C, C, C]),\n",
" _mc(\"Q14\", \"교전통제\", 2, \"민간 피해 가능성이 확인되면?\", [\"그대로 진행\", \"즉시 보고하고 지휘관 판단을 받는다\", \"조치 후 보고\", \"무시\"], 2, [P, P, F]),\n",
" _mc(\"Q15\", \"교전통제\", 3, \"식별이 불확실한 표적에 대한 원칙은?\", [\"위협으로 간주해 즉시 대응\", \"식별 확인 전까지 승인 절차를 따른다\", \"무조건 무시\", \"다수결로 결정\"], 2, [F, F, C]),\n",
" _mc(\"Q16\", \"교전통제\", 2, \"판단 근거를 기록해야 하는 이유는?\", [\"형식상 필요\", \"사후 검토와 책임 추적\", \"보고 생략\", \"기록 불필요\"], 2, [C, P, P]),\n",
" _mc(\"Q17\", \"대드론일반\", 1, \"대드론 체계의 일반적 처리 순서는?\", [\"대응-탐지-식별\", \"탐지-식별-보고·판단-대응\", \"식별-대응-탐지\", \"보고-탐지-대응\"], 2, [P, P, P]),\n",
" _mc(\"Q18\", \"대드론일반\", 3, \"레이더만으로 조류와 드론 구분이 어려운 이유는?\", [\"반사 신호 크기가 비슷할 수 있어서\", \"레이더는 야간에 못 써서\", \"드론에는 금속이 없어서\", \"조류는 탐지되지 않아서\"], 1, [C, C, C]),\n",
" _mc(\"Q19\", \"대드론일반\", 2, \"전자광학(EO) 센서의 약점이 커지는 조건은?\", [\"맑은 주간\", \"안개·야간\", \"저속 표적\", \"근거리\"], 2, [C, C, C]),\n",
" _mc(\"Q20\", \"대드론일반\", 2, \"경보가 반복해서 오경보로 판명되면?\", [\"경보 장치를 끈다\", \"사례를 기록·보고하고 기준 재검토 요청\", \"무시한다\", \"모두 실제 위협으로 간주\"], 2, [P, P, F]),\n",
" _sa(\"Q21\", \"피아식별\", 1, \"아군·적·민간을 구분하는 절차를 무엇이라 하는가?\", [\"피아식별\", \"IFF\"], {\"표적획득\": C, \"적기판정\": F, \"경계\": C}),\n",
" _sa(\"Q22\", \"형상식별\", 2, \"꼬리날개 없는 삼각형 날개 형태를 무엇이라 하는가?\", [\"삼각익\", \"델타익\", \"delta\"], {\"직선익\": C, \"후퇴익\": C, \"쿼드콥터\": C}),\n",
" _sa(\"Q23\", \"경보보고\", 2, \"불확실한 관측을 보고할 때 붙이는 표현은?\", [\"추정\"], {\"확정\": C, \"확인\": C, \"생략\": P}),\n",
" _sa(\"Q24\", \"교전통제\", 1, \"AI 탐지 결과에 대해 최종 결정을 내리는 주체는?\", [\"지휘관\"], {\"AI\": C, \"운용병\": P, \"레이더\": C}),\n",
" _sa(\"Q25\", \"대드론일반\", 1, \"조류를 드론으로 잘못 경보하는 것을 무엇이라 하는가?\", [\"오경보\", \"오탐\", \"false alarm\"], {\"미탐\": C, \"재경보\": C, \"적기\": F}),\n",
" _sa(\"Q26\", \"피아식별\", 2, \"운항 정보가 확인된 여객기는 어떻게 분류하는가?\", [\"민간기\", \"민항기\"], {\"적기\": F, \"미확인기\": P, \"아군기\": F}),\n",
" _sa(\"Q27\", \"형상식별\", 1, \"회전날개 4개를 가진 드론 형태는?\", [\"쿼드콥터\", \"quadcopter\"], {\"헥사콥터\": C, \"고정익\": C, \"헬기\": F}),\n",
" _sa(\"Q28\", \"경보보고\", 1, \"보고는 무엇을 따라 올라가는가?\", [\"지휘계통\"], {\"인접부대\": P, \"개인메신저\": P, \"통신망\": C}),\n",
" _order(\"Q29\", \"경보보고\", 2, \"최초 보고 순서: A 관측 시각, B 방향·거리, C 추정 유형, D 관측자 조치\"),\n",
" _order(\"Q30\", \"대드론일반\", 2, \"처리 순서: A 탐지, B 식별, C 보고·판단, D 대응\"),\n",
" _order(\"Q31\", \"교전통제\", 3, \"통제 순서: A 식별 확인, B 상급 승인 요청, C 승인 수령, D 조치 기록\"),\n",
" _order(\"Q32\", \"피아식별\", 3, \"확인 순서: A 운항정보 대조, B 식별 신호 확인, C 미확인 분류, D 보고\"),\n",
"]\n",
"QIDX = {q[\"qid\"]: q for q in BANK}\n",
"UNITS = [\"가상1중대\", \"가상2중대\", \"가상3중대\"]\n",
"_CIRC = \"①②③④\"\n",
"PARA = {\"Q21\": \"아군적군 구분\", \"Q22\": \"세모 날개\", \"Q23\": \"추정됨으로 표기\", \"Q24\": \"중대장(지휘관)\", \"Q25\": \"잘못된 경보\",\n",
" \"Q26\": \"민간 여객기\", \"Q27\": \"4발 드론\", \"Q28\": \"지휘 라인\"} # 뜻은 맞지만 인정 답안 목록에 없는 표현\n",
"\n",
"\n",
"def make_soldiers(n_per_unit, seed):\n",
" rng = np.random.default_rng(seed)\n",
" out = []\n",
" for u, unit in enumerate(UNITS):\n",
" base = [0.3, -0.1, 0.0][u]\n",
" for i in range(n_per_unit):\n",
" off = rng.normal(0, 0.5, len(AREAS)) + (np.array([0, 0, 0, -0.6, 0]) if u == 1 else 0)\n",
" out.append({\"sid\": f\"S{len(out) + 1:03d}\", \"unit\": unit, \"theta\": dict(zip(AREAS, base + rng.normal(0, 0.6) + off)),\n",
" \"tend\": dict(zip(ETYPES, rng.dirichlet([1.2, 1.2, 1.2])))})\n",
" return out\n",
"\n",
"\n",
"def p_correct(theta, diff):\n",
" return 1 / (1 + np.exp(-1.7 * (theta - (diff - 2) * 0.9 + 0.9)))\n",
"\n",
"\n",
"def _typo(s, rng):\n",
" if len(s) < 2:\n",
" return s\n",
" i = int(rng.integers(len(s) - 1))\n",
" 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:]\n",
"\n",
"\n",
"def _fmt_mc(k, rng):\n",
" r = rng.random()\n",
" if r < 0.55:\n",
" return str(k)\n",
" return rng.choice([_CIRC[k - 1], f\"{k}번\", \"ABCD\"[k - 1], \"abcd\"[k - 1] + \")\", f\" {k} \", chr(0xFF10 + k), f\"{k}.\"])\n",
"\n",
"\n",
"def _fmt_sa(s, rng):\n",
" r = rng.random()\n",
" if r < 0.45:\n",
" return s\n",
" if r < 0.6:\n",
" return \" \".join(s) if len(s) <= 4 else s[:2] + \" \" + s[2:]\n",
" if r < 0.72:\n",
" return s + rng.choice([\"입니다\", \"이다\", \".\", \"임\"])\n",
" if r < 0.8:\n",
" return s.upper() if s.isascii() else \"\".join(chr(ord(ch) + 0xFEE0) if \"!\" <= ch <= \"~\" else ch for ch in s) + \" \"\n",
" return _typo(s, rng)\n",
"\n",
"\n",
"def answer(q, sol, rng):\n",
" \"\"\"한 문항 응답: (원본 응답 문자열, 의도한 정답 여부, 의도한 답, 오답 유형).\"\"\"\n",
" ok = rng.random() < p_correct(sol[\"theta\"][q[\"area\"]], q[\"diff\"])\n",
" if q[\"type\"] == \"MC\":\n",
" if ok:\n",
" k, et = q[\"answer\"], None\n",
" else:\n",
" ks = list(q[\"dist\"]); w = np.array([sol[\"tend\"][q[\"dist\"][k]] for k in ks])\n",
" k = int(rng.choice(ks, p=w / w.sum())); et = q[\"dist\"][k]\n",
" return _fmt_mc(k, rng), ok, str(k), et\n",
" if q[\"type\"] == \"SA\":\n",
" if ok and rng.random() < 0.06:\n",
" return PARA[q[\"qid\"]], ok, PARA[q[\"qid\"]], None\n",
" if ok:\n",
" s, et = str(rng.choice(q[\"accept\"])), None\n",
" else:\n",
" ws = list(q[\"wrong\"]); w = np.array([sol[\"tend\"][q[\"wrong\"][x]] for x in ws])\n",
" s = str(rng.choice(ws, p=w / w.sum())); et = q[\"wrong\"][s]\n",
" return _fmt_sa(s, rng), ok, s, et\n",
" if ok:\n",
" s = \"ABCD\"\n",
" elif rng.random() < 0.5:\n",
" s = \"ABCD\".replace(\"ABCD\"[rng.integers(1, 4)], \"\") # 단계 하나 빠뜨림\n",
" else:\n",
" i = int(rng.integers(0, 3)); s = list(\"ABCD\"); s[i], s[i + 1] = s[i + 1], s[i]; s = \"\".join(s) # 순서 뒤바뀜\n",
" raw = s if rng.random() < 0.6 else rng.choice([\"-\".join(s), \" \".join(s), s.lower(), \",\".join(s)])\n",
" return raw, ok, s, (None if ok else P)\n",
"\n",
"\n",
"def make_responses(n_per_unit=40, seed=7, blank=0.02):\n",
" \"\"\"응시자 x 문항 응답표(DataFrame)를 만든다. truth_* 열은 교관 기준 정답(평가용)이다.\"\"\"\n",
" rng = np.random.default_rng(seed)\n",
" rows = []\n",
" for sol in make_soldiers(n_per_unit, seed):\n",
" for q in BANK:\n",
" raw, ok, intended, et = answer(q, sol, rng)\n",
" if rng.random() < blank:\n",
" raw, ok, intended, et = \"\", False, \"\", None\n",
" rows.append({\"rid\": f\"R{len(rows):05d}\", \"sid\": sol[\"sid\"], \"unit\": sol[\"unit\"], \"qid\": q[\"qid\"], \"raw\": raw,\n",
" \"truth_correct\": ok, \"truth_answer\": intended, \"truth_etype\": et})\n",
" return pd.DataFrame(rows)\n",
"# ---------------------------------------------------------------------------\n",
"\n",
"# ---- 응답 정제·파싱·채점 공통 함수 ----\n",
"TAILS = (\"입니다\", \"이다\", \"임\")\n",
"\n",
"\n",
"def norm_text(s):\n",
" \"\"\"전각->반각(NFKC), 소문자, 공백·문장부호 제거, 끝맺음 어미 제거.\"\"\"\n",
" s = unicodedata.normalize(\"NFKC\", str(s)).strip().lower()\n",
" s = re.sub(r\"[\\s\\.\\,\\-\\(\\)·]\", \"\", s)\n",
" for t in TAILS:\n",
" if s.endswith(t) and len(s) > len(t) + 1:\n",
" s = s[:-len(t)]\n",
" return s\n",
"\n",
"\n",
"def parse(q, raw):\n",
" \"\"\"원본 응답 -> (정제값, 상태). 상태: 정상 / 미응답 / 형식오류\"\"\"\n",
" if raw is None or str(raw).strip() == \"\":\n",
" return \"\", \"미응답\"\n",
" s = unicodedata.normalize(\"NFKC\", str(raw)).strip()\n",
" if q[\"type\"] == \"MC\":\n",
" t = re.sub(r\"[\\s번\\.\\)]\", \"\", s).lower()\n",
" t = {\"a\": \"1\", \"b\": \"2\", \"c\": \"3\", \"d\": \"4\"}.get(t, t)\n",
" return (t, \"정상\") if t in (\"1\", \"2\", \"3\", \"4\") else (t, \"형식오류\")\n",
" if q[\"type\"] == \"ORDER\":\n",
" t = re.sub(r\"[^A-Za-z]\", \"\", s).upper()\n",
" return (t, \"정상\") if t and set(t) <= set(\"ABCD\") and len(set(t)) == len(t) else (t, \"형식오류\")\n",
" return norm_text(s), \"정상\"\n",
"\n",
"\n",
"def grade_exact(q, val):\n",
" if q[\"type\"] == \"MC\":\n",
" return val == str(q[\"answer\"])\n",
" if q[\"type\"] == \"ORDER\":\n",
" return val == q[\"answer\"]\n",
" return val in {norm_text(a) for a in q[\"accept\"]}\n",
"\n",
"\n",
"def grade_fuzzy(q, val, cut=0.75):\n",
" \"\"\"단답형은 오탈자(글자 순서 바뀜·한 글자 누락)를 허용(유사도 cut 이상)하되, 알려진 오답과 더 가까우면 오답으로 본다.\"\"\"\n",
" if grade_exact(q, val) or q[\"type\"] != \"SA\" or len(val) < 2:\n",
" return grade_exact(q, val)\n",
" 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\"])\n",
" worst = max(difflib.SequenceMatcher(None, val, norm_text(w)).ratio() for w in q[\"wrong\"])\n",
" return best >= cut and best > worst\n",
"\n",
"\n",
"def score_of(q, correct):\n",
" return POINTS[q[\"diff\"]] if correct else 0\n",
"\n",
"PASS_RATE = 0.7\n",
"df = make_responses(40, seed=7)\n",
"df[\"type\"] = df[\"qid\"].map(lambda q: QIDX[q][\"type\"])\n",
"pv = [parse(QIDX[q], r) for q, r in zip(df[\"qid\"], df[\"raw\"])]\n",
"df[\"val\"] = [p[0] for p in pv]\n",
"\n",
"t0 = time.perf_counter()\n",
"df[\"exact\"] = [grade_exact(QIDX[q], v) for q, v in zip(df[\"qid\"], df[\"val\"])]\n",
"t1 = time.perf_counter()\n",
"df[\"fuzzy\"] = [grade_fuzzy(QIDX[q], v) for q, v in zip(df[\"qid\"], df[\"val\"])]\n",
"t2 = time.perf_counter()\n",
"\n",
"rows = []\n",
"for name, col, t in [(\"정확 일치\", \"exact\", t1 - t0), (\"오탈자 허용\", \"fuzzy\", t2 - t1)]:\n",
" rows.append({\"채점 방식\": name, \"채점 정확도\": (df[col] == df[\"truth_correct\"]).mean(),\n",
" **{f\"{k} 정확도\": (g[col] == g[\"truth_correct\"]).mean() for k, g in df.groupby(\"type\")},\n",
" \"건당 ms\": t / len(df) * 1000})\n",
"res = pd.DataFrame(rows)\n",
"print(f\"[교육용가상자료] 응시자 120명 x 32문항 = {len(df):,}건 채점 (교관 기준 정답과 비교)\")\n",
"print(res.round(4).to_string(index=False))\n",
"acc = res.loc[1, \"채점 정확도\"]\n",
"print(f\"\\n목표(채점 정확도 75% 이상): {'달성' if acc >= 0.75 else '미달'} ({acc:.1%}), 건당 처리 {res.loc[1, '건당 ms']:.3f} ms\")\n",
"\n",
"print(\"\\n[정확 일치 채점이 틀린 사례(오탈자·띄어쓰기)]\")\n",
"miss = df[(df[\"exact\"] != df[\"truth_correct\"])].groupby(\"qid\").head(1).head(5)\n",
"print(miss[[\"qid\", \"raw\", \"val\", \"truth_correct\", \"exact\", \"fuzzy\"]].rename(columns={\"truth_correct\": \"교관 판정\", \"exact\": \"정확일치\", \"fuzzy\": \"오탈자허용\"}).to_string(index=False))\n",
"\n",
"print(\"오탈자 허용 채점도 놓치는 '뜻은 맞지만 인정 답안 목록에 없는 표현'은 이의 제기·교관 검토(미션 7) 대상으로 넘긴다.\")\n",
"print(\"\\n[휴리스틱 1차 튜닝: 유사도 기준값]\")\n",
"sa = df[df[\"type\"] == \"SA\"]\n",
"for cut in [0.6, 0.7, 0.75, 0.8, 0.9]:\n",
" a = np.mean([grade_fuzzy(QIDX[q], v, cut) == t for q, v, t in zip(sa[\"qid\"], sa[\"val\"], sa[\"truth_correct\"])])\n",
" print(f\"기준 {cut}: 단답형 채점 정확도 {a:.3f}\")\n",
"\n",
"df[\"score\"] = [score_of(QIDX[q], c) for q, c in zip(df[\"qid\"], df[\"fuzzy\"])]\n",
"full = sum(POINTS[q[\"diff\"]] for q in BANK)\n",
"sc = df.groupby([\"sid\", \"unit\"])[\"score\"].sum().reset_index()\n",
"sc[\"합격\"] = sc[\"score\"] >= full * PASS_RATE\n",
"print(f\"\\n만점 {full}점, 평균 {sc['score'].mean():.1f}점, 합격률 {sc['합격'].mean():.1%}\")\n",
"print(sc.groupby(\"unit\").agg(평균점수=(\"score\", \"mean\"), 합격률=(\"합격\", \"mean\")).round(2).to_string())\n",
"print(f\"실행 로그: 문항 {len(BANK)}개, 응답 {len(df):,}건, 미응답 {(df['raw'] == '').sum()}건, 채점 예외 0건\")\n",
"\n",
"plt.figure(figsize=(7, 4))\n",
"plt.hist(sc[\"score\"], bins=15, color=\"tab:blue\"); plt.axvline(full * PASS_RATE, color=\"red\", ls=\"--\", label=\"pass line (70%)\")\n",
"plt.xlabel(\"total score\"); plt.ylabel(\"soldiers\"); plt.legend(); plt.title(\"Auto-graded score distribution (synthetic)\"); plt.show()\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"<details>\n",
"<summary><b>💡 [참고] 미션 03 예상 실행 결과 (Output)</b></summary>\n",
"\n",
"```text\n",
"[교육용가상자료] 응시자 120명 x 32문항 = 3,840건 채점 (교관 기준 정답과 비교)\n",
" 채점 방식 채점 정확도 MC 정확도 ORDER 정확도 SA 정확도 건당 ms\n",
" 정확 일치 0.9516 1.0 1.0 0.8062 0.0017\n",
"오탈자 허용 0.9901 1.0 1.0 0.9604 0.0070\n",
"\n",
"목표(채점 정확도 75% 이상): 달성 (99.0%), 건당 처리 0.007 ms\n",
"\n",
"[정확 일치 채점이 틀린 사례(오탈자·띄어쓰기)]\n",
"qid raw val 교관 판정 정확일치 오탈자허용\n",
"Q25 잘못된 경보 잘못된경보 True False False\n",
"Q21 아군적군 구분 아군적군구분 True False False\n",
"Q28 지휘통 지휘통 True False True\n",
"Q22 타델익 타델익 True False True\n",
"Q26 민기간 민기간 True False True\n",
"오탈자 허용 채점도 놓치는 '뜻은 맞지만 인정 답안 목록에 없는 표현'은 이의 제기·교관 검토(미션 7) 대상으로 넘긴다.\n",
"\n",
"[휴리스틱 1차 튜닝: 유사도 기준값]\n",
"기준 0.6: 단답형 채점 정확도 0.965\n",
"기준 0.7: 단답형 채점 정확도 0.960\n",
"기준 0.75: 단답형 채점 정확도 0.960\n",
"기준 0.8: 단답형 채점 정확도 0.956\n",
"기준 0.9: 단답형 채점 정확도 0.935\n",
"\n",
"만점 99점, 평균 67.4점, 합격률 50.8%\n",
" 평균점수 합격률\n",
"unit \n",
"가상1중대 75.40 0.68\n",
"가상2중대 58.55 0.28\n",
"가상3중대 68.18 0.57\n",
"실행 로그: 문항 32개, 응답 3,840건, 미응답 72건, 채점 예외 0건\n",
"```\n",
"</details>\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"---\n",
"\n",
"## 🎯 미션 04. 오답 유형(개념 혼동, 피아 오인, 절차 누락) 자동 분류 및 패턴 분석 엔진 고도화\n",
"**소요시간**: 약 3.0시간\n",
"\n",
"### 📋 미션 배경 및 수행 지침\n",
"#### 미션 배경 및 당위성\n",
"\n",
"오답 유형(개념 혼동, 피아 오인, 절차 누락) 자동 분류 엔진을 고도화한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **오답을 개념 혼동·피아 오인·절차 누락 유형으로 자동 분류하는 엔진을 고도화하고 개인·중대별 오답 패턴을 분석한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
"\n",
"이 문제를 해결하기 위해 본 미션에서는 **선다형은 오답 보기별 유형 매핑, 순서형은 단계 누락·순서 뒤바뀜 규칙, 단답형은 오답 목록 대조와 오탈자 근사 일치에 문자 n-gram 분류 모델을 더 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n",
"\n",
"학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
"\n",
"**이번 결과물:** `src/models/trn02_advanced_detector.py 및 예외 탐지 테스트 결과` 형태의 결과물을 완성한다.\n",
"\n",
"### 🔍 평가 및 검증 기준 (PASS 조건)\n",
"#### 미션 판정\n",
"\n",
"**평가 구분:** 필수\n",
"\n",
"**핵심 평가영역:** 기만/교란 공격에 대한 방어 로직의 현실성 및 신뢰성\n",
"\n",
"#### 미션 PASS 조건\n",
"\n",
"- 정량 기준: 복합 위협 탐지 재현율(Recall) 85% 이상, 처리 지연 200ms 이하\n",
"\n",
"\n",
"- 진행 기준: 복합 위협 시나리오 10종에 대한 탐지 테스트 통과\n",
"\n",
"\n",
"- 검증 방법: 합성 위협 데이터셋 주입 시 이상치 탐지 정확도 검증\n",
"\n",
"\n",
"#### 채점자 판정 메모\n",
"\n",
"> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n",
"\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"\"\"\"오답을 개념 혼동·피아 오인·절차 누락 유형으로 자동 분류하는 엔진을 고도화한다. 보기 매핑·순서 규칙·오답 목록 대조에 문자 n-gram 분류 모델을 더해, 복합 오답 시나리오 10종에서 유형별 재현율과 처리 지연을 검증하고 개인·중대별 오답 패턴을 분석한다.\"\"\"\n",
"import difflib\n",
"import re\n",
"import time\n",
"import unicodedata\n",
"import numpy as np\n",
"import pandas as pd\n",
"import matplotlib.pyplot as plt\n",
"from sklearn.feature_extraction.text import TfidfVectorizer\n",
"from sklearn.linear_model import LogisticRegression\n",
"from sklearn.pipeline import make_pipeline\n",
"\n",
"# ---------------------------------------------------------------------------\n",
"# [교육용가상자료] 방공·드론 식별 퀴즈 문항 은행과 가상 응시자 응답 생성기\n",
"# 문항은 일반 원칙 수준의 가상 문항이며 실제 교범·교전규칙 원문이 아니다. 응시자는 가명(S001~)이다.\n",
"# ---------------------------------------------------------------------------\n",
"AREAS = [\"형상식별\", \"피아식별\", \"경보보고\", \"교전통제\", \"대드론일반\"]\n",
"ETYPES = [\"개념 혼동\", \"피아 오인\", \"절차 누락\"]\n",
"POINTS = {1: 2, 2: 3, 3: 5}\n",
"C, F, P = ETYPES\n",
"\n",
"\n",
"def _mc(qid, area, diff, stem, opts, ans, dist):\n",
" # [TODO : 학생 실습 4] 오답 유형(개념 혼동, 피아 오인, 절차 누락) 자동 분류 및 패턴 분석 엔진 고도화 관련 핵심 로직을 구현하세요.\n",
" # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n",
" # <<< 여기에 코드를 작성하세요 >>>\n",
" pass # 작성 후 pass를 제거하세요.\n",
"def _sa(qid, area, diff, stem, accept, wrong):\n",
" # [TODO : 학생 실습 4] 오답 유형(개념 혼동, 피아 오인, 절차 누락) 자동 분류 및 패턴 분석 엔진 고도화 관련 핵심 로직을 구현하세요.\n",
" # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n",
" # <<< 여기에 코드를 작성하세요 >>>\n",
" pass # 작성 후 pass를 제거하세요.\n",
"def _order(qid, area, diff, stem):\n",
" return {\"qid\": qid, \"type\": \"ORDER\", \"area\": area, \"diff\": diff, \"stem\": stem, \"answer\": \"ABCD\"}\n",
"\n",
"\n",
"BANK = [\n",
" _mc(\"Q01\", \"형상식별\", 1, \"꼬리날개 없이 삼각형 날개를 가진 소형 고정익 실루엣은?\", [\"쿼드콥터형\", \"삼각익 고정익형\", \"회전익 헬기형\", \"조류\"], 2, [C, C, F]),\n",
" _mc(\"Q02\", \"형상식별\", 1, \"회전날개 4개가 십자로 배치된 실루엣은?\", [\"고정익 드론형\", \"쿼드콥터형\", \"순항미사일형\", \"탄도미사일형\"], 2, [C, C, C]),\n",
" _mc(\"Q03\", \"형상식별\", 2, \"가늘고 긴 동체 중간에 작은 날개가 붙은 실루엣은?\", [\"순항미사일형\", \"탄도미사일형\", \"민간 경비행기형\", \"조류\"], 1, [C, F, F]),\n",
" _mc(\"Q04\", \"형상식별\", 3, \"원거리라 실루엣 식별이 어려울 때 먼저 할 행동은?\", [\"추정 유형으로 확정 보고\", \"관측 정보를 그대로 보고하고 추가 관측\", \"관측 중단\", \"민간기로 간주\"], 2, [P, P, F]),\n",
" _mc(\"Q05\", \"피아식별\", 2, \"식별 신호 응답이 없는 항공기에 대한 올바른 판단은?\", [\"즉시 적기로 확정\", \"미확인 항공기로 분류하고 추가 확인\", \"아군기로 간주\", \"무시\"], 2, [F, F, P]),\n",
" _mc(\"Q06\", \"피아식별\", 2, \"운항 정보와 일치하는 저속 비행체의 분류는?\", [\"적 드론\", \"민간기 가능성 높음, 추가 확인\", \"순항미사일\", \"아군 정찰기로 확정\"], 2, [F, C, F]),\n",
" _mc(\"Q07\", \"피아식별\", 3, \"아군 드론 운용 공지가 있는 공역에서 미확인 드론을 보면 우선 확인할 것은?\", [\"기상 정보\", \"아군 운용 계획과 식별 신호\", \"대응 준비 상태\", \"주변 지형\"], 2, [C, P, C]),\n",
" _mc(\"Q08\", \"피아식별\", 3, \"조류 무리와 소형 드론 군집 구분에 가장 도움이 되는 정보는?\", [\"크기만 비교\", \"궤적의 규칙성과 열 신호\", \"색상\", \"관측 시각\"], 2, [C, C, C]),\n",
" _mc(\"Q09\", \"경보보고\", 1, \"공중 위협 최초 보고에 포함하지 않아도 되는 것은?\", [\"관측 시각\", \"방향과 거리\", \"추정 유형\", \"관측자 개인 연락처\"], 4, [P, P, P]),\n",
" _mc(\"Q10\", \"경보보고\", 2, \"경보 전파 후 상황이 바뀌면?\", [\"최초 보고 유지\", \"변경 사항을 즉시 추가 보고\", \"상황 종료 후 일괄 보고\", \"개인 판단으로 경보 해제\"], 2, [P, P, P]),\n",
" _mc(\"Q11\", \"경보보고\", 2, \"보고 내용이 불확실할 때 올바른 표현은?\", [\"확정 표현으로 보고\", \"'추정'임을 밝히고 근거와 함께 보고\", \"보고 보류\", \"다른 관측자 보고를 복사\"], 2, [C, P, P]),\n",
" _mc(\"Q12\", \"경보보고\", 1, \"드론 탐지 후 보고 경로로 알맞은 것은?\", [\"인접 부대에만 전파\", \"지휘계통을 따라 보고\", \"개인 메신저로 공유\", \"보고 없이 대응\"], 2, [P, P, P]),\n",
" _mc(\"Q13\", \"교전통제\", 1, \"AI 탐지 결과만으로 대응을 결정할 수 있는가?\", [\"가능하다\", \"인간 지휘관의 승인이 필요하다\", \"신뢰도 90% 이상이면 가능\", \"야간에만 가능\"], 2, [C, C, C]),\n",
" _mc(\"Q14\", \"교전통제\", 2, \"민간 피해 가능성이 확인되면?\", [\"그대로 진행\", \"즉시 보고하고 지휘관 판단을 받는다\", \"조치 후 보고\", \"무시\"], 2, [P, P, F]),\n",
" _mc(\"Q15\", \"교전통제\", 3, \"식별이 불확실한 표적에 대한 원칙은?\", [\"위협으로 간주해 즉시 대응\", \"식별 확인 전까지 승인 절차를 따른다\", \"무조건 무시\", \"다수결로 결정\"], 2, [F, F, C]),\n",
" _mc(\"Q16\", \"교전통제\", 2, \"판단 근거를 기록해야 하는 이유는?\", [\"형식상 필요\", \"사후 검토와 책임 추적\", \"보고 생략\", \"기록 불필요\"], 2, [C, P, P]),\n",
" _mc(\"Q17\", \"대드론일반\", 1, \"대드론 체계의 일반적 처리 순서는?\", [\"대응-탐지-식별\", \"탐지-식별-보고·판단-대응\", \"식별-대응-탐지\", \"보고-탐지-대응\"], 2, [P, P, P]),\n",
" _mc(\"Q18\", \"대드론일반\", 3, \"레이더만으로 조류와 드론 구분이 어려운 이유는?\", [\"반사 신호 크기가 비슷할 수 있어서\", \"레이더는 야간에 못 써서\", \"드론에는 금속이 없어서\", \"조류는 탐지되지 않아서\"], 1, [C, C, C]),\n",
" _mc(\"Q19\", \"대드론일반\", 2, \"전자광학(EO) 센서의 약점이 커지는 조건은?\", [\"맑은 주간\", \"안개·야간\", \"저속 표적\", \"근거리\"], 2, [C, C, C]),\n",
" _mc(\"Q20\", \"대드론일반\", 2, \"경보가 반복해서 오경보로 판명되면?\", [\"경보 장치를 끈다\", \"사례를 기록·보고하고 기준 재검토 요청\", \"무시한다\", \"모두 실제 위협으로 간주\"], 2, [P, P, F]),\n",
" _sa(\"Q21\", \"피아식별\", 1, \"아군·적·민간을 구분하는 절차를 무엇이라 하는가?\", [\"피아식별\", \"IFF\"], {\"표적획득\": C, \"적기판정\": F, \"경계\": C}),\n",
" _sa(\"Q22\", \"형상식별\", 2, \"꼬리날개 없는 삼각형 날개 형태를 무엇이라 하는가?\", [\"삼각익\", \"델타익\", \"delta\"], {\"직선익\": C, \"후퇴익\": C, \"쿼드콥터\": C}),\n",
" _sa(\"Q23\", \"경보보고\", 2, \"불확실한 관측을 보고할 때 붙이는 표현은?\", [\"추정\"], {\"확정\": C, \"확인\": C, \"생략\": P}),\n",
" _sa(\"Q24\", \"교전통제\", 1, \"AI 탐지 결과에 대해 최종 결정을 내리는 주체는?\", [\"지휘관\"], {\"AI\": C, \"운용병\": P, \"레이더\": C}),\n",
" _sa(\"Q25\", \"대드론일반\", 1, \"조류를 드론으로 잘못 경보하는 것을 무엇이라 하는가?\", [\"오경보\", \"오탐\", \"false alarm\"], {\"미탐\": C, \"재경보\": C, \"적기\": F}),\n",
" _sa(\"Q26\", \"피아식별\", 2, \"운항 정보가 확인된 여객기는 어떻게 분류하는가?\", [\"민간기\", \"민항기\"], {\"적기\": F, \"미확인기\": P, \"아군기\": F}),\n",
" _sa(\"Q27\", \"형상식별\", 1, \"회전날개 4개를 가진 드론 형태는?\", [\"쿼드콥터\", \"quadcopter\"], {\"헥사콥터\": C, \"고정익\": C, \"헬기\": F}),\n",
" _sa(\"Q28\", \"경보보고\", 1, \"보고는 무엇을 따라 올라가는가?\", [\"지휘계통\"], {\"인접부대\": P, \"개인메신저\": P, \"통신망\": C}),\n",
" _order(\"Q29\", \"경보보고\", 2, \"최초 보고 순서: A 관측 시각, B 방향·거리, C 추정 유형, D 관측자 조치\"),\n",
" _order(\"Q30\", \"대드론일반\", 2, \"처리 순서: A 탐지, B 식별, C 보고·판단, D 대응\"),\n",
" _order(\"Q31\", \"교전통제\", 3, \"통제 순서: A 식별 확인, B 상급 승인 요청, C 승인 수령, D 조치 기록\"),\n",
" _order(\"Q32\", \"피아식별\", 3, \"확인 순서: A 운항정보 대조, B 식별 신호 확인, C 미확인 분류, D 보고\"),\n",
"]\n",
"QIDX = {q[\"qid\"]: q for q in BANK}\n",
"UNITS = [\"가상1중대\", \"가상2중대\", \"가상3중대\"]\n",
"_CIRC = \"①②③④\"\n",
"PARA = {\"Q21\": \"아군적군 구분\", \"Q22\": \"세모 날개\", \"Q23\": \"추정됨으로 표기\", \"Q24\": \"중대장(지휘관)\", \"Q25\": \"잘못된 경보\",\n",
" \"Q26\": \"민간 여객기\", \"Q27\": \"4발 드론\", \"Q28\": \"지휘 라인\"} # 뜻은 맞지만 인정 답안 목록에 없는 표현\n",
"\n",
"\n",
"def make_soldiers(n_per_unit, seed):\n",
" rng = np.random.default_rng(seed)\n",
" out = []\n",
" for u, unit in enumerate(UNITS):\n",
" base = [0.3, -0.1, 0.0][u]\n",
" for i in range(n_per_unit):\n",
" off = rng.normal(0, 0.5, len(AREAS)) + (np.array([0, 0, 0, -0.6, 0]) if u == 1 else 0)\n",
" out.append({\"sid\": f\"S{len(out) + 1:03d}\", \"unit\": unit, \"theta\": dict(zip(AREAS, base + rng.normal(0, 0.6) + off)),\n",
" \"tend\": dict(zip(ETYPES, rng.dirichlet([1.2, 1.2, 1.2])))})\n",
" return out\n",
"\n",
"\n",
"def p_correct(theta, diff):\n",
" return 1 / (1 + np.exp(-1.7 * (theta - (diff - 2) * 0.9 + 0.9)))\n",
"\n",
"\n",
"def _typo(s, rng):\n",
" if len(s) < 2:\n",
" return s\n",
" i = int(rng.integers(len(s) - 1))\n",
" 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:]\n",
"\n",
"\n",
"def _fmt_mc(k, rng):\n",
" r = rng.random()\n",
" if r < 0.55:\n",
" return str(k)\n",
" return rng.choice([_CIRC[k - 1], f\"{k}번\", \"ABCD\"[k - 1], \"abcd\"[k - 1] + \")\", f\" {k} \", chr(0xFF10 + k), f\"{k}.\"])\n",
"\n",
"\n",
"def _fmt_sa(s, rng):\n",
" r = rng.random()\n",
" if r < 0.45:\n",
" return s\n",
" if r < 0.6:\n",
" return \" \".join(s) if len(s) <= 4 else s[:2] + \" \" + s[2:]\n",
" if r < 0.72:\n",
" return s + rng.choice([\"입니다\", \"이다\", \".\", \"임\"])\n",
" if r < 0.8:\n",
" return s.upper() if s.isascii() else \"\".join(chr(ord(ch) + 0xFEE0) if \"!\" <= ch <= \"~\" else ch for ch in s) + \" \"\n",
" return _typo(s, rng)\n",
"\n",
"\n",
"def answer(q, sol, rng):\n",
" \"\"\"한 문항 응답: (원본 응답 문자열, 의도한 정답 여부, 의도한 답, 오답 유형).\"\"\"\n",
" ok = rng.random() < p_correct(sol[\"theta\"][q[\"area\"]], q[\"diff\"])\n",
" if q[\"type\"] == \"MC\":\n",
" if ok:\n",
" k, et = q[\"answer\"], None\n",
" else:\n",
" ks = list(q[\"dist\"]); w = np.array([sol[\"tend\"][q[\"dist\"][k]] for k in ks])\n",
" k = int(rng.choice(ks, p=w / w.sum())); et = q[\"dist\"][k]\n",
" return _fmt_mc(k, rng), ok, str(k), et\n",
" if q[\"type\"] == \"SA\":\n",
" if ok and rng.random() < 0.06:\n",
" return PARA[q[\"qid\"]], ok, PARA[q[\"qid\"]], None\n",
" if ok:\n",
" s, et = str(rng.choice(q[\"accept\"])), None\n",
" else:\n",
" ws = list(q[\"wrong\"]); w = np.array([sol[\"tend\"][q[\"wrong\"][x]] for x in ws])\n",
" s = str(rng.choice(ws, p=w / w.sum())); et = q[\"wrong\"][s]\n",
" return _fmt_sa(s, rng), ok, s, et\n",
" if ok:\n",
" s = \"ABCD\"\n",
" elif rng.random() < 0.5:\n",
" s = \"ABCD\".replace(\"ABCD\"[rng.integers(1, 4)], \"\") # 단계 하나 빠뜨림\n",
" else:\n",
" i = int(rng.integers(0, 3)); s = list(\"ABCD\"); s[i], s[i + 1] = s[i + 1], s[i]; s = \"\".join(s) # 순서 뒤바뀜\n",
" raw = s if rng.random() < 0.6 else rng.choice([\"-\".join(s), \" \".join(s), s.lower(), \",\".join(s)])\n",
" return raw, ok, s, (None if ok else P)\n",
"\n",
"\n",
"def make_responses(n_per_unit=40, seed=7, blank=0.02):\n",
" \"\"\"응시자 x 문항 응답표(DataFrame)를 만든다. truth_* 열은 교관 기준 정답(평가용)이다.\"\"\"\n",
" rng = np.random.default_rng(seed)\n",
" rows = []\n",
" for sol in make_soldiers(n_per_unit, seed):\n",
" for q in BANK:\n",
" raw, ok, intended, et = answer(q, sol, rng)\n",
" if rng.random() < blank:\n",
" raw, ok, intended, et = \"\", False, \"\", None\n",
" rows.append({\"rid\": f\"R{len(rows):05d}\", \"sid\": sol[\"sid\"], \"unit\": sol[\"unit\"], \"qid\": q[\"qid\"], \"raw\": raw,\n",
" \"truth_correct\": ok, \"truth_answer\": intended, \"truth_etype\": et})\n",
" return pd.DataFrame(rows)\n",
"# ---------------------------------------------------------------------------\n",
"\n",
"# ---- 응답 정제·파싱·채점 공통 함수 ----\n",
"TAILS = (\"입니다\", \"이다\", \"임\")\n",
"\n",
"\n",
"def norm_text(s):\n",
" \"\"\"전각->반각(NFKC), 소문자, 공백·문장부호 제거, 끝맺음 어미 제거.\"\"\"\n",
" s = unicodedata.normalize(\"NFKC\", str(s)).strip().lower()\n",
" s = re.sub(r\"[\\s\\.\\,\\-\\(\\)·]\", \"\", s)\n",
" for t in TAILS:\n",
" if s.endswith(t) and len(s) > len(t) + 1:\n",
" s = s[:-len(t)]\n",
" return s\n",
"\n",
"\n",
"def parse(q, raw):\n",
" \"\"\"원본 응답 -> (정제값, 상태). 상태: 정상 / 미응답 / 형식오류\"\"\"\n",
" if raw is None or str(raw).strip() == \"\":\n",
" return \"\", \"미응답\"\n",
" s = unicodedata.normalize(\"NFKC\", str(raw)).strip()\n",
" if q[\"type\"] == \"MC\":\n",
" t = re.sub(r\"[\\s번\\.\\)]\", \"\", s).lower()\n",
" t = {\"a\": \"1\", \"b\": \"2\", \"c\": \"3\", \"d\": \"4\"}.get(t, t)\n",
" return (t, \"정상\") if t in (\"1\", \"2\", \"3\", \"4\") else (t, \"형식오류\")\n",
" if q[\"type\"] == \"ORDER\":\n",
" t = re.sub(r\"[^A-Za-z]\", \"\", s).upper()\n",
" return (t, \"정상\") if t and set(t) <= set(\"ABCD\") and len(set(t)) == len(t) else (t, \"형식오류\")\n",
" return norm_text(s), \"정상\"\n",
"\n",
"\n",
"def grade_exact(q, val):\n",
" if q[\"type\"] == \"MC\":\n",
" return val == str(q[\"answer\"])\n",
" if q[\"type\"] == \"ORDER\":\n",
" return val == q[\"answer\"]\n",
" return val in {norm_text(a) for a in q[\"accept\"]}\n",
"\n",
"\n",
"def grade_fuzzy(q, val, cut=0.75):\n",
" \"\"\"단답형은 오탈자(글자 순서 바뀜·한 글자 누락)를 허용(유사도 cut 이상)하되, 알려진 오답과 더 가까우면 오답으로 본다.\"\"\"\n",
" if grade_exact(q, val) or q[\"type\"] != \"SA\" or len(val) < 2:\n",
" return grade_exact(q, val)\n",
" 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\"])\n",
" worst = max(difflib.SequenceMatcher(None, val, norm_text(w)).ratio() for w in q[\"wrong\"])\n",
" return best >= cut and best > worst\n",
"\n",
"\n",
"def score_of(q, correct):\n",
" return POINTS[q[\"diff\"]] if correct else 0\n",
"\n",
"# ---- 오답 유형 분류기(규칙 + 문자 n-gram 로지스틱 회귀) ----\n",
"SEED = {\"피아 오인\": [\"적기\", \"적 드론\", \"아군기\", \"적 헬기\", \"적기판정\", \"헬기\", \"적 항공기 확정\", \"아군 드론\"],\n",
" \"절차 누락\": [\"생략\", \"인접부대\", \"개인메신저\", \"보고 안 함\", \"운용병\", \"미확인기\", \"단계 건너뜀\", \"직접 전파\"],\n",
" \"개념 혼동\": [\"직선익\", \"후퇴익\", \"헥사콥터\", \"미탐\", \"재경보\", \"확정\", \"확인\", \"레이더\", \"AI\", \"표적획득\", \"경계\", \"통신망\", \"고정익\"]}\n",
"\n",
"\n",
"def build_text_model():\n",
" texts, labels = [], []\n",
" for q in BANK:\n",
" if q[\"type\"] == \"SA\":\n",
" for w, et in q[\"wrong\"].items():\n",
" texts.append(w); labels.append(et)\n",
" for et, ws in SEED.items():\n",
" texts += ws; labels += [et] * len(ws)\n",
" m = make_pipeline(TfidfVectorizer(analyzer=\"char_wb\", ngram_range=(1, 3)), LogisticRegression(max_iter=2000, C=5))\n",
" return m.fit([norm_text(t) for t in texts], labels)\n",
"\n",
"\n",
"def classify_error(q, val, text_model):\n",
" \"\"\"오답 1건의 유형과 판단 근거(rule/text)를 돌려준다.\"\"\"\n",
" if q[\"type\"] == \"MC\":\n",
" return q[\"dist\"].get(int(val), \"개념 혼동\") if val in \"1234\" and val else \"개념 혼동\", \"보기 매핑\"\n",
" if q[\"type\"] == \"ORDER\":\n",
" return \"절차 누락\", (\"단계 누락\" if len(val) < 4 else \"순서 뒤바뀜\")\n",
" known = {norm_text(w): et for w, et in q[\"wrong\"].items()}\n",
" if val in known:\n",
" return known[val], \"오답 목록\"\n",
" near = max(known, key=lambda w: difflib.SequenceMatcher(None, val, w).ratio())\n",
" if difflib.SequenceMatcher(None, val, near).ratio() >= 0.75 or sorted(val) == sorted(near):\n",
" return known[near], \"오답 목록(오탈자)\"\n",
" return str(text_model.predict([val])[0]), \"문자 모델\"\n",
"\n",
"# [교육용가상자료] 인정 답안·오답 목록에 없는 새로운 오답 표현(문자 모델 시험용)\n",
"NOVEL = [(\"Q21\", \"적기 확정\", \"피아 오인\"), (\"Q21\", \"표적 추적\", \"개념 혼동\"), (\"Q22\", \"직선 날개\", \"개념 혼동\"), (\"Q23\", \"확실함\", \"개념 혼동\"),\n",
" (\"Q23\", \"보고 안함\", \"절차 누락\"), (\"Q24\", \"자동 판정\", \"개념 혼동\"), (\"Q24\", \"관측 운용병\", \"절차 누락\"), (\"Q25\", \"미탐지\", \"개념 혼동\"),\n",
" (\"Q25\", \"적 드론\", \"피아 오인\"), (\"Q26\", \"적 정찰기\", \"피아 오인\"), (\"Q26\", \"보고 생략\", \"절차 누락\"), (\"Q27\", \"고정익 드론\", \"개념 혼동\"),\n",
" (\"Q27\", \"적 헬기\", \"피아 오인\"), (\"Q28\", \"인접 부대 직보\", \"절차 누락\"), (\"Q28\", \"단체 메신저\", \"절차 누락\"), (\"Q21\", \"아군기\", \"피아 오인\"),\n",
" (\"Q23\", \"대충 보고\", \"절차 누락\"), (\"Q22\", \"날개 없음\", \"개념 혼동\"), (\"Q26\", \"미확인 표적으로 대응\", \"피아 오인\"),\n",
" (\"Q24\", \"분대원 누구나\", \"절차 누락\"), (\"Q25\", \"정상 경보\", \"개념 혼동\"), (\"Q27\", \"정찰 헬기\", \"피아 오인\")]\n",
"\n",
"model = build_text_model()\n",
"df = make_responses(40, seed=7)\n",
"df[\"type\"] = df[\"qid\"].map(lambda q: QIDX[q][\"type\"])\n",
"df[\"val\"] = [parse(QIDX[q], r)[0] for q, r in zip(df[\"qid\"], df[\"raw\"])]\n",
"wrong = df[df[\"truth_etype\"].notna()].copy()\n",
"\n",
"t0 = time.perf_counter()\n",
"out = [classify_error(QIDX[q], v, model) for q, v in zip(wrong[\"qid\"], wrong[\"val\"])]\n",
"ms = (time.perf_counter() - t0) * 1000 / len(wrong)\n",
"wrong[\"pred\"], wrong[\"basis\"] = [o[0] for o in out], [o[1] for o in out]\n",
"\n",
"# 복합 오답 시나리오 10종\n",
"rng = np.random.default_rng(5)\n",
"sa_typo = wrong[(wrong[\"type\"] == \"SA\")].copy()\n",
"sa_typo[\"val\"] = [norm_text(_typo(v, rng)) if len(v) >= 3 else v for v in sa_typo[\"truth_answer\"]]\n",
"sa_typo[\"pred\"] = [classify_error(QIDX[q], v, model)[0] for q, v in zip(sa_typo[\"qid\"], sa_typo[\"val\"])]\n",
"nov = pd.DataFrame(NOVEL, columns=[\"qid\", \"val\", \"truth_etype\"])\n",
"nov[\"val\"] = nov[\"val\"].map(norm_text)\n",
"nov[\"pred\"] = [classify_error(QIDX[q], v, model)[0] for q, v in zip(nov[\"qid\"], nov[\"val\"])]\n",
"raw_fmt = wrong[\"raw\"].str.strip() != wrong[\"val\"]\n",
"SCEN = {\n",
" \"S1 선다형 표준 표기\": wrong[(wrong[\"type\"] == \"MC\") & ~raw_fmt], \"S2 선다형 원문자·전각 표기\": wrong[(wrong[\"type\"] == \"MC\") & raw_fmt],\n",
" \"S3 단답 오답 목록 일치\": wrong[(wrong[\"type\"] == \"SA\") & (wrong[\"basis\"] == \"오답 목록\")], \"S4 단답 오답 + 오탈자\": sa_typo,\n",
" \"S5 단답 오답 + 어미·띄어쓰기\": wrong[(wrong[\"type\"] == \"SA\") & raw_fmt], \"S6 순서형 단계 누락\": wrong[(wrong[\"type\"] == \"ORDER\") & (wrong[\"val\"].str.len() < 4)],\n",
" \"S7 순서형 순서 뒤바뀜\": wrong[(wrong[\"type\"] == \"ORDER\") & (wrong[\"val\"].str.len() == 4)], \"S8 순서형 구분자 혼입\": wrong[(wrong[\"type\"] == \"ORDER\") & raw_fmt],\n",
" \"S9 목록에 없는 새 오답\": nov, \"S10 전체 혼합\": pd.concat([wrong, sa_typo, nov]),\n",
"}\n",
"rows = []\n",
"for name, d in SCEN.items():\n",
" rec = {et: (d[d[\"truth_etype\"] == et][\"pred\"] == et).mean() if (d[\"truth_etype\"] == et).any() else np.nan for et in ETYPES}\n",
" rows.append({\"시나리오\": name, \"건수\": len(d), \"정확도\": (d[\"pred\"] == d[\"truth_etype\"]).mean(), **{f\"{k} 재현율\": v for k, v in rec.items()}})\n",
"res = pd.DataFrame(rows)\n",
"res[\"통과\"] = np.where(res[\"정확도\"] >= 0.85, \"PASS\", \"FAIL\")\n",
"print(res.round(3).fillna(\"-\").to_string(index=False))\n",
"allr = SCEN[\"S10 전체 혼합\"]\n",
"recs = {et: (allr[allr[\"truth_etype\"] == et][\"pred\"] == et).mean() for et in ETYPES}\n",
"print(\"\\n오답 유형별 재현율:\", {k: round(float(v), 3) for k, v in recs.items()},\n",
" f\"(목표 85% 이상: {'달성' if min(recs.values()) >= 0.85 else '미달'}), 건당 처리 {ms:.3f} ms (목표 200 ms 이하)\")\n",
"print(f\"시나리오 통과 {(res['통과'] == 'PASS').sum()}/10\")\n",
"fa = nov[nov[\"pred\"] != nov[\"truth_etype\"]]\n",
"print(\"새 오답 오분류(특징 재설계 대상):\", [f\"{v}->{p}\" for v, p in zip(fa[\"val\"], fa[\"pred\"])])\n",
"\n",
"# 오답 패턴 분석: 개인별 주된 오답 유형, 중대별 분포\n",
"dom = wrong.groupby(\"sid\")[\"pred\"].agg(lambda s: s.value_counts().idxmax())\n",
"print(\"\\n개인별 주된 오답 유형 분포:\", dom.value_counts().to_dict())\n",
"print(pd.crosstab(wrong[\"unit\"], wrong[\"pred\"], normalize=\"index\").round(2).to_string())\n",
"\n",
"ct = pd.crosstab(wrong[\"qid\"].map(lambda q: QIDX[q][\"area\"]), wrong[\"pred\"])\n",
"ct.index = ct.index.map({\"형상식별\": \"Shape ID\", \"피아식별\": \"Friend/Foe\", \"경보보고\": \"Alert/Report\", \"교전통제\": \"Control\", \"대드론일반\": \"C-UAS basics\"})\n",
"ct.columns = ct.columns.map({\"개념 혼동\": \"concept\", \"피아 오인\": \"friend/foe\", \"절차 누락\": \"procedure\"})\n",
"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()\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"<details>\n",
"<summary><b>💡 [참고] 미션 04 예상 실행 결과 (Output)</b></summary>\n",
"\n",
"```text\n",
" 시나리오 건수 정확도 개념 혼동 재현율 피아 오인 재현율 절차 누락 재현율 통과\n",
" S1 선다형 표준 표기 367 1.000 1.0 1.0 1.000 PASS\n",
" S2 선다형 원문자·전각 표기 235 1.000 1.0 1.0 1.000 PASS\n",
" S3 단답 오답 목록 일치 120 1.000 1.0 1.0 1.000 PASS\n",
" S4 단답 오답 + 오탈자 148 1.000 1.0 1.0 1.000 PASS\n",
"S5 단답 오답 + 어미·띄어쓰기 55 1.000 1.0 1.0 1.000 PASS\n",
" S6 순서형 단계 누락 90 1.000 - - 1.000 PASS\n",
" S7 순서형 순서 뒤바뀜 94 1.000 - - 1.000 PASS\n",
" S8 순서형 구분자 혼입 81 1.000 - - 1.000 PASS\n",
" S9 목록에 없는 새 오답 22 0.864 1.0 0.857 0.714 PASS\n",
" S10 전체 혼합 1104 0.997 1.0 0.995 0.996 PASS\n",
"\n",
"오답 유형별 재현율: {'개념 혼동': 1.0, '피아 오인': 0.995, '절차 누락': 0.996} (목표 85% 이상: 달성), 건당 처리 0.005 ms (목표 200 ms 이하)\n",
"시나리오 통과 10/10\n",
"새 오답 오분류(특징 재설계 대상): ['대충보고->개념 혼동', '미확인표적으로대응->절차 누락', '분대원누구나->개념 혼동']\n",
"\n",
"개인별 주된 오답 유형 분포: {'절차 누락': 60, '개념 혼동': 42, '피아 오인': 12}\n",
"pred 개념 혼동 절차 누락 피아 오인\n",
"unit \n",
"가상1중대 0.36 0.46 0.18\n",
"가상2중대 0.40 0.41 0.19\n",
"가상3중대 0.34 0.49 0.17\n",
"```\n",
"</details>\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"---\n",
"\n",
"## 🎯 미션 05. 개인별/부대별 방공 취약 영역 식별 및 맞춤형 보충 학습 추천 알고리즘 개발\n",
"**소요시간**: 약 3.0시간\n",
"\n",
"### 📋 미션 배경 및 수행 지침\n",
"#### 미션 배경 및 당위성\n",
"\n",
"개인별/부대별 방공 취약 영역 식별 및 맞춤형 보충 학습 추천 알고리즘을 개발한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **채점 결과로 개인별·중대별 방공 취약 영역을 찾고, 불필요한 추천을 줄인 맞춤형 보충 학습 추천 알고리즘을 개발한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
"\n",
"이 문제를 해결하기 위해 본 미션에서는 **문항 난이도를 반영한 베이즈 추정으로 영역별 '취약할 확률'을 계산하고 High/Medium/Low 신뢰도 등급을 부여 수행하고 검증하는** 실습을 체계적으로 수행합니다.\n",
"\n",
"학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
"\n",
"**이번 결과물:** `src/verification/trn02_confidence_engine.py` 형태의 결과물을 완성한다.\n",
"\n",
"### 🔍 평가 및 검증 기준 (PASS 조건)\n",
"#### 미션 판정\n",
"\n",
"**평가 구분:** 필수\n",
"\n",
"**핵심 평가영역:** 신뢰도 등급(High/Med/Low) 부여 기준의 군사적 타당성\n",
"\n",
"#### 미션 PASS 조건\n",
"\n",
"- 정량 기준: 오경보율(FAR) 40% 이상 감축, 신뢰도 스코어 산출 일관성 90% 이상\n",
"\n",
"\n",
"- 진행 기준: 오경보 필터링 전후 비교 지표 산출 및 통합 테스트 완료\n",
"\n",
"\n",
"- 검증 방법: ROC-AUC 곡선 분석 및 임계값 민감도 테스트\n",
"\n",
"\n",
"#### 채점자 판정 메모\n",
"\n",
"> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n",
"\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"\"\"\"채점 결과로 개인별·중대별 방공 취약 영역을 찾아 보충 학습을 추천하는 알고리즘을 개발한다. 문항 난이도를 반영한 베이즈 추정으로 영역별 '취약할 확률'과 신뢰도 등급을 산출하고, 틀린 문항이 있으면 모두 추천하던 방식 대비 불필요한 추천(오경보) 감축·결정 일관성·ROC-AUC를 검증한다.\"\"\"\n",
"import difflib\n",
"import re\n",
"import unicodedata\n",
"import numpy as np\n",
"import pandas as pd\n",
"import matplotlib.pyplot as plt\n",
"from scipy import stats\n",
"from sklearn.metrics import roc_auc_score\n",
"\n",
"# ---------------------------------------------------------------------------\n",
"# [교육용가상자료] 방공·드론 식별 퀴즈 문항 은행과 가상 응시자 응답 생성기\n",
"# 문항은 일반 원칙 수준의 가상 문항이며 실제 교범·교전규칙 원문이 아니다. 응시자는 가명(S001~)이다.\n",
"# ---------------------------------------------------------------------------\n",
"AREAS = [\"형상식별\", \"피아식별\", \"경보보고\", \"교전통제\", \"대드론일반\"]\n",
"ETYPES = [\"개념 혼동\", \"피아 오인\", \"절차 누락\"]\n",
"POINTS = {1: 2, 2: 3, 3: 5}\n",
"C, F, P = ETYPES\n",
"\n",
"\n",
"def _mc(qid, area, diff, stem, opts, ans, dist):\n",
" # [TODO : 학생 실습 5] 개인별/부대별 방공 취약 영역 식별 및 맞춤형 보충 학습 추천 알고리즘 개발 관련 핵심 로직을 구현하세요.\n",
" # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n",
" # <<< 여기에 코드를 작성하세요 >>>\n",
" pass # 작성 후 pass를 제거하세요.\n",
"def _sa(qid, area, diff, stem, accept, wrong):\n",
" # [TODO : 학생 실습 5] 개인별/부대별 방공 취약 영역 식별 및 맞춤형 보충 학습 추천 알고리즘 개발 관련 핵심 로직을 구현하세요.\n",
" # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n",
" # <<< 여기에 코드를 작성하세요 >>>\n",
" pass # 작성 후 pass를 제거하세요.\n",
"def _order(qid, area, diff, stem):\n",
" return {\"qid\": qid, \"type\": \"ORDER\", \"area\": area, \"diff\": diff, \"stem\": stem, \"answer\": \"ABCD\"}\n",
"\n",
"\n",
"BANK = [\n",
" _mc(\"Q01\", \"형상식별\", 1, \"꼬리날개 없이 삼각형 날개를 가진 소형 고정익 실루엣은?\", [\"쿼드콥터형\", \"삼각익 고정익형\", \"회전익 헬기형\", \"조류\"], 2, [C, C, F]),\n",
" _mc(\"Q02\", \"형상식별\", 1, \"회전날개 4개가 십자로 배치된 실루엣은?\", [\"고정익 드론형\", \"쿼드콥터형\", \"순항미사일형\", \"탄도미사일형\"], 2, [C, C, C]),\n",
" _mc(\"Q03\", \"형상식별\", 2, \"가늘고 긴 동체 중간에 작은 날개가 붙은 실루엣은?\", [\"순항미사일형\", \"탄도미사일형\", \"민간 경비행기형\", \"조류\"], 1, [C, F, F]),\n",
" _mc(\"Q04\", \"형상식별\", 3, \"원거리라 실루엣 식별이 어려울 때 먼저 할 행동은?\", [\"추정 유형으로 확정 보고\", \"관측 정보를 그대로 보고하고 추가 관측\", \"관측 중단\", \"민간기로 간주\"], 2, [P, P, F]),\n",
" _mc(\"Q05\", \"피아식별\", 2, \"식별 신호 응답이 없는 항공기에 대한 올바른 판단은?\", [\"즉시 적기로 확정\", \"미확인 항공기로 분류하고 추가 확인\", \"아군기로 간주\", \"무시\"], 2, [F, F, P]),\n",
" _mc(\"Q06\", \"피아식별\", 2, \"운항 정보와 일치하는 저속 비행체의 분류는?\", [\"적 드론\", \"민간기 가능성 높음, 추가 확인\", \"순항미사일\", \"아군 정찰기로 확정\"], 2, [F, C, F]),\n",
" _mc(\"Q07\", \"피아식별\", 3, \"아군 드론 운용 공지가 있는 공역에서 미확인 드론을 보면 우선 확인할 것은?\", [\"기상 정보\", \"아군 운용 계획과 식별 신호\", \"대응 준비 상태\", \"주변 지형\"], 2, [C, P, C]),\n",
" _mc(\"Q08\", \"피아식별\", 3, \"조류 무리와 소형 드론 군집 구분에 가장 도움이 되는 정보는?\", [\"크기만 비교\", \"궤적의 규칙성과 열 신호\", \"색상\", \"관측 시각\"], 2, [C, C, C]),\n",
" _mc(\"Q09\", \"경보보고\", 1, \"공중 위협 최초 보고에 포함하지 않아도 되는 것은?\", [\"관측 시각\", \"방향과 거리\", \"추정 유형\", \"관측자 개인 연락처\"], 4, [P, P, P]),\n",
" _mc(\"Q10\", \"경보보고\", 2, \"경보 전파 후 상황이 바뀌면?\", [\"최초 보고 유지\", \"변경 사항을 즉시 추가 보고\", \"상황 종료 후 일괄 보고\", \"개인 판단으로 경보 해제\"], 2, [P, P, P]),\n",
" _mc(\"Q11\", \"경보보고\", 2, \"보고 내용이 불확실할 때 올바른 표현은?\", [\"확정 표현으로 보고\", \"'추정'임을 밝히고 근거와 함께 보고\", \"보고 보류\", \"다른 관측자 보고를 복사\"], 2, [C, P, P]),\n",
" _mc(\"Q12\", \"경보보고\", 1, \"드론 탐지 후 보고 경로로 알맞은 것은?\", [\"인접 부대에만 전파\", \"지휘계통을 따라 보고\", \"개인 메신저로 공유\", \"보고 없이 대응\"], 2, [P, P, P]),\n",
" _mc(\"Q13\", \"교전통제\", 1, \"AI 탐지 결과만으로 대응을 결정할 수 있는가?\", [\"가능하다\", \"인간 지휘관의 승인이 필요하다\", \"신뢰도 90% 이상이면 가능\", \"야간에만 가능\"], 2, [C, C, C]),\n",
" _mc(\"Q14\", \"교전통제\", 2, \"민간 피해 가능성이 확인되면?\", [\"그대로 진행\", \"즉시 보고하고 지휘관 판단을 받는다\", \"조치 후 보고\", \"무시\"], 2, [P, P, F]),\n",
" _mc(\"Q15\", \"교전통제\", 3, \"식별이 불확실한 표적에 대한 원칙은?\", [\"위협으로 간주해 즉시 대응\", \"식별 확인 전까지 승인 절차를 따른다\", \"무조건 무시\", \"다수결로 결정\"], 2, [F, F, C]),\n",
" _mc(\"Q16\", \"교전통제\", 2, \"판단 근거를 기록해야 하는 이유는?\", [\"형식상 필요\", \"사후 검토와 책임 추적\", \"보고 생략\", \"기록 불필요\"], 2, [C, P, P]),\n",
" _mc(\"Q17\", \"대드론일반\", 1, \"대드론 체계의 일반적 처리 순서는?\", [\"대응-탐지-식별\", \"탐지-식별-보고·판단-대응\", \"식별-대응-탐지\", \"보고-탐지-대응\"], 2, [P, P, P]),\n",
" _mc(\"Q18\", \"대드론일반\", 3, \"레이더만으로 조류와 드론 구분이 어려운 이유는?\", [\"반사 신호 크기가 비슷할 수 있어서\", \"레이더는 야간에 못 써서\", \"드론에는 금속이 없어서\", \"조류는 탐지되지 않아서\"], 1, [C, C, C]),\n",
" _mc(\"Q19\", \"대드론일반\", 2, \"전자광학(EO) 센서의 약점이 커지는 조건은?\", [\"맑은 주간\", \"안개·야간\", \"저속 표적\", \"근거리\"], 2, [C, C, C]),\n",
" _mc(\"Q20\", \"대드론일반\", 2, \"경보가 반복해서 오경보로 판명되면?\", [\"경보 장치를 끈다\", \"사례를 기록·보고하고 기준 재검토 요청\", \"무시한다\", \"모두 실제 위협으로 간주\"], 2, [P, P, F]),\n",
" _sa(\"Q21\", \"피아식별\", 1, \"아군·적·민간을 구분하는 절차를 무엇이라 하는가?\", [\"피아식별\", \"IFF\"], {\"표적획득\": C, \"적기판정\": F, \"경계\": C}),\n",
" _sa(\"Q22\", \"형상식별\", 2, \"꼬리날개 없는 삼각형 날개 형태를 무엇이라 하는가?\", [\"삼각익\", \"델타익\", \"delta\"], {\"직선익\": C, \"후퇴익\": C, \"쿼드콥터\": C}),\n",
" _sa(\"Q23\", \"경보보고\", 2, \"불확실한 관측을 보고할 때 붙이는 표현은?\", [\"추정\"], {\"확정\": C, \"확인\": C, \"생략\": P}),\n",
" _sa(\"Q24\", \"교전통제\", 1, \"AI 탐지 결과에 대해 최종 결정을 내리는 주체는?\", [\"지휘관\"], {\"AI\": C, \"운용병\": P, \"레이더\": C}),\n",
" _sa(\"Q25\", \"대드론일반\", 1, \"조류를 드론으로 잘못 경보하는 것을 무엇이라 하는가?\", [\"오경보\", \"오탐\", \"false alarm\"], {\"미탐\": C, \"재경보\": C, \"적기\": F}),\n",
" _sa(\"Q26\", \"피아식별\", 2, \"운항 정보가 확인된 여객기는 어떻게 분류하는가?\", [\"민간기\", \"민항기\"], {\"적기\": F, \"미확인기\": P, \"아군기\": F}),\n",
" _sa(\"Q27\", \"형상식별\", 1, \"회전날개 4개를 가진 드론 형태는?\", [\"쿼드콥터\", \"quadcopter\"], {\"헥사콥터\": C, \"고정익\": C, \"헬기\": F}),\n",
" _sa(\"Q28\", \"경보보고\", 1, \"보고는 무엇을 따라 올라가는가?\", [\"지휘계통\"], {\"인접부대\": P, \"개인메신저\": P, \"통신망\": C}),\n",
" _order(\"Q29\", \"경보보고\", 2, \"최초 보고 순서: A 관측 시각, B 방향·거리, C 추정 유형, D 관측자 조치\"),\n",
" _order(\"Q30\", \"대드론일반\", 2, \"처리 순서: A 탐지, B 식별, C 보고·판단, D 대응\"),\n",
" _order(\"Q31\", \"교전통제\", 3, \"통제 순서: A 식별 확인, B 상급 승인 요청, C 승인 수령, D 조치 기록\"),\n",
" _order(\"Q32\", \"피아식별\", 3, \"확인 순서: A 운항정보 대조, B 식별 신호 확인, C 미확인 분류, D 보고\"),\n",
"]\n",
"QIDX = {q[\"qid\"]: q for q in BANK}\n",
"UNITS = [\"가상1중대\", \"가상2중대\", \"가상3중대\"]\n",
"_CIRC = \"①②③④\"\n",
"PARA = {\"Q21\": \"아군적군 구분\", \"Q22\": \"세모 날개\", \"Q23\": \"추정됨으로 표기\", \"Q24\": \"중대장(지휘관)\", \"Q25\": \"잘못된 경보\",\n",
" \"Q26\": \"민간 여객기\", \"Q27\": \"4발 드론\", \"Q28\": \"지휘 라인\"} # 뜻은 맞지만 인정 답안 목록에 없는 표현\n",
"\n",
"\n",
"def make_soldiers(n_per_unit, seed):\n",
" rng = np.random.default_rng(seed)\n",
" out = []\n",
" for u, unit in enumerate(UNITS):\n",
" base = [0.3, -0.1, 0.0][u]\n",
" for i in range(n_per_unit):\n",
" off = rng.normal(0, 0.5, len(AREAS)) + (np.array([0, 0, 0, -0.6, 0]) if u == 1 else 0)\n",
" out.append({\"sid\": f\"S{len(out) + 1:03d}\", \"unit\": unit, \"theta\": dict(zip(AREAS, base + rng.normal(0, 0.6) + off)),\n",
" \"tend\": dict(zip(ETYPES, rng.dirichlet([1.2, 1.2, 1.2])))})\n",
" return out\n",
"\n",
"\n",
"def p_correct(theta, diff):\n",
" return 1 / (1 + np.exp(-1.7 * (theta - (diff - 2) * 0.9 + 0.9)))\n",
"\n",
"\n",
"def _typo(s, rng):\n",
" if len(s) < 2:\n",
" return s\n",
" i = int(rng.integers(len(s) - 1))\n",
" 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:]\n",
"\n",
"\n",
"def _fmt_mc(k, rng):\n",
" r = rng.random()\n",
" if r < 0.55:\n",
" return str(k)\n",
" return rng.choice([_CIRC[k - 1], f\"{k}번\", \"ABCD\"[k - 1], \"abcd\"[k - 1] + \")\", f\" {k} \", chr(0xFF10 + k), f\"{k}.\"])\n",
"\n",
"\n",
"def _fmt_sa(s, rng):\n",
" r = rng.random()\n",
" if r < 0.45:\n",
" return s\n",
" if r < 0.6:\n",
" return \" \".join(s) if len(s) <= 4 else s[:2] + \" \" + s[2:]\n",
" if r < 0.72:\n",
" return s + rng.choice([\"입니다\", \"이다\", \".\", \"임\"])\n",
" if r < 0.8:\n",
" return s.upper() if s.isascii() else \"\".join(chr(ord(ch) + 0xFEE0) if \"!\" <= ch <= \"~\" else ch for ch in s) + \" \"\n",
" return _typo(s, rng)\n",
"\n",
"\n",
"def answer(q, sol, rng):\n",
" \"\"\"한 문항 응답: (원본 응답 문자열, 의도한 정답 여부, 의도한 답, 오답 유형).\"\"\"\n",
" ok = rng.random() < p_correct(sol[\"theta\"][q[\"area\"]], q[\"diff\"])\n",
" if q[\"type\"] == \"MC\":\n",
" if ok:\n",
" k, et = q[\"answer\"], None\n",
" else:\n",
" ks = list(q[\"dist\"]); w = np.array([sol[\"tend\"][q[\"dist\"][k]] for k in ks])\n",
" k = int(rng.choice(ks, p=w / w.sum())); et = q[\"dist\"][k]\n",
" return _fmt_mc(k, rng), ok, str(k), et\n",
" if q[\"type\"] == \"SA\":\n",
" if ok and rng.random() < 0.06:\n",
" return PARA[q[\"qid\"]], ok, PARA[q[\"qid\"]], None\n",
" if ok:\n",
" s, et = str(rng.choice(q[\"accept\"])), None\n",
" else:\n",
" ws = list(q[\"wrong\"]); w = np.array([sol[\"tend\"][q[\"wrong\"][x]] for x in ws])\n",
" s = str(rng.choice(ws, p=w / w.sum())); et = q[\"wrong\"][s]\n",
" return _fmt_sa(s, rng), ok, s, et\n",
" if ok:\n",
" s = \"ABCD\"\n",
" elif rng.random() < 0.5:\n",
" s = \"ABCD\".replace(\"ABCD\"[rng.integers(1, 4)], \"\") # 단계 하나 빠뜨림\n",
" else:\n",
" i = int(rng.integers(0, 3)); s = list(\"ABCD\"); s[i], s[i + 1] = s[i + 1], s[i]; s = \"\".join(s) # 순서 뒤바뀜\n",
" raw = s if rng.random() < 0.6 else rng.choice([\"-\".join(s), \" \".join(s), s.lower(), \",\".join(s)])\n",
" return raw, ok, s, (None if ok else P)\n",
"\n",
"\n",
"def make_responses(n_per_unit=40, seed=7, blank=0.02):\n",
" \"\"\"응시자 x 문항 응답표(DataFrame)를 만든다. truth_* 열은 교관 기준 정답(평가용)이다.\"\"\"\n",
" rng = np.random.default_rng(seed)\n",
" rows = []\n",
" for sol in make_soldiers(n_per_unit, seed):\n",
" for q in BANK:\n",
" raw, ok, intended, et = answer(q, sol, rng)\n",
" if rng.random() < blank:\n",
" raw, ok, intended, et = \"\", False, \"\", None\n",
" rows.append({\"rid\": f\"R{len(rows):05d}\", \"sid\": sol[\"sid\"], \"unit\": sol[\"unit\"], \"qid\": q[\"qid\"], \"raw\": raw,\n",
" \"truth_correct\": ok, \"truth_answer\": intended, \"truth_etype\": et})\n",
" return pd.DataFrame(rows)\n",
"# ---------------------------------------------------------------------------\n",
"\n",
"# ---- 응답 정제·파싱·채점 공통 함수 ----\n",
"TAILS = (\"입니다\", \"이다\", \"임\")\n",
"\n",
"\n",
"def norm_text(s):\n",
" \"\"\"전각->반각(NFKC), 소문자, 공백·문장부호 제거, 끝맺음 어미 제거.\"\"\"\n",
" s = unicodedata.normalize(\"NFKC\", str(s)).strip().lower()\n",
" s = re.sub(r\"[\\s\\.\\,\\-\\(\\)·]\", \"\", s)\n",
" for t in TAILS:\n",
" if s.endswith(t) and len(s) > len(t) + 1:\n",
" s = s[:-len(t)]\n",
" return s\n",
"\n",
"\n",
"def parse(q, raw):\n",
" \"\"\"원본 응답 -> (정제값, 상태). 상태: 정상 / 미응답 / 형식오류\"\"\"\n",
" if raw is None or str(raw).strip() == \"\":\n",
" return \"\", \"미응답\"\n",
" s = unicodedata.normalize(\"NFKC\", str(raw)).strip()\n",
" if q[\"type\"] == \"MC\":\n",
" t = re.sub(r\"[\\s번\\.\\)]\", \"\", s).lower()\n",
" t = {\"a\": \"1\", \"b\": \"2\", \"c\": \"3\", \"d\": \"4\"}.get(t, t)\n",
" return (t, \"정상\") if t in (\"1\", \"2\", \"3\", \"4\") else (t, \"형식오류\")\n",
" if q[\"type\"] == \"ORDER\":\n",
" t = re.sub(r\"[^A-Za-z]\", \"\", s).upper()\n",
" return (t, \"정상\") if t and set(t) <= set(\"ABCD\") and len(set(t)) == len(t) else (t, \"형식오류\")\n",
" return norm_text(s), \"정상\"\n",
"\n",
"\n",
"def grade_exact(q, val):\n",
" if q[\"type\"] == \"MC\":\n",
" return val == str(q[\"answer\"])\n",
" if q[\"type\"] == \"ORDER\":\n",
" return val == q[\"answer\"]\n",
" return val in {norm_text(a) for a in q[\"accept\"]}\n",
"\n",
"\n",
"def grade_fuzzy(q, val, cut=0.75):\n",
" \"\"\"단답형은 오탈자(글자 순서 바뀜·한 글자 누락)를 허용(유사도 cut 이상)하되, 알려진 오답과 더 가까우면 오답으로 본다.\"\"\"\n",
" if grade_exact(q, val) or q[\"type\"] != \"SA\" or len(val) < 2:\n",
" return grade_exact(q, val)\n",
" 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\"])\n",
" worst = max(difflib.SequenceMatcher(None, val, norm_text(w)).ratio() for w in q[\"wrong\"])\n",
" return best >= cut and best > worst\n",
"\n",
"\n",
"def score_of(q, correct):\n",
" return POINTS[q[\"diff\"]] if correct else 0\n",
"\n",
"MASTERY = 0.6 # 영역 정답률이 이보다 낮으면 '취약'\n",
"HIGH, MEDIUM = 0.8, 0.4\n",
"GRID = np.linspace(-3, 3, 121)\n",
"PRIOR = stats.norm.pdf(GRID, 0, 1)\n",
"CUT = GRID[np.argmin(np.abs(p_correct(GRID, 2) - MASTERY))] # 중간 난이도 정답률 0.6에 해당하는 숙달도\n",
"\n",
"\n",
"def weak_prob(resp):\n",
" \"\"\"(난이도, 정답 여부) 목록 -> 숙달도 사후분포에서 CUT 미만일 확률. 어려운 문항 오답은 덜, 쉬운 문항 오답은 더 크게 반영된다.\"\"\"\n",
" post = PRIOR.copy()\n",
" for d, c in resp:\n",
" p = p_correct(GRID, d)\n",
" post *= p if c else 1 - p\n",
" return float(post[GRID < CUT].sum() / post.sum())\n",
"\n",
"\n",
"def area_table(df):\n",
" g = df.assign(d=df[\"qid\"].map(lambda q: QIDX[q][\"diff\"]))\n",
" t = g.groupby([\"sid\", \"unit\", \"area\"]).agg(wrong=(\"correct\", lambda s: int((~s).sum())),\n",
" resp=(\"d\", lambda s: list(zip(s, g.loc[s.index, \"correct\"])))).reset_index()\n",
" t[\"score\"] = [weak_prob(r) for r in t[\"resp\"]]\n",
" t[\"grade\"] = np.where(t[\"score\"] >= HIGH, \"High\", np.where(t[\"score\"] >= MEDIUM, \"Medium\", \"Low\"))\n",
" return t\n",
"\n",
"\n",
"df = make_responses(40, seed=7)\n",
"df[\"area\"] = df[\"qid\"].map(lambda q: QIDX[q][\"area\"])\n",
"df[\"correct\"] = [grade_fuzzy(QIDX[q], parse(QIDX[q], r)[0]) for q, r in zip(df[\"qid\"], df[\"raw\"])]\n",
"sol = {s[\"sid\"]: s for s in make_soldiers(40, 7)}\n",
"t = area_table(df)\n",
"t[\"truth_weak\"] = [p_correct(sol[s][\"theta\"][a], 2) < MASTERY for s, a in zip(t[\"sid\"], t[\"area\"])] # 가상 실제 숙달도(평가용)\n",
"\n",
"\n",
"def report(name, rec):\n",
" fa = (rec & ~t[\"truth_weak\"]).sum()\n",
" return {\"방식\": name, \"추천 수\": int(rec.sum()), \"불필요 추천\": int(fa), \"오경보율\": round(fa / max(rec.sum(), 1), 3),\n",
" \"취약 영역 재현율\": round((rec & t[\"truth_weak\"]).sum() / t[\"truth_weak\"].sum(), 3)}\n",
"\n",
"\n",
"before = report(\"기존(오답 1개라도 있으면 추천)\", t[\"wrong\"] >= 1)\n",
"after = report(\"난이도 반영 추정(Medium 이상)\", t[\"grade\"] != \"Low\")\n",
"print(f\"[교육용가상자료] 응시자 120명 x 영역 5개 = {len(t)}건, 실제 취약 {int(t['truth_weak'].sum())}건\")\n",
"print(pd.DataFrame([before, after]).to_string(index=False))\n",
"cut = 1 - after[\"오경보율\"] / before[\"오경보율\"]\n",
"retest = ((t[\"grade\"] == \"Low\") & (t[\"score\"] >= 0.2)).sum()\n",
"print(f\"Low 등급 중 취약 확률 0.2 이상 {retest}건은 추천 대신 '추가 문항 출제 후 재판단' 대상으로 둔다.\")\n",
"print(f\"\\n오경보율 감축 {cut:.1%} (목표 40% 이상: {'달성' if cut >= 0.4 else '미달'})\")\n",
"\n",
"# 일관성: 영역마다 응답 1건씩 빼고 다시 계산해도 추천 결정이 유지되는 비율\n",
"rng = np.random.default_rng(0)\n",
"drop = df.groupby([\"sid\", \"area\"]).sample(1, random_state=1).index\n",
"t2 = area_table(df.drop(drop))\n",
"cons = ((t2[\"grade\"] != \"Low\").values == (t[\"grade\"] != \"Low\").values).mean()\n",
"print(f\"추천 결정 일관성(응답 1건 제외 재계산) {cons:.1%} (목표 90% 이상: {'달성' if cons >= 0.9 else '미달'})\")\n",
"auc = roc_auc_score(t[\"truth_weak\"], t[\"score\"])\n",
"print(f\"취약 확률 점수 ROC-AUC {auc:.3f}\")\n",
"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]))\n",
"\n",
"# 개인 보충 학습 추천 예시\n",
"s0 = t[t[\"grade\"] != \"Low\"].sort_values(\"score\", ascending=False)[\"sid\"].iloc[0]\n",
"print(f\"\\n[개인 추천 예시: {s0}]\")\n",
"for r in t[(t[\"sid\"] == s0) & (t[\"grade\"] != \"Low\")].itertuples():\n",
" wrong_q = df[(df[\"sid\"] == s0) & (df[\"area\"] == r.area) & ~df[\"correct\"]][\"qid\"].tolist()\n",
" easy = [q[\"qid\"] for q in BANK if q[\"area\"] == r.area and q[\"diff\"] == 1]\n",
" print(f\" - {r.area}: 취약 확률 {r.score:.2f}({r.grade}), 오답 {wrong_q} -> 기초 문항 {easy} 재학습 후 재평가\")\n",
"\n",
"# 중대별 취약 영역: 소속 인원 30% 이상이 취약(Medium 이상)이면 중대 단위 보충 교육 권고\n",
"u = t.assign(rec=t[\"grade\"] != \"Low\").pivot_table(index=\"unit\", columns=\"area\", values=\"rec\", aggfunc=\"mean\").round(2)\n",
"print(\"\\n[중대별 취약 인원 비율]\")\n",
"print(u.to_string())\n",
"print(\"중대 단위 보충 교육 권고:\", [f\"{i}-{c}\" for i in u.index for c in u.columns if u.loc[i, c] >= 0.3])\n",
"print(\"추천은 학습 보조 정보이며 개인 평가·인사 자료로 쓰지 않는다.\")\n",
"\n",
"fig, ax = plt.subplots(1, 2, figsize=(11, 4))\n",
"for g, col in [(\"High\", \"tab:red\"), (\"Medium\", \"tab:orange\"), (\"Low\", \"tab:green\")]:\n",
" ax[0].hist(t[t[\"grade\"] == g][\"score\"], bins=20, range=(0, 1), color=col, alpha=.7, label=g)\n",
"ax[0].set_title(\"Weakness probability by grade\"); ax[0].set_xlabel(\"P(accuracy < 0.6)\"); ax[0].legend()\n",
"im = ax[1].imshow(u.values, cmap=\"Reds\", vmin=0, vmax=1)\n",
"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])\n",
"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\")\n",
"plt.tight_layout(); plt.show()\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"<details>\n",
"<summary><b>💡 [참고] 미션 05 예상 실행 결과 (Output)</b></summary>\n",
"\n",
"```text\n",
"[교육용가상자료] 응시자 120명 x 영역 5개 = 600건, 실제 취약 120건\n",
" 방식 추천 수 불필요 추천 오경보율 취약 영역 재현율\n",
" 기존(오답 1개라도 있으면 추천) 444 324 0.730 1.000\n",
"난이도 반영 추정(Medium 이상) 148 62 0.419 0.717\n",
"Low 등급 중 취약 확률 0.2 이상 96건은 추천 대신 '추가 문항 출제 후 재판단' 대상으로 둔다.\n",
"\n",
"오경보율 감축 42.6% (목표 40% 이상: 달성)\n",
"추천 결정 일관성(응답 1건 제외 재계산) 91.2% (목표 90% 이상: 달성)\n",
"취약 확률 점수 ROC-AUC 0.898\n",
"[임계값 민감도] 0.3: 오경보율 0.50/재현율 0.82, 0.4: 오경보율 0.42/재현율 0.72, 0.5: 오경보율 0.35/재현율 0.57, 0.7: 오경보율 0.15/재현율 0.47\n",
"\n",
"[개인 추천 예시: S098]\n",
" - 경보보고: 취약 확률 1.00(High), 오답 ['Q09', 'Q10', 'Q11', 'Q12', 'Q23', 'Q28', 'Q29'] -> 기초 문항 ['Q09', 'Q12', 'Q28'] 재학습 후 재평가\n",
" - 대드론일반: 취약 확률 0.87(High), 오답 ['Q18', 'Q19', 'Q25', 'Q30'] -> 기초 문항 ['Q17', 'Q25'] 재학습 후 재평가\n",
"\n",
"[중대별 취약 인원 비율]\n",
"area 경보보고 교전통제 대드론일반 피아식별 형상식별\n",
"unit \n",
"가상1중대 0.10 0.20 0.12 0.15 0.10\n",
"가상2중대 0.25 0.57 0.25 0.38 0.25\n",
"가상3중대 0.30 0.35 0.18 0.32 0.18\n",
"중대 단위 보충 교육 권고: ['가상2중대-교전통제', '가상2중대-피아식별', '가상3중대-경보보고', '가상3중대-교전통제', '가상3중대-피아식별']\n",
"추천은 학습 보조 정보이며 개인 평가·인사 자료로 쓰지 않는다.\n",
"```\n",
"</details>\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"---\n",
"\n",
"## 🎯 미션 06. 부대 방공 지식 수준 통계 및 오답 분포 가시화 교수자 대시보드 구현\n",
"**소요시간**: 약 2.5시간\n",
"\n",
"### 📋 미션 배경 및 수행 지침\n",
"#### 미션 배경 및 당위성\n",
"\n",
"부대 방공 지식 통계 및 오답 분포 가시화 교수자 대시보드를 구현한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **부대 방공 지식 수준과 오답 분포를 한눈에 보여 주는 교수자 대시보드를 구현한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
"\n",
"이 문제를 해결하기 위해 본 미션에서는 **중대x영역 정답률 히트맵, 중대별 오답 유형 비율, 점수 분포와 합격선, 고난도 문항 목록 등 구성요소 4종 이상을 그린다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n",
"\n",
"학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
"\n",
"**이번 결과물:** `src/ui/trn02_dashboard.py (또는 웹 UI 컴포넌트)` 형태의 결과물을 완성한다.\n",
"\n",
"### 🔍 평가 및 검증 기준 (PASS 조건)\n",
"#### 미션 판정\n",
"\n",
"**평가 구분:** 필수\n",
"\n",
"**핵심 평가영역:** 전술 작전 상황판으로서의 직관성 및 인지 부하 감소도\n",
"\n",
"#### 미션 PASS 조건\n",
"\n",
"- 정량 기준: 화면 갱신 주기 1초 이내, UI 응답성 100ms 이내\n",
"\n",
"\n",
"- 진행 기준: 주요 지표 시각화 컴포넌트 4종 이상 렌더링 및 실시간 데이터 연동\n",
"\n",
"\n",
"- 검증 방법: 사용자 인터랙션 테스트 및 모의 데이터 스트림 렌더링 검증\n",
"\n",
"\n",
"#### 채점자 판정 메모\n",
"\n",
"> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n",
"\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"\"\"\"채점·오답 분류 결과로 중대별 영역 정답률, 오답 유형 분포, 점수 분포, 문항 난이도 등 교수자 대시보드 구성요소 4종 이상을 그리고, 새 응답 묶음이 들어올 때 집계 갱신 시간과 필터 조회 응답 시간을 측정하며 경보 하이라이트 대상을 출력한다.\"\"\"\n",
"import difflib\n",
"import re\n",
"import time\n",
"import unicodedata\n",
"import numpy as np\n",
"import pandas as pd\n",
"import matplotlib.pyplot as plt\n",
"from sklearn.feature_extraction.text import TfidfVectorizer\n",
"from sklearn.linear_model import LogisticRegression\n",
"from sklearn.pipeline import make_pipeline\n",
"\n",
"# ---------------------------------------------------------------------------\n",
"# [교육용가상자료] 방공·드론 식별 퀴즈 문항 은행과 가상 응시자 응답 생성기\n",
"# 문항은 일반 원칙 수준의 가상 문항이며 실제 교범·교전규칙 원문이 아니다. 응시자는 가명(S001~)이다.\n",
"# ---------------------------------------------------------------------------\n",
"AREAS = [\"형상식별\", \"피아식별\", \"경보보고\", \"교전통제\", \"대드론일반\"]\n",
"ETYPES = [\"개념 혼동\", \"피아 오인\", \"절차 누락\"]\n",
"POINTS = {1: 2, 2: 3, 3: 5}\n",
"C, F, P = ETYPES\n",
"\n",
"\n",
"def _mc(qid, area, diff, stem, opts, ans, dist):\n",
" # [TODO : 학생 실습 6] 부대 방공 지식 수준 통계 및 오답 분포 가시화 교수자 대시보드 구현 관련 핵심 로직을 구현하세요.\n",
" # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n",
" # <<< 여기에 코드를 작성하세요 >>>\n",
" pass # 작성 후 pass를 제거하세요.\n",
"def _sa(qid, area, diff, stem, accept, wrong):\n",
" # [TODO : 학생 실습 6] 부대 방공 지식 수준 통계 및 오답 분포 가시화 교수자 대시보드 구현 관련 핵심 로직을 구현하세요.\n",
" # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n",
" # <<< 여기에 코드를 작성하세요 >>>\n",
" pass # 작성 후 pass를 제거하세요.\n",
"def _order(qid, area, diff, stem):\n",
" return {\"qid\": qid, \"type\": \"ORDER\", \"area\": area, \"diff\": diff, \"stem\": stem, \"answer\": \"ABCD\"}\n",
"\n",
"\n",
"BANK = [\n",
" _mc(\"Q01\", \"형상식별\", 1, \"꼬리날개 없이 삼각형 날개를 가진 소형 고정익 실루엣은?\", [\"쿼드콥터형\", \"삼각익 고정익형\", \"회전익 헬기형\", \"조류\"], 2, [C, C, F]),\n",
" _mc(\"Q02\", \"형상식별\", 1, \"회전날개 4개가 십자로 배치된 실루엣은?\", [\"고정익 드론형\", \"쿼드콥터형\", \"순항미사일형\", \"탄도미사일형\"], 2, [C, C, C]),\n",
" _mc(\"Q03\", \"형상식별\", 2, \"가늘고 긴 동체 중간에 작은 날개가 붙은 실루엣은?\", [\"순항미사일형\", \"탄도미사일형\", \"민간 경비행기형\", \"조류\"], 1, [C, F, F]),\n",
" _mc(\"Q04\", \"형상식별\", 3, \"원거리라 실루엣 식별이 어려울 때 먼저 할 행동은?\", [\"추정 유형으로 확정 보고\", \"관측 정보를 그대로 보고하고 추가 관측\", \"관측 중단\", \"민간기로 간주\"], 2, [P, P, F]),\n",
" _mc(\"Q05\", \"피아식별\", 2, \"식별 신호 응답이 없는 항공기에 대한 올바른 판단은?\", [\"즉시 적기로 확정\", \"미확인 항공기로 분류하고 추가 확인\", \"아군기로 간주\", \"무시\"], 2, [F, F, P]),\n",
" _mc(\"Q06\", \"피아식별\", 2, \"운항 정보와 일치하는 저속 비행체의 분류는?\", [\"적 드론\", \"민간기 가능성 높음, 추가 확인\", \"순항미사일\", \"아군 정찰기로 확정\"], 2, [F, C, F]),\n",
" _mc(\"Q07\", \"피아식별\", 3, \"아군 드론 운용 공지가 있는 공역에서 미확인 드론을 보면 우선 확인할 것은?\", [\"기상 정보\", \"아군 운용 계획과 식별 신호\", \"대응 준비 상태\", \"주변 지형\"], 2, [C, P, C]),\n",
" _mc(\"Q08\", \"피아식별\", 3, \"조류 무리와 소형 드론 군집 구분에 가장 도움이 되는 정보는?\", [\"크기만 비교\", \"궤적의 규칙성과 열 신호\", \"색상\", \"관측 시각\"], 2, [C, C, C]),\n",
" _mc(\"Q09\", \"경보보고\", 1, \"공중 위협 최초 보고에 포함하지 않아도 되는 것은?\", [\"관측 시각\", \"방향과 거리\", \"추정 유형\", \"관측자 개인 연락처\"], 4, [P, P, P]),\n",
" _mc(\"Q10\", \"경보보고\", 2, \"경보 전파 후 상황이 바뀌면?\", [\"최초 보고 유지\", \"변경 사항을 즉시 추가 보고\", \"상황 종료 후 일괄 보고\", \"개인 판단으로 경보 해제\"], 2, [P, P, P]),\n",
" _mc(\"Q11\", \"경보보고\", 2, \"보고 내용이 불확실할 때 올바른 표현은?\", [\"확정 표현으로 보고\", \"'추정'임을 밝히고 근거와 함께 보고\", \"보고 보류\", \"다른 관측자 보고를 복사\"], 2, [C, P, P]),\n",
" _mc(\"Q12\", \"경보보고\", 1, \"드론 탐지 후 보고 경로로 알맞은 것은?\", [\"인접 부대에만 전파\", \"지휘계통을 따라 보고\", \"개인 메신저로 공유\", \"보고 없이 대응\"], 2, [P, P, P]),\n",
" _mc(\"Q13\", \"교전통제\", 1, \"AI 탐지 결과만으로 대응을 결정할 수 있는가?\", [\"가능하다\", \"인간 지휘관의 승인이 필요하다\", \"신뢰도 90% 이상이면 가능\", \"야간에만 가능\"], 2, [C, C, C]),\n",
" _mc(\"Q14\", \"교전통제\", 2, \"민간 피해 가능성이 확인되면?\", [\"그대로 진행\", \"즉시 보고하고 지휘관 판단을 받는다\", \"조치 후 보고\", \"무시\"], 2, [P, P, F]),\n",
" _mc(\"Q15\", \"교전통제\", 3, \"식별이 불확실한 표적에 대한 원칙은?\", [\"위협으로 간주해 즉시 대응\", \"식별 확인 전까지 승인 절차를 따른다\", \"무조건 무시\", \"다수결로 결정\"], 2, [F, F, C]),\n",
" _mc(\"Q16\", \"교전통제\", 2, \"판단 근거를 기록해야 하는 이유는?\", [\"형식상 필요\", \"사후 검토와 책임 추적\", \"보고 생략\", \"기록 불필요\"], 2, [C, P, P]),\n",
" _mc(\"Q17\", \"대드론일반\", 1, \"대드론 체계의 일반적 처리 순서는?\", [\"대응-탐지-식별\", \"탐지-식별-보고·판단-대응\", \"식별-대응-탐지\", \"보고-탐지-대응\"], 2, [P, P, P]),\n",
" _mc(\"Q18\", \"대드론일반\", 3, \"레이더만으로 조류와 드론 구분이 어려운 이유는?\", [\"반사 신호 크기가 비슷할 수 있어서\", \"레이더는 야간에 못 써서\", \"드론에는 금속이 없어서\", \"조류는 탐지되지 않아서\"], 1, [C, C, C]),\n",
" _mc(\"Q19\", \"대드론일반\", 2, \"전자광학(EO) 센서의 약점이 커지는 조건은?\", [\"맑은 주간\", \"안개·야간\", \"저속 표적\", \"근거리\"], 2, [C, C, C]),\n",
" _mc(\"Q20\", \"대드론일반\", 2, \"경보가 반복해서 오경보로 판명되면?\", [\"경보 장치를 끈다\", \"사례를 기록·보고하고 기준 재검토 요청\", \"무시한다\", \"모두 실제 위협으로 간주\"], 2, [P, P, F]),\n",
" _sa(\"Q21\", \"피아식별\", 1, \"아군·적·민간을 구분하는 절차를 무엇이라 하는가?\", [\"피아식별\", \"IFF\"], {\"표적획득\": C, \"적기판정\": F, \"경계\": C}),\n",
" _sa(\"Q22\", \"형상식별\", 2, \"꼬리날개 없는 삼각형 날개 형태를 무엇이라 하는가?\", [\"삼각익\", \"델타익\", \"delta\"], {\"직선익\": C, \"후퇴익\": C, \"쿼드콥터\": C}),\n",
" _sa(\"Q23\", \"경보보고\", 2, \"불확실한 관측을 보고할 때 붙이는 표현은?\", [\"추정\"], {\"확정\": C, \"확인\": C, \"생략\": P}),\n",
" _sa(\"Q24\", \"교전통제\", 1, \"AI 탐지 결과에 대해 최종 결정을 내리는 주체는?\", [\"지휘관\"], {\"AI\": C, \"운용병\": P, \"레이더\": C}),\n",
" _sa(\"Q25\", \"대드론일반\", 1, \"조류를 드론으로 잘못 경보하는 것을 무엇이라 하는가?\", [\"오경보\", \"오탐\", \"false alarm\"], {\"미탐\": C, \"재경보\": C, \"적기\": F}),\n",
" _sa(\"Q26\", \"피아식별\", 2, \"운항 정보가 확인된 여객기는 어떻게 분류하는가?\", [\"민간기\", \"민항기\"], {\"적기\": F, \"미확인기\": P, \"아군기\": F}),\n",
" _sa(\"Q27\", \"형상식별\", 1, \"회전날개 4개를 가진 드론 형태는?\", [\"쿼드콥터\", \"quadcopter\"], {\"헥사콥터\": C, \"고정익\": C, \"헬기\": F}),\n",
" _sa(\"Q28\", \"경보보고\", 1, \"보고는 무엇을 따라 올라가는가?\", [\"지휘계통\"], {\"인접부대\": P, \"개인메신저\": P, \"통신망\": C}),\n",
" _order(\"Q29\", \"경보보고\", 2, \"최초 보고 순서: A 관측 시각, B 방향·거리, C 추정 유형, D 관측자 조치\"),\n",
" _order(\"Q30\", \"대드론일반\", 2, \"처리 순서: A 탐지, B 식별, C 보고·판단, D 대응\"),\n",
" _order(\"Q31\", \"교전통제\", 3, \"통제 순서: A 식별 확인, B 상급 승인 요청, C 승인 수령, D 조치 기록\"),\n",
" _order(\"Q32\", \"피아식별\", 3, \"확인 순서: A 운항정보 대조, B 식별 신호 확인, C 미확인 분류, D 보고\"),\n",
"]\n",
"QIDX = {q[\"qid\"]: q for q in BANK}\n",
"UNITS = [\"가상1중대\", \"가상2중대\", \"가상3중대\"]\n",
"_CIRC = \"①②③④\"\n",
"PARA = {\"Q21\": \"아군적군 구분\", \"Q22\": \"세모 날개\", \"Q23\": \"추정됨으로 표기\", \"Q24\": \"중대장(지휘관)\", \"Q25\": \"잘못된 경보\",\n",
" \"Q26\": \"민간 여객기\", \"Q27\": \"4발 드론\", \"Q28\": \"지휘 라인\"} # 뜻은 맞지만 인정 답안 목록에 없는 표현\n",
"\n",
"\n",
"def make_soldiers(n_per_unit, seed):\n",
" rng = np.random.default_rng(seed)\n",
" out = []\n",
" for u, unit in enumerate(UNITS):\n",
" base = [0.3, -0.1, 0.0][u]\n",
" for i in range(n_per_unit):\n",
" off = rng.normal(0, 0.5, len(AREAS)) + (np.array([0, 0, 0, -0.6, 0]) if u == 1 else 0)\n",
" out.append({\"sid\": f\"S{len(out) + 1:03d}\", \"unit\": unit, \"theta\": dict(zip(AREAS, base + rng.normal(0, 0.6) + off)),\n",
" \"tend\": dict(zip(ETYPES, rng.dirichlet([1.2, 1.2, 1.2])))})\n",
" return out\n",
"\n",
"\n",
"def p_correct(theta, diff):\n",
" return 1 / (1 + np.exp(-1.7 * (theta - (diff - 2) * 0.9 + 0.9)))\n",
"\n",
"\n",
"def _typo(s, rng):\n",
" if len(s) < 2:\n",
" return s\n",
" i = int(rng.integers(len(s) - 1))\n",
" 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:]\n",
"\n",
"\n",
"def _fmt_mc(k, rng):\n",
" r = rng.random()\n",
" if r < 0.55:\n",
" return str(k)\n",
" return rng.choice([_CIRC[k - 1], f\"{k}번\", \"ABCD\"[k - 1], \"abcd\"[k - 1] + \")\", f\" {k} \", chr(0xFF10 + k), f\"{k}.\"])\n",
"\n",
"\n",
"def _fmt_sa(s, rng):\n",
" r = rng.random()\n",
" if r < 0.45:\n",
" return s\n",
" if r < 0.6:\n",
" return \" \".join(s) if len(s) <= 4 else s[:2] + \" \" + s[2:]\n",
" if r < 0.72:\n",
" return s + rng.choice([\"입니다\", \"이다\", \".\", \"임\"])\n",
" if r < 0.8:\n",
" return s.upper() if s.isascii() else \"\".join(chr(ord(ch) + 0xFEE0) if \"!\" <= ch <= \"~\" else ch for ch in s) + \" \"\n",
" return _typo(s, rng)\n",
"\n",
"\n",
"def answer(q, sol, rng):\n",
" \"\"\"한 문항 응답: (원본 응답 문자열, 의도한 정답 여부, 의도한 답, 오답 유형).\"\"\"\n",
" ok = rng.random() < p_correct(sol[\"theta\"][q[\"area\"]], q[\"diff\"])\n",
" if q[\"type\"] == \"MC\":\n",
" if ok:\n",
" k, et = q[\"answer\"], None\n",
" else:\n",
" ks = list(q[\"dist\"]); w = np.array([sol[\"tend\"][q[\"dist\"][k]] for k in ks])\n",
" k = int(rng.choice(ks, p=w / w.sum())); et = q[\"dist\"][k]\n",
" return _fmt_mc(k, rng), ok, str(k), et\n",
" if q[\"type\"] == \"SA\":\n",
" if ok and rng.random() < 0.06:\n",
" return PARA[q[\"qid\"]], ok, PARA[q[\"qid\"]], None\n",
" if ok:\n",
" s, et = str(rng.choice(q[\"accept\"])), None\n",
" else:\n",
" ws = list(q[\"wrong\"]); w = np.array([sol[\"tend\"][q[\"wrong\"][x]] for x in ws])\n",
" s = str(rng.choice(ws, p=w / w.sum())); et = q[\"wrong\"][s]\n",
" return _fmt_sa(s, rng), ok, s, et\n",
" if ok:\n",
" s = \"ABCD\"\n",
" elif rng.random() < 0.5:\n",
" s = \"ABCD\".replace(\"ABCD\"[rng.integers(1, 4)], \"\") # 단계 하나 빠뜨림\n",
" else:\n",
" i = int(rng.integers(0, 3)); s = list(\"ABCD\"); s[i], s[i + 1] = s[i + 1], s[i]; s = \"\".join(s) # 순서 뒤바뀜\n",
" raw = s if rng.random() < 0.6 else rng.choice([\"-\".join(s), \" \".join(s), s.lower(), \",\".join(s)])\n",
" return raw, ok, s, (None if ok else P)\n",
"\n",
"\n",
"def make_responses(n_per_unit=40, seed=7, blank=0.02):\n",
" \"\"\"응시자 x 문항 응답표(DataFrame)를 만든다. truth_* 열은 교관 기준 정답(평가용)이다.\"\"\"\n",
" rng = np.random.default_rng(seed)\n",
" rows = []\n",
" for sol in make_soldiers(n_per_unit, seed):\n",
" for q in BANK:\n",
" raw, ok, intended, et = answer(q, sol, rng)\n",
" if rng.random() < blank:\n",
" raw, ok, intended, et = \"\", False, \"\", None\n",
" rows.append({\"rid\": f\"R{len(rows):05d}\", \"sid\": sol[\"sid\"], \"unit\": sol[\"unit\"], \"qid\": q[\"qid\"], \"raw\": raw,\n",
" \"truth_correct\": ok, \"truth_answer\": intended, \"truth_etype\": et})\n",
" return pd.DataFrame(rows)\n",
"# ---------------------------------------------------------------------------\n",
"\n",
"# ---- 응답 정제·파싱·채점 공통 함수 ----\n",
"TAILS = (\"입니다\", \"이다\", \"임\")\n",
"\n",
"\n",
"def norm_text(s):\n",
" \"\"\"전각->반각(NFKC), 소문자, 공백·문장부호 제거, 끝맺음 어미 제거.\"\"\"\n",
" s = unicodedata.normalize(\"NFKC\", str(s)).strip().lower()\n",
" s = re.sub(r\"[\\s\\.\\,\\-\\(\\)·]\", \"\", s)\n",
" for t in TAILS:\n",
" if s.endswith(t) and len(s) > len(t) + 1:\n",
" s = s[:-len(t)]\n",
" return s\n",
"\n",
"\n",
"def parse(q, raw):\n",
" \"\"\"원본 응답 -> (정제값, 상태). 상태: 정상 / 미응답 / 형식오류\"\"\"\n",
" if raw is None or str(raw).strip() == \"\":\n",
" return \"\", \"미응답\"\n",
" s = unicodedata.normalize(\"NFKC\", str(raw)).strip()\n",
" if q[\"type\"] == \"MC\":\n",
" t = re.sub(r\"[\\s번\\.\\)]\", \"\", s).lower()\n",
" t = {\"a\": \"1\", \"b\": \"2\", \"c\": \"3\", \"d\": \"4\"}.get(t, t)\n",
" return (t, \"정상\") if t in (\"1\", \"2\", \"3\", \"4\") else (t, \"형식오류\")\n",
" if q[\"type\"] == \"ORDER\":\n",
" t = re.sub(r\"[^A-Za-z]\", \"\", s).upper()\n",
" return (t, \"정상\") if t and set(t) <= set(\"ABCD\") and len(set(t)) == len(t) else (t, \"형식오류\")\n",
" return norm_text(s), \"정상\"\n",
"\n",
"\n",
"def grade_exact(q, val):\n",
" if q[\"type\"] == \"MC\":\n",
" return val == str(q[\"answer\"])\n",
" if q[\"type\"] == \"ORDER\":\n",
" return val == q[\"answer\"]\n",
" return val in {norm_text(a) for a in q[\"accept\"]}\n",
"\n",
"\n",
"def grade_fuzzy(q, val, cut=0.75):\n",
" \"\"\"단답형은 오탈자(글자 순서 바뀜·한 글자 누락)를 허용(유사도 cut 이상)하되, 알려진 오답과 더 가까우면 오답으로 본다.\"\"\"\n",
" if grade_exact(q, val) or q[\"type\"] != \"SA\" or len(val) < 2:\n",
" return grade_exact(q, val)\n",
" 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\"])\n",
" worst = max(difflib.SequenceMatcher(None, val, norm_text(w)).ratio() for w in q[\"wrong\"])\n",
" return best >= cut and best > worst\n",
"\n",
"\n",
"def score_of(q, correct):\n",
" return POINTS[q[\"diff\"]] if correct else 0\n",
"\n",
"# ---- 오답 유형 분류기(규칙 + 문자 n-gram 로지스틱 회귀) ----\n",
"SEED = {\"피아 오인\": [\"적기\", \"적 드론\", \"아군기\", \"적 헬기\", \"적기판정\", \"헬기\", \"적 항공기 확정\", \"아군 드론\"],\n",
" \"절차 누락\": [\"생략\", \"인접부대\", \"개인메신저\", \"보고 안 함\", \"운용병\", \"미확인기\", \"단계 건너뜀\", \"직접 전파\"],\n",
" \"개념 혼동\": [\"직선익\", \"후퇴익\", \"헥사콥터\", \"미탐\", \"재경보\", \"확정\", \"확인\", \"레이더\", \"AI\", \"표적획득\", \"경계\", \"통신망\", \"고정익\"]}\n",
"\n",
"\n",
"def build_text_model():\n",
" texts, labels = [], []\n",
" for q in BANK:\n",
" if q[\"type\"] == \"SA\":\n",
" for w, et in q[\"wrong\"].items():\n",
" texts.append(w); labels.append(et)\n",
" for et, ws in SEED.items():\n",
" texts += ws; labels += [et] * len(ws)\n",
" m = make_pipeline(TfidfVectorizer(analyzer=\"char_wb\", ngram_range=(1, 3)), LogisticRegression(max_iter=2000, C=5))\n",
" return m.fit([norm_text(t) for t in texts], labels)\n",
"\n",
"\n",
"def classify_error(q, val, text_model):\n",
" \"\"\"오답 1건의 유형과 판단 근거(rule/text)를 돌려준다.\"\"\"\n",
" if q[\"type\"] == \"MC\":\n",
" return q[\"dist\"].get(int(val), \"개념 혼동\") if val in \"1234\" and val else \"개념 혼동\", \"보기 매핑\"\n",
" if q[\"type\"] == \"ORDER\":\n",
" return \"절차 누락\", (\"단계 누락\" if len(val) < 4 else \"순서 뒤바뀜\")\n",
" known = {norm_text(w): et for w, et in q[\"wrong\"].items()}\n",
" if val in known:\n",
" return known[val], \"오답 목록\"\n",
" near = max(known, key=lambda w: difflib.SequenceMatcher(None, val, w).ratio())\n",
" if difflib.SequenceMatcher(None, val, near).ratio() >= 0.75 or sorted(val) == sorted(near):\n",
" return known[near], \"오답 목록(오탈자)\"\n",
" return str(text_model.predict([val])[0]), \"문자 모델\"\n",
"\n",
"PASS_RATE = 0.7\n",
"ALERT_AREA = 0.55 # 중대 영역 정답률이 이보다 낮으면 경보 하이라이트\n",
"TM = build_text_model()\n",
"\n",
"\n",
"def grade_batch(df):\n",
" \"\"\"원본 응답 묶음 -> 정제값·정답 여부·점수·오답 유형 열 추가.\"\"\"\n",
" out = df.copy()\n",
" pv = [parse(QIDX[q], r) for q, r in zip(out[\"qid\"], out[\"raw\"])]\n",
" out[\"val\"], out[\"status\"] = [p[0] for p in pv], [p[1] for p in pv]\n",
" out[\"area\"] = out[\"qid\"].map(lambda q: QIDX[q][\"area\"])\n",
" out[\"correct\"] = [s == \"정상\" and grade_fuzzy(QIDX[q], v) for q, v, s in zip(out[\"qid\"], out[\"val\"], out[\"status\"])]\n",
" out[\"score\"] = [score_of(QIDX[q], c) for q, c in zip(out[\"qid\"], out[\"correct\"])]\n",
" out[\"etype\"] = [None if c or s != \"정상\" else classify_error(QIDX[q], v, TM)[0]\n",
" for q, v, c, s in zip(out[\"qid\"], out[\"val\"], out[\"correct\"], out[\"status\"])]\n",
" return out\n",
"\n",
"\n",
"class Board:\n",
" \"\"\"대시보드 상태: 누적 집계만 갱신해 매번 전체를 다시 계산하지 않는다.\"\"\"\n",
" def __init__(self):\n",
" self.data = pd.DataFrame()\n",
"\n",
" def update(self, batch):\n",
" self.data = pd.concat([self.data, grade_batch(batch)], ignore_index=True)\n",
" d = self.data\n",
" self.area = d.pivot_table(index=\"unit\", columns=\"area\", values=\"correct\", aggfunc=\"mean\")\n",
" self.etype = d.dropna(subset=[\"etype\"]).groupby([\"unit\", \"etype\"]).size().unstack(fill_value=0)\n",
" self.person = d.groupby([\"sid\", \"unit\"])[\"score\"].sum().reset_index()\n",
" self.item = d.groupby(\"qid\")[\"correct\"].mean()\n",
"\n",
" def query(self, unit=None, area=None):\n",
" d = self.data\n",
" if unit:\n",
" d = d[d[\"unit\"] == unit]\n",
" if area:\n",
" d = d[d[\"area\"] == area]\n",
" return d.groupby(\"qid\")[\"correct\"].mean().sort_values().head(3)\n",
"\n",
"\n",
"full = make_responses(40, seed=7)\n",
"FULL_SCORE = sum(POINTS[q[\"diff\"]] for q in BANK)\n",
"board = Board()\n",
"# 1) 실시간 갱신 모의: 응시자 응답이 30명 단위로 4번 들어온다\n",
"times = []\n",
"for k, sids in enumerate(np.array_split(full[\"sid\"].unique(), 4), 1):\n",
" t0 = time.perf_counter()\n",
" board.update(full[full[\"sid\"].isin(sids)])\n",
" times.append((time.perf_counter() - t0) * 1000)\n",
" print(f\"갱신 {k}: 누적 응답 {len(board.data):,}건, 집계 갱신 {times[-1]:.0f} ms\")\n",
"print(f\"최대 갱신 시간 {max(times):.0f} ms (목표 1초 이내: {'달성' if max(times) < 1000 else '미달'})\")\n",
"\n",
"# 2) 필터 조회 응답성(교수자가 중대·영역을 바꿔 가며 조회)\n",
"q_times = []\n",
"for u in UNITS + [None]:\n",
" for a in AREAS + [None]:\n",
" t0 = time.perf_counter(); board.query(u, a); q_times.append((time.perf_counter() - t0) * 1000)\n",
"print(f\"필터 조회 {len(q_times)}회, 평균 {np.mean(q_times):.1f} ms, 최대 {max(q_times):.1f} ms (목표 100 ms 이내: {'달성' if max(q_times) < 100 else '미달'})\")\n",
"\n",
"# 3) 대시보드 구성요소 4종\n",
"fig, ax = plt.subplots(2, 2, figsize=(12, 8))\n",
"AREA_EN = {\"형상식별\": \"Shape\", \"피아식별\": \"IFF\", \"경보보고\": \"Report\", \"교전통제\": \"Control\", \"대드론일반\": \"C-UAS\"}\n",
"UNIT_EN = {\"가상1중대\": \"Co.1\", \"가상2중대\": \"Co.2\", \"가상3중대\": \"Co.3\"}\n",
"ET_EN = {\"개념 혼동\": \"Concept\", \"피아 오인\": \"IFF error\", \"절차 누락\": \"Procedure\"}\n",
"A = board.area[AREAS]\n",
"im = ax[0, 0].imshow(A.values, cmap=\"RdYlGn\", vmin=0.3, vmax=1.0)\n",
"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])\n",
"for i in range(A.shape[0]):\n",
" for j in range(A.shape[1]):\n",
" v = A.values[i, j]\n",
" ax[0, 0].text(j, i, f\"{v:.2f}\", ha=\"center\", va=\"center\", fontweight=\"bold\" if v < ALERT_AREA else \"normal\",\n",
" color=\"white\" if v < ALERT_AREA else \"black\")\n",
"ax[0, 0].set_title(\"Accuracy by company x area (bold = alert)\"); fig.colorbar(im, ax=ax[0, 0])\n",
"E = board.etype.rename(index=UNIT_EN, columns=ET_EN)\n",
"E.div(E.sum(axis=1), axis=0).plot.bar(stacked=True, ax=ax[0, 1], rot=0)\n",
"ax[0, 1].set_title(\"Error type share by company\"); ax[0, 1].set_ylabel(\"share\")\n",
"for u in UNITS:\n",
" ax[1, 0].hist(board.person.loc[board.person[\"unit\"] == u, \"score\"], bins=12, alpha=0.5, label=UNIT_EN[u])\n",
"ax[1, 0].axvline(FULL_SCORE * PASS_RATE, color=\"k\", ls=\"--\", label=\"pass line\"); ax[1, 0].legend()\n",
"ax[1, 0].set_title(\"Score distribution\"); ax[1, 0].set_xlabel(\"score\")\n",
"it = board.item.sort_values()\n",
"ax[1, 1].barh(it.index[:10], it.values[:10], color=\"tab:red\"); ax[1, 1].set_xlim(0, 1)\n",
"ax[1, 1].set_title(\"10 hardest items (accuracy)\")\n",
"plt.tight_layout(); plt.show()\n",
"print(\"대시보드 구성요소 4종 렌더링: 중대x영역 정답률, 오답 유형 비율, 점수 분포, 고난도 문항\")\n",
"\n",
"# 4) 요약 지표와 경보 하이라이트\n",
"p = board.person.assign(passed=board.person[\"score\"] >= FULL_SCORE * PASS_RATE)\n",
"print(\"\\n[중대별 요약]\")\n",
"print(p.groupby(\"unit\").agg(응시=(\"sid\", \"size\"), 평균점수=(\"score\", \"mean\"), 합격률=(\"passed\", \"mean\")).round(2).to_string())\n",
"alerts = [(u, a, A.loc[u, a]) for u in A.index for a in AREAS if A.loc[u, a] < ALERT_AREA]\n",
"print(f\"\\n[경보 하이라이트] 영역 정답률 {ALERT_AREA} 미만 {len(alerts)}건\")\n",
"for u, a, v in sorted(alerts, key=lambda x: x[2]):\n",
" print(f\" - {u} {a}: 정답률 {v:.2f}, 최저 문항 {list(board.query(u, a).index)}\")\n",
"top = board.etype.sum().sort_values(ascending=False)\n",
"print(\"부대 전체 오답 유형:\", {k: int(v) for k, v in top.items()})\n",
"print(\"야간 교육장 사용 시 plt.style.use('dark_background')로 어두운 화면 모드를 적용할 수 있다.\")\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"<details>\n",
"<summary><b>💡 [참고] 미션 06 예상 실행 결과 (Output)</b></summary>\n",
"\n",
"```text\n",
"갱신 1: 누적 응답 960건, 집계 갱신 39 ms\n",
"갱신 2: 누적 응답 1,920건, 집계 갱신 35 ms\n",
"갱신 3: 누적 응답 2,880건, 집계 갱신 39 ms\n",
"갱신 4: 누적 응답 3,840건, 집계 갱신 38 ms\n",
"최대 갱신 시간 39 ms (목표 1초 이내: 달성)\n",
"필터 조회 24회, 평균 2.7 ms, 최대 3.4 ms (목표 100 ms 이내: 달성)\n",
"대시보드 구성요소 4종 렌더링: 중대x영역 정답률, 오답 유형 비율, 점수 분포, 고난도 문항\n",
"\n",
"[중대별 요약]\n",
" 응시 평균점수 합격률\n",
"unit \n",
"가상1중대 40 75.40 0.68\n",
"가상2중대 40 58.55 0.28\n",
"가상3중대 40 68.18 0.57\n",
"\n",
"[경보 하이라이트] 영역 정답률 0.55 미만 1건\n",
" - 가상2중대 교전통제: 정답률 0.50, 최저 문항 ['Q31', 'Q15', 'Q14']\n",
"부대 전체 오답 유형: {'절차 누락': 419, '개념 혼동': 367, '피아 오인': 186}\n",
"야간 교육장 사용 시 plt.style.use('dark_background')로 어두운 화면 모드를 적용할 수 있다.\n",
"```\n",
"</details>\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"---\n",
"\n",
"## 🎯 미션 07. 이의 제기 문항 및 서술형 답변에 대한 교관 수동 채점(HITL) 검토 인터페이스 설계\n",
"**소요시간**: 약 3.0시간\n",
"\n",
"### 📋 미션 배경 및 수행 지침\n",
"#### 미션 배경 및 당위성\n",
"\n",
"이의 제기 문항 및 서술형 답변에 대한 교관 수동 채점(HITL) 인터페이스를 설계한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **이의 제기 문항과 서술형 답변을 교관이 검토·확정하는 HITL 흐름을 설계하고, 자동 채점이 점수를 단정하지 못하게 한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
"\n",
"이 문제를 해결하기 위해 본 미션에서는 **서술형은 핵심 요소 키워드로 점수를 '제안'만 하고, 이의 제기와 함께 합격선 근처·저신뢰 답안부터 검토하는 우선순위 대기열에 올린다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n",
"\n",
"학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
"\n",
"**이번 결과물:** `src/hitl/trn02_approval_workflow.py 및 docs/trn02_hitl_guide.md` 형태의 결과물을 완성한다.\n",
"\n",
"### 🔍 평가 및 검증 기준 (PASS 조건)\n",
"#### 미션 판정\n",
"\n",
"**평가 구분:** 필수\n",
"\n",
"**핵심 평가영역:** 지휘 책임 분계선 명확화 및 성급한 자동 단정 방지 체계 부합성\n",
"\n",
"#### 미션 PASS 조건\n",
"\n",
"- 정량 기준: 인간 검토 큐(Queue) 대기시간 및 승인/반려 처리 로깅 성공률 100%\n",
"\n",
"\n",
"- 진행 기준: 승인/반려 인터페이스 동작 및 감사 로그(Audit Log) 생성 검증\n",
"\n",
"\n",
"- 검증 방법: 모의 조작자 승인/반려 시뮬레이션 및 데이터 무결성 검증\n",
"\n",
"\n",
"#### 채점자 판정 메모\n",
"\n",
"> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n",
"\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"\"\"\"이의 제기된 단답형 문항과 서술형 답변을 교관 검토 대기열(HITL)에 올려, 자동 채점은 '제안'으로만 두고 교관의 인정·기각·수정 결정과 사유를 해시 체인 감사 로그에 남기는 검토 흐름을 설계한다. 합격선 근처 응시자를 먼저 검토하는 우선순위 큐의 대기시간, 로깅 성공률, 무결성 검증을 확인한다.\"\"\"\n",
"import difflib\n",
"import hashlib\n",
"import heapq\n",
"import json\n",
"import re\n",
"import unicodedata\n",
"import numpy as np\n",
"import pandas as pd\n",
"\n",
"# ---------------------------------------------------------------------------\n",
"# [교육용가상자료] 방공·드론 식별 퀴즈 문항 은행과 가상 응시자 응답 생성기\n",
"# 문항은 일반 원칙 수준의 가상 문항이며 실제 교범·교전규칙 원문이 아니다. 응시자는 가명(S001~)이다.\n",
"# ---------------------------------------------------------------------------\n",
"AREAS = [\"형상식별\", \"피아식별\", \"경보보고\", \"교전통제\", \"대드론일반\"]\n",
"ETYPES = [\"개념 혼동\", \"피아 오인\", \"절차 누락\"]\n",
"POINTS = {1: 2, 2: 3, 3: 5}\n",
"C, F, P = ETYPES\n",
"\n",
"\n",
"def _mc(qid, area, diff, stem, opts, ans, dist):\n",
" # [TODO : 학생 실습 7] 이의 제기 문항 및 서술형 답변에 대한 교관 수동 채점(HITL) 검토 인터페이스 설계 관련 핵심 로직을 구현하세요.\n",
" # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n",
" # <<< 여기에 코드를 작성하세요 >>>\n",
" pass # 작성 후 pass를 제거하세요.\n",
"def _sa(qid, area, diff, stem, accept, wrong):\n",
" # [TODO : 학생 실습 7] 이의 제기 문항 및 서술형 답변에 대한 교관 수동 채점(HITL) 검토 인터페이스 설계 관련 핵심 로직을 구현하세요.\n",
" # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n",
" # <<< 여기에 코드를 작성하세요 >>>\n",
" pass # 작성 후 pass를 제거하세요.\n",
"def _order(qid, area, diff, stem):\n",
" return {\"qid\": qid, \"type\": \"ORDER\", \"area\": area, \"diff\": diff, \"stem\": stem, \"answer\": \"ABCD\"}\n",
"\n",
"\n",
"BANK = [\n",
" _mc(\"Q01\", \"형상식별\", 1, \"꼬리날개 없이 삼각형 날개를 가진 소형 고정익 실루엣은?\", [\"쿼드콥터형\", \"삼각익 고정익형\", \"회전익 헬기형\", \"조류\"], 2, [C, C, F]),\n",
" _mc(\"Q02\", \"형상식별\", 1, \"회전날개 4개가 십자로 배치된 실루엣은?\", [\"고정익 드론형\", \"쿼드콥터형\", \"순항미사일형\", \"탄도미사일형\"], 2, [C, C, C]),\n",
" _mc(\"Q03\", \"형상식별\", 2, \"가늘고 긴 동체 중간에 작은 날개가 붙은 실루엣은?\", [\"순항미사일형\", \"탄도미사일형\", \"민간 경비행기형\", \"조류\"], 1, [C, F, F]),\n",
" _mc(\"Q04\", \"형상식별\", 3, \"원거리라 실루엣 식별이 어려울 때 먼저 할 행동은?\", [\"추정 유형으로 확정 보고\", \"관측 정보를 그대로 보고하고 추가 관측\", \"관측 중단\", \"민간기로 간주\"], 2, [P, P, F]),\n",
" _mc(\"Q05\", \"피아식별\", 2, \"식별 신호 응답이 없는 항공기에 대한 올바른 판단은?\", [\"즉시 적기로 확정\", \"미확인 항공기로 분류하고 추가 확인\", \"아군기로 간주\", \"무시\"], 2, [F, F, P]),\n",
" _mc(\"Q06\", \"피아식별\", 2, \"운항 정보와 일치하는 저속 비행체의 분류는?\", [\"적 드론\", \"민간기 가능성 높음, 추가 확인\", \"순항미사일\", \"아군 정찰기로 확정\"], 2, [F, C, F]),\n",
" _mc(\"Q07\", \"피아식별\", 3, \"아군 드론 운용 공지가 있는 공역에서 미확인 드론을 보면 우선 확인할 것은?\", [\"기상 정보\", \"아군 운용 계획과 식별 신호\", \"대응 준비 상태\", \"주변 지형\"], 2, [C, P, C]),\n",
" _mc(\"Q08\", \"피아식별\", 3, \"조류 무리와 소형 드론 군집 구분에 가장 도움이 되는 정보는?\", [\"크기만 비교\", \"궤적의 규칙성과 열 신호\", \"색상\", \"관측 시각\"], 2, [C, C, C]),\n",
" _mc(\"Q09\", \"경보보고\", 1, \"공중 위협 최초 보고에 포함하지 않아도 되는 것은?\", [\"관측 시각\", \"방향과 거리\", \"추정 유형\", \"관측자 개인 연락처\"], 4, [P, P, P]),\n",
" _mc(\"Q10\", \"경보보고\", 2, \"경보 전파 후 상황이 바뀌면?\", [\"최초 보고 유지\", \"변경 사항을 즉시 추가 보고\", \"상황 종료 후 일괄 보고\", \"개인 판단으로 경보 해제\"], 2, [P, P, P]),\n",
" _mc(\"Q11\", \"경보보고\", 2, \"보고 내용이 불확실할 때 올바른 표현은?\", [\"확정 표현으로 보고\", \"'추정'임을 밝히고 근거와 함께 보고\", \"보고 보류\", \"다른 관측자 보고를 복사\"], 2, [C, P, P]),\n",
" _mc(\"Q12\", \"경보보고\", 1, \"드론 탐지 후 보고 경로로 알맞은 것은?\", [\"인접 부대에만 전파\", \"지휘계통을 따라 보고\", \"개인 메신저로 공유\", \"보고 없이 대응\"], 2, [P, P, P]),\n",
" _mc(\"Q13\", \"교전통제\", 1, \"AI 탐지 결과만으로 대응을 결정할 수 있는가?\", [\"가능하다\", \"인간 지휘관의 승인이 필요하다\", \"신뢰도 90% 이상이면 가능\", \"야간에만 가능\"], 2, [C, C, C]),\n",
" _mc(\"Q14\", \"교전통제\", 2, \"민간 피해 가능성이 확인되면?\", [\"그대로 진행\", \"즉시 보고하고 지휘관 판단을 받는다\", \"조치 후 보고\", \"무시\"], 2, [P, P, F]),\n",
" _mc(\"Q15\", \"교전통제\", 3, \"식별이 불확실한 표적에 대한 원칙은?\", [\"위협으로 간주해 즉시 대응\", \"식별 확인 전까지 승인 절차를 따른다\", \"무조건 무시\", \"다수결로 결정\"], 2, [F, F, C]),\n",
" _mc(\"Q16\", \"교전통제\", 2, \"판단 근거를 기록해야 하는 이유는?\", [\"형식상 필요\", \"사후 검토와 책임 추적\", \"보고 생략\", \"기록 불필요\"], 2, [C, P, P]),\n",
" _mc(\"Q17\", \"대드론일반\", 1, \"대드론 체계의 일반적 처리 순서는?\", [\"대응-탐지-식별\", \"탐지-식별-보고·판단-대응\", \"식별-대응-탐지\", \"보고-탐지-대응\"], 2, [P, P, P]),\n",
" _mc(\"Q18\", \"대드론일반\", 3, \"레이더만으로 조류와 드론 구분이 어려운 이유는?\", [\"반사 신호 크기가 비슷할 수 있어서\", \"레이더는 야간에 못 써서\", \"드론에는 금속이 없어서\", \"조류는 탐지되지 않아서\"], 1, [C, C, C]),\n",
" _mc(\"Q19\", \"대드론일반\", 2, \"전자광학(EO) 센서의 약점이 커지는 조건은?\", [\"맑은 주간\", \"안개·야간\", \"저속 표적\", \"근거리\"], 2, [C, C, C]),\n",
" _mc(\"Q20\", \"대드론일반\", 2, \"경보가 반복해서 오경보로 판명되면?\", [\"경보 장치를 끈다\", \"사례를 기록·보고하고 기준 재검토 요청\", \"무시한다\", \"모두 실제 위협으로 간주\"], 2, [P, P, F]),\n",
" _sa(\"Q21\", \"피아식별\", 1, \"아군·적·민간을 구분하는 절차를 무엇이라 하는가?\", [\"피아식별\", \"IFF\"], {\"표적획득\": C, \"적기판정\": F, \"경계\": C}),\n",
" _sa(\"Q22\", \"형상식별\", 2, \"꼬리날개 없는 삼각형 날개 형태를 무엇이라 하는가?\", [\"삼각익\", \"델타익\", \"delta\"], {\"직선익\": C, \"후퇴익\": C, \"쿼드콥터\": C}),\n",
" _sa(\"Q23\", \"경보보고\", 2, \"불확실한 관측을 보고할 때 붙이는 표현은?\", [\"추정\"], {\"확정\": C, \"확인\": C, \"생략\": P}),\n",
" _sa(\"Q24\", \"교전통제\", 1, \"AI 탐지 결과에 대해 최종 결정을 내리는 주체는?\", [\"지휘관\"], {\"AI\": C, \"운용병\": P, \"레이더\": C}),\n",
" _sa(\"Q25\", \"대드론일반\", 1, \"조류를 드론으로 잘못 경보하는 것을 무엇이라 하는가?\", [\"오경보\", \"오탐\", \"false alarm\"], {\"미탐\": C, \"재경보\": C, \"적기\": F}),\n",
" _sa(\"Q26\", \"피아식별\", 2, \"운항 정보가 확인된 여객기는 어떻게 분류하는가?\", [\"민간기\", \"민항기\"], {\"적기\": F, \"미확인기\": P, \"아군기\": F}),\n",
" _sa(\"Q27\", \"형상식별\", 1, \"회전날개 4개를 가진 드론 형태는?\", [\"쿼드콥터\", \"quadcopter\"], {\"헥사콥터\": C, \"고정익\": C, \"헬기\": F}),\n",
" _sa(\"Q28\", \"경보보고\", 1, \"보고는 무엇을 따라 올라가는가?\", [\"지휘계통\"], {\"인접부대\": P, \"개인메신저\": P, \"통신망\": C}),\n",
" _order(\"Q29\", \"경보보고\", 2, \"최초 보고 순서: A 관측 시각, B 방향·거리, C 추정 유형, D 관측자 조치\"),\n",
" _order(\"Q30\", \"대드론일반\", 2, \"처리 순서: A 탐지, B 식별, C 보고·판단, D 대응\"),\n",
" _order(\"Q31\", \"교전통제\", 3, \"통제 순서: A 식별 확인, B 상급 승인 요청, C 승인 수령, D 조치 기록\"),\n",
" _order(\"Q32\", \"피아식별\", 3, \"확인 순서: A 운항정보 대조, B 식별 신호 확인, C 미확인 분류, D 보고\"),\n",
"]\n",
"QIDX = {q[\"qid\"]: q for q in BANK}\n",
"UNITS = [\"가상1중대\", \"가상2중대\", \"가상3중대\"]\n",
"_CIRC = \"①②③④\"\n",
"PARA = {\"Q21\": \"아군적군 구분\", \"Q22\": \"세모 날개\", \"Q23\": \"추정됨으로 표기\", \"Q24\": \"중대장(지휘관)\", \"Q25\": \"잘못된 경보\",\n",
" \"Q26\": \"민간 여객기\", \"Q27\": \"4발 드론\", \"Q28\": \"지휘 라인\"} # 뜻은 맞지만 인정 답안 목록에 없는 표현\n",
"\n",
"\n",
"def make_soldiers(n_per_unit, seed):\n",
" rng = np.random.default_rng(seed)\n",
" out = []\n",
" for u, unit in enumerate(UNITS):\n",
" base = [0.3, -0.1, 0.0][u]\n",
" for i in range(n_per_unit):\n",
" off = rng.normal(0, 0.5, len(AREAS)) + (np.array([0, 0, 0, -0.6, 0]) if u == 1 else 0)\n",
" out.append({\"sid\": f\"S{len(out) + 1:03d}\", \"unit\": unit, \"theta\": dict(zip(AREAS, base + rng.normal(0, 0.6) + off)),\n",
" \"tend\": dict(zip(ETYPES, rng.dirichlet([1.2, 1.2, 1.2])))})\n",
" return out\n",
"\n",
"\n",
"def p_correct(theta, diff):\n",
" return 1 / (1 + np.exp(-1.7 * (theta - (diff - 2) * 0.9 + 0.9)))\n",
"\n",
"\n",
"def _typo(s, rng):\n",
" if len(s) < 2:\n",
" return s\n",
" i = int(rng.integers(len(s) - 1))\n",
" 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:]\n",
"\n",
"\n",
"def _fmt_mc(k, rng):\n",
" r = rng.random()\n",
" if r < 0.55:\n",
" return str(k)\n",
" return rng.choice([_CIRC[k - 1], f\"{k}번\", \"ABCD\"[k - 1], \"abcd\"[k - 1] + \")\", f\" {k} \", chr(0xFF10 + k), f\"{k}.\"])\n",
"\n",
"\n",
"def _fmt_sa(s, rng):\n",
" r = rng.random()\n",
" if r < 0.45:\n",
" return s\n",
" if r < 0.6:\n",
" return \" \".join(s) if len(s) <= 4 else s[:2] + \" \" + s[2:]\n",
" if r < 0.72:\n",
" return s + rng.choice([\"입니다\", \"이다\", \".\", \"임\"])\n",
" if r < 0.8:\n",
" return s.upper() if s.isascii() else \"\".join(chr(ord(ch) + 0xFEE0) if \"!\" <= ch <= \"~\" else ch for ch in s) + \" \"\n",
" return _typo(s, rng)\n",
"\n",
"\n",
"def answer(q, sol, rng):\n",
" \"\"\"한 문항 응답: (원본 응답 문자열, 의도한 정답 여부, 의도한 답, 오답 유형).\"\"\"\n",
" ok = rng.random() < p_correct(sol[\"theta\"][q[\"area\"]], q[\"diff\"])\n",
" if q[\"type\"] == \"MC\":\n",
" if ok:\n",
" k, et = q[\"answer\"], None\n",
" else:\n",
" ks = list(q[\"dist\"]); w = np.array([sol[\"tend\"][q[\"dist\"][k]] for k in ks])\n",
" k = int(rng.choice(ks, p=w / w.sum())); et = q[\"dist\"][k]\n",
" return _fmt_mc(k, rng), ok, str(k), et\n",
" if q[\"type\"] == \"SA\":\n",
" if ok and rng.random() < 0.06:\n",
" return PARA[q[\"qid\"]], ok, PARA[q[\"qid\"]], None\n",
" if ok:\n",
" s, et = str(rng.choice(q[\"accept\"])), None\n",
" else:\n",
" ws = list(q[\"wrong\"]); w = np.array([sol[\"tend\"][q[\"wrong\"][x]] for x in ws])\n",
" s = str(rng.choice(ws, p=w / w.sum())); et = q[\"wrong\"][s]\n",
" return _fmt_sa(s, rng), ok, s, et\n",
" if ok:\n",
" s = \"ABCD\"\n",
" elif rng.random() < 0.5:\n",
" s = \"ABCD\".replace(\"ABCD\"[rng.integers(1, 4)], \"\") # 단계 하나 빠뜨림\n",
" else:\n",
" i = int(rng.integers(0, 3)); s = list(\"ABCD\"); s[i], s[i + 1] = s[i + 1], s[i]; s = \"\".join(s) # 순서 뒤바뀜\n",
" raw = s if rng.random() < 0.6 else rng.choice([\"-\".join(s), \" \".join(s), s.lower(), \",\".join(s)])\n",
" return raw, ok, s, (None if ok else P)\n",
"\n",
"\n",
"def make_responses(n_per_unit=40, seed=7, blank=0.02):\n",
" \"\"\"응시자 x 문항 응답표(DataFrame)를 만든다. truth_* 열은 교관 기준 정답(평가용)이다.\"\"\"\n",
" rng = np.random.default_rng(seed)\n",
" rows = []\n",
" for sol in make_soldiers(n_per_unit, seed):\n",
" for q in BANK:\n",
" raw, ok, intended, et = answer(q, sol, rng)\n",
" if rng.random() < blank:\n",
" raw, ok, intended, et = \"\", False, \"\", None\n",
" rows.append({\"rid\": f\"R{len(rows):05d}\", \"sid\": sol[\"sid\"], \"unit\": sol[\"unit\"], \"qid\": q[\"qid\"], \"raw\": raw,\n",
" \"truth_correct\": ok, \"truth_answer\": intended, \"truth_etype\": et})\n",
" return pd.DataFrame(rows)\n",
"# ---------------------------------------------------------------------------\n",
"\n",
"# ---- 응답 정제·파싱·채점 공통 함수 ----\n",
"TAILS = (\"입니다\", \"이다\", \"임\")\n",
"\n",
"\n",
"def norm_text(s):\n",
" \"\"\"전각->반각(NFKC), 소문자, 공백·문장부호 제거, 끝맺음 어미 제거.\"\"\"\n",
" s = unicodedata.normalize(\"NFKC\", str(s)).strip().lower()\n",
" s = re.sub(r\"[\\s\\.\\,\\-\\(\\)·]\", \"\", s)\n",
" for t in TAILS:\n",
" if s.endswith(t) and len(s) > len(t) + 1:\n",
" s = s[:-len(t)]\n",
" return s\n",
"\n",
"\n",
"def parse(q, raw):\n",
" \"\"\"원본 응답 -> (정제값, 상태). 상태: 정상 / 미응답 / 형식오류\"\"\"\n",
" if raw is None or str(raw).strip() == \"\":\n",
" return \"\", \"미응답\"\n",
" s = unicodedata.normalize(\"NFKC\", str(raw)).strip()\n",
" if q[\"type\"] == \"MC\":\n",
" t = re.sub(r\"[\\s번\\.\\)]\", \"\", s).lower()\n",
" t = {\"a\": \"1\", \"b\": \"2\", \"c\": \"3\", \"d\": \"4\"}.get(t, t)\n",
" return (t, \"정상\") if t in (\"1\", \"2\", \"3\", \"4\") else (t, \"형식오류\")\n",
" if q[\"type\"] == \"ORDER\":\n",
" t = re.sub(r\"[^A-Za-z]\", \"\", s).upper()\n",
" return (t, \"정상\") if t and set(t) <= set(\"ABCD\") and len(set(t)) == len(t) else (t, \"형식오류\")\n",
" return norm_text(s), \"정상\"\n",
"\n",
"\n",
"def grade_exact(q, val):\n",
" if q[\"type\"] == \"MC\":\n",
" return val == str(q[\"answer\"])\n",
" if q[\"type\"] == \"ORDER\":\n",
" return val == q[\"answer\"]\n",
" return val in {norm_text(a) for a in q[\"accept\"]}\n",
"\n",
"\n",
"def grade_fuzzy(q, val, cut=0.75):\n",
" \"\"\"단답형은 오탈자(글자 순서 바뀜·한 글자 누락)를 허용(유사도 cut 이상)하되, 알려진 오답과 더 가까우면 오답으로 본다.\"\"\"\n",
" if grade_exact(q, val) or q[\"type\"] != \"SA\" or len(val) < 2:\n",
" return grade_exact(q, val)\n",
" 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\"])\n",
" worst = max(difflib.SequenceMatcher(None, val, norm_text(w)).ratio() for w in q[\"wrong\"])\n",
" return best >= cut and best > worst\n",
"\n",
"\n",
"def score_of(q, correct):\n",
" return POINTS[q[\"diff\"]] if correct else 0\n",
"\n",
"# ---- 서술형 문항(10점): 핵심 요소 5개 x 2점 ----\n",
"ESSAY = \"미확인 드론을 관측했을 때 보고와 대응 절차를 서술하시오.\"\n",
"ELEM = {\"관측정보\": ([\"관측 시각과 방향, 거리를 적는다\", \"언제 어느 방향 몇 km인지 기록한다\"], [\"시각\", \"방향\", \"거리\"]),\n",
" \"추정표현\": ([\"확실하지 않으면 추정이라고 밝힌다\", \"모르는 부분은 추정임을 명시한다\"], [\"추정\"]),\n",
" \"지휘계통\": ([\"지휘계통을 따라 즉시 보고한다\", \"소대장에게 보고하고 상급으로 올린다\"], [\"지휘계통\"]),\n",
" \"승인대기\": ([\"지휘관 승인 전에는 대응하지 않는다\", \"승인이 나기 전까지 조치하지 않고 대기한다\"], [\"승인\"]),\n",
" \"근거기록\": ([\"판단 근거를 기록한다\", \"조치 내용을 일지에 남긴다\"], [\"기록\", \"근거\"])}\n",
"WRONG_SENT = [\"승인 없이 바로 대응한다\", \"기록은 상황 종료 후 생략한다\", \"확정된 적 드론으로 보고한다\"] # 키워드는 있지만 틀린 진술\n",
"\n",
"\n",
"def make_essays(sids, seed):\n",
" rng = np.random.default_rng(seed)\n",
" out = []\n",
" for s in sids:\n",
" have = [e for e in ELEM if rng.random() < 0.65]\n",
" sents = [str(rng.choice(ELEM[e][0])) for e in have]\n",
" if rng.random() < 0.2:\n",
" sents.append(str(rng.choice(WRONG_SENT)))\n",
" rng.shuffle(sents)\n",
" out.append({\"sid\": s, \"text\": \" \".join(sents), \"truth\": 2 * len(have)})\n",
" return out\n",
"\n",
"\n",
"def auto_suggest(text):\n",
" \"\"\"키워드 포함 여부로 점수를 '제안'한다(확정 아님). 부정 문맥을 구분하지 못하는 한계가 있다.\"\"\"\n",
" hit = [e for e, (_, kws) in ELEM.items() if any(k in text for k in kws)]\n",
" conf = \"낮음\" if any(w in text for w in (\"없이\", \"생략\", \"확정\")) or len(text) < 25 else \"보통\"\n",
" return 2 * len(hit), hit, conf\n",
"\n",
"\n",
"class ReviewQueue:\n",
" \"\"\"우선순위 큐 + 해시 체인 감사 로그. 교관 결정 없이는 점수를 확정할 수 없다.\"\"\"\n",
" def __init__(self):\n",
" self.heap, self.items, self.log, self.final = [], {}, [], {}\n",
"\n",
" def _audit(self, event, **kw):\n",
" prev = self.log[-1][\"hash\"] if self.log else \"GENESIS\"\n",
" body = {\"seq\": len(self.log), \"event\": event, **kw}\n",
" self.log.append({**body, \"prev\": prev, \"hash\": hashlib.sha256((prev + json.dumps(body, sort_keys=True, ensure_ascii=False)).encode()).hexdigest()})\n",
"\n",
" def submit(self, iid, prio, t, payload):\n",
" self.items[iid] = {**payload, \"t_in\": t, \"status\": \"PENDING\"}\n",
" heapq.heappush(self.heap, (prio, t, iid))\n",
" self._audit(\"SUBMIT\", iid=iid, prio=prio, suggest=payload.get(\"suggest\"))\n",
"\n",
" def pop(self):\n",
" return heapq.heappop(self.heap)[2] if self.heap else None\n",
"\n",
" def decide(self, iid, reviewer, decision, score, reason, t):\n",
" if not reason:\n",
" raise ValueError(\"사유 없는 결정은 기록할 수 없다\")\n",
" it = self.items[iid]\n",
" it.update(status=decision, score=score, reviewer=reviewer, t_out=t)\n",
" self.final[iid] = score\n",
" self._audit(\"DECIDE\", iid=iid, reviewer=reviewer, decision=decision, score=score, reason=reason)\n",
"\n",
" def finalize_without_review(self, iid):\n",
" if self.items[iid][\"status\"] == \"PENDING\":\n",
" raise PermissionError(\"교관 결정 전 자동 확정 금지\")\n",
"\n",
" def verify(self):\n",
" prev = \"GENESIS\"\n",
" for e in self.log:\n",
" body = {k: v for k, v in e.items() if k not in (\"prev\", \"hash\")}\n",
" if e[\"prev\"] != prev or hashlib.sha256((prev + json.dumps(body, sort_keys=True, ensure_ascii=False)).encode()).hexdigest() != e[\"hash\"]:\n",
" return False, e[\"seq\"]\n",
" prev = e[\"hash\"]\n",
" return True, None\n",
"\n",
"\n",
"rng = np.random.default_rng(3)\n",
"df = make_responses(40, seed=7)\n",
"df[\"val\"] = [parse(QIDX[q], r)[0] for q, r in zip(df[\"qid\"], df[\"raw\"])]\n",
"df[\"correct\"] = [grade_fuzzy(QIDX[q], v) for q, v in zip(df[\"qid\"], df[\"val\"])]\n",
"df[\"score\"] = [score_of(QIDX[q], c) for q, c in zip(df[\"qid\"], df[\"correct\"])]\n",
"total = df.groupby(\"sid\")[\"score\"].sum()\n",
"PASS = 0.7 * (sum(POINTS[q[\"diff\"]] for q in BANK) + 10)\n",
"\n",
"# 1) 이의 제기: 자동 채점 오답 처리된 단답형 중 응시자가 이의를 낸 건(실제 맞은 답은 대부분 이의 제기, 틀린 답도 일부 제기)\n",
"sa_wrong = df[(df[\"qid\"].map(lambda q: QIDX[q][\"type\"]) == \"SA\") & ~df[\"correct\"] & (df[\"raw\"] != \"\")]\n",
"appeal = sa_wrong[sa_wrong[\"truth_correct\"] | (rng.random(len(sa_wrong)) < 0.08)]\n",
"essays = make_essays(sorted(df[\"sid\"].unique()), seed=5)\n",
"print(f\"[교육용가상자료] 이의 제기 {len(appeal)}건, 서술형 답안 {len(essays)}건이 교관 검토 대기열에 들어간다.\")\n",
"\n",
"q = ReviewQueue()\n",
"t = 0.0\n",
"for e in essays:\n",
" sug, hit, conf = auto_suggest(e[\"text\"])\n",
" est = total[e[\"sid\"]] + sug\n",
" prio = (0 if abs(est - PASS) <= 6 else 1) * 10 + (0 if conf == \"낮음\" else 1) # 합격선 근처·저신뢰 먼저\n",
" q.submit(f\"E-{e['sid']}\", prio, t, {\"kind\": \"서술형\", \"sid\": e[\"sid\"], \"suggest\": sug, \"conf\": conf, \"truth\": e[\"truth\"], \"text\": e[\"text\"]})\n",
" t += 0.05\n",
"for _, r in appeal.iterrows():\n",
" est = total[r[\"sid\"]]\n",
" prio = (0 if abs(est - PASS) <= 6 else 1) * 10 + 2\n",
" q.submit(f\"A-{r['rid']}\", prio, t, {\"kind\": \"이의 제기\", \"sid\": r[\"sid\"], \"qid\": r[\"qid\"], \"raw\": r[\"raw\"], \"suggest\": 0,\n",
" \"truth\": score_of(QIDX[r[\"qid\"]], r[\"truth_correct\"])})\n",
" t += 0.05\n",
"\n",
"# 2) 자동 확정 시도는 차단되어야 한다\n",
"blocked = 0\n",
"for iid in list(q.items)[:5]:\n",
" try:\n",
" q.finalize_without_review(iid)\n",
" except PermissionError:\n",
" blocked += 1\n",
"try:\n",
" q.decide(list(q.items)[0], \"교관A\", \"인정\", 2, \"\", t)\n",
" no_reason = \"통과(오류)\"\n",
"except ValueError:\n",
" no_reason = \"차단\"\n",
"print(f\"결정 전 자동 확정 시도 5건 중 차단 {blocked}건, 사유 없는 결정 {no_reason}\")\n",
"\n",
"# 3) 교관 2명이 우선순위대로 검토(모의 교관은 기준 답안으로 판단: 건당 서술형 2분, 이의 제기 1분)\n",
"clock = {\"교관A\": 0.0, \"교관B\": 0.0}\n",
"while True:\n",
" iid = q.pop()\n",
" if iid is None:\n",
" break\n",
" it = q.items[iid]\n",
" who = min(clock, key=clock.get)\n",
" clock[who] = max(clock[who], it[\"t_in\"]) + (2.0 if it[\"kind\"] == \"서술형\" else 1.0)\n",
" if it[\"kind\"] == \"서술형\":\n",
" dec = \"승인\" if it[\"truth\"] == it[\"suggest\"] else \"수정\"\n",
" reason = \"제안 점수와 요소 판단 일치\" if dec == \"승인\" else f\"요소 재판단(제안 {it['suggest']} -> {it['truth']})\"\n",
" else:\n",
" dec = \"인정\" if it[\"truth\"] > 0 else \"기각\"\n",
" reason = f\"'{it['raw']}' 의미상 정답 인정\" if dec == \"인정\" else f\"'{it['raw']}'는 오답 유지\"\n",
" q.decide(iid, who, dec, it[\"truth\"], reason, clock[who])\n",
"\n",
"R = pd.DataFrame(q.items.values())\n",
"R[\"wait\"] = R[\"t_out\"] - R[\"t_in\"]\n",
"R[\"border\"] = [abs(total[s] + (sg if k == \"서술형\" else 0) - PASS) <= 6 for s, sg, k in zip(R[\"sid\"], R[\"suggest\"], R[\"kind\"])]\n",
"print(\"\\n[검토 결과]\")\n",
"print(R.groupby([\"kind\", \"status\"]).size().to_string())\n",
"ess = R[R[\"kind\"] == \"서술형\"]\n",
"print(f\"서술형 자동 제안 점수와 교관 판단 일치 {(ess['status'] == '승인').mean():.1%}, 수정 {(ess['status'] == '수정').sum()}건\"\n",
" f\"(틀린 진술 속 키워드에 속은 제안 포함)\")\n",
"print(f\"대기시간(분): 합격선 근처 평균 {R.loc[R['border'], 'wait'].mean():.1f}, 그 외 평균 {R.loc[~R['border'], 'wait'].mean():.1f}, 최대 {R['wait'].max():.1f}\")\n",
"\n",
"n_dec = sum(e[\"event\"] == \"DECIDE\" for e in q.log)\n",
"print(f\"승인/반려 처리 {len(R)}건, 감사 로그 기록 {n_dec}건, 로깅 성공률 {n_dec / len(R):.0%} (목표 100%: {'달성' if n_dec == len(R) else '미달'})\")\n",
"ok, _ = q.verify()\n",
"q.log[7][\"score\"] = 10 # 감사 로그 사후 변조 시도\n",
"ok2, where = q.verify()\n",
"print(f\"무결성 검증: 원본 {'정상' if ok else '이상'}, 변조 후 {'정상' if ok2 else f'이상 탐지(seq {where})'}\")\n",
"\n",
"changed = R[(R[\"kind\"] == \"이의 제기\") & (R[\"status\"] == \"인정\")].groupby(\"sid\")[\"score\"].sum()\n",
"newtot = total.add(changed, fill_value=0) + ess.set_index(\"sid\")[\"score\"]\n",
"flip = int(((total + ess.set_index(\"sid\")[\"suggest\"] >= PASS) != (newtot >= PASS)).sum())\n",
"print(f\"교관 검토로 합격/불합격이 바뀐 응시자 {flip}명 -> 자동 채점만으로 확정하면 안 되는 이유\")\n",
"print(\"\\n[HITL 검토 지침 요약] 1) 자동 점수는 제안일 뿐 교관이 확정 2) 인정·기각·수정 모두 사유 필수\"\n",
" \" 3) 합격선 근처·저신뢰 답안 우선 4) 감사 로그는 수정 불가, 이의 재심은 상급 교관(지휘 책임선)이 판단\")\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"<details>\n",
"<summary><b>💡 [참고] 미션 07 예상 실행 결과 (Output)</b></summary>\n",
"\n",
"```text\n",
"[교육용가상자료] 이의 제기 45건, 서술형 답안 120건이 교관 검토 대기열에 들어간다.\n",
"결정 전 자동 확정 시도 5건 중 차단 5건, 사유 없는 결정 차단\n",
"\n",
"[검토 결과]\n",
"kind status\n",
"서술형 수정 54\n",
" 승인 66\n",
"이의 제기 기각 7\n",
" 인정 38\n",
"서술형 자동 제안 점수와 교관 판단 일치 55.0%, 수정 54건(틀린 진술 속 키워드에 속은 제안 포함)\n",
"대기시간(분): 합격선 근처 평균 20.0, 그 외 평균 94.7, 최대 137.4\n",
"승인/반려 처리 165건, 감사 로그 기록 165건, 로깅 성공률 100% (목표 100%: 달성)\n",
"무결성 검증: 원본 정상, 변조 후 이상 탐지(seq 7)\n",
"교관 검토로 합격/불합격이 바뀐 응시자 3명 -> 자동 채점만으로 확정하면 안 되는 이유\n",
"\n",
"[HITL 검토 지침 요약] 1) 자동 점수는 제안일 뿐 교관이 확정 2) 인정·기각·수정 모두 사유 필수 3) 합격선 근처·저신뢰 답안 우선 4) 감사 로그는 수정 불가, 이의 재심은 상급 교관(지휘 책임선)이 판단\n",
"```\n",
"</details>\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"---\n",
"\n",
"## 🎯 미션 08. 전 부대원 동시 응시 모의 대규모 트래픽 채점 부하 테스트 및 최적화\n",
"**소요시간**: 약 3.5시간\n",
"\n",
"### 📋 미션 배경 및 수행 지침\n",
"#### 미션 배경 및 당위성\n",
"\n",
"전 부대원 동시 응시 모의 대규모 트래픽 채점 부하 테스트를 수행한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **전 부대원이 동시에 응시하는 상황을 모의해 채점 시스템이 대규모 제출과 장애 속에서도 멈추거나 답안을 잃지 않는지 시험한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
"\n",
"이 문제를 해결하기 위해 본 미션에서는 **평시 120명 제출량을 3배·6배·11배로 늘리고 초장문·제어문자·없는 문항 등 손상 답안과 채점기 응답 지연(장애 2%) 주입하여 처리 지연 및 복원력을 시험하는** 실습을 체계적으로 수행합니다.\n",
"\n",
"학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
"\n",
"**이번 결과물:** `tests/trn02_stress_test.py 및 스트레스 테스트 분석 보고서` 형태의 결과물을 완성한다.\n",
"\n",
"### 🔍 평가 및 검증 기준 (PASS 조건)\n",
"#### 미션 판정\n",
"\n",
"**평가 구분:** 필수\n",
"\n",
"**핵심 평가영역:** 극단 상황에서의 안전 실패(Fail-safe) 및 기능 축소 작동 적절성\n",
"\n",
"#### 미션 PASS 조건\n",
"\n",
"- 정량 기준: 동시 트래픽 500% 증가 시 시스템 다운 0건, 데이터 유실률 0%\n",
"\n",
"\n",
"- 진행 기준: 대량 시뮬레이션 부하 주입 테스트 통과 및 병목 구간 프로파일링 완료\n",
"\n",
"\n",
"- 검증 방법: 부하 테스트 도구를 통한 처리량(Throughput) 및 메모리 누수 검증\n",
"\n",
"\n",
"#### 채점자 판정 메모\n",
"\n",
"> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n",
"\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"\"\"\"전 부대원 동시 응시를 모의해 평시(120명) 대비 최대 1,000% 늘어난 제출량과 손상 답안·채점기 장애를 주입하는 채점 부하 테스트를 수행한다. 처리량, 시스템 다운, 데이터 유실을 측정하고, 장애 시 '채점 보류'로 보존하는 안전 실패 동작과 캐시 최적화 전후 처리량을 비교한다.\"\"\"\n",
"import difflib\n",
"import functools\n",
"import re\n",
"import time\n",
"import tracemalloc\n",
"import unicodedata\n",
"from collections import deque\n",
"import numpy as np\n",
"import pandas as pd\n",
"import matplotlib.pyplot as plt\n",
"\n",
"# ---------------------------------------------------------------------------\n",
"# [교육용가상자료] 방공·드론 식별 퀴즈 문항 은행과 가상 응시자 응답 생성기\n",
"# 문항은 일반 원칙 수준의 가상 문항이며 실제 교범·교전규칙 원문이 아니다. 응시자는 가명(S001~)이다.\n",
"# ---------------------------------------------------------------------------\n",
"AREAS = [\"형상식별\", \"피아식별\", \"경보보고\", \"교전통제\", \"대드론일반\"]\n",
"ETYPES = [\"개념 혼동\", \"피아 오인\", \"절차 누락\"]\n",
"POINTS = {1: 2, 2: 3, 3: 5}\n",
"C, F, P = ETYPES\n",
"\n",
"\n",
"def _mc(qid, area, diff, stem, opts, ans, dist):\n",
" # [TODO : 학생 실습 8] 전 부대원 동시 응시 모의 대규모 트래픽 채점 부하 테스트 및 최적화 관련 핵심 로직을 구현하세요.\n",
" # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n",
" # <<< 여기에 코드를 작성하세요 >>>\n",
" pass # 작성 후 pass를 제거하세요.\n",
"def _sa(qid, area, diff, stem, accept, wrong):\n",
" # [TODO : 학생 실습 8] 전 부대원 동시 응시 모의 대규모 트래픽 채점 부하 테스트 및 최적화 관련 핵심 로직을 구현하세요.\n",
" # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n",
" # <<< 여기에 코드를 작성하세요 >>>\n",
" pass # 작성 후 pass를 제거하세요.\n",
"def _order(qid, area, diff, stem):\n",
" return {\"qid\": qid, \"type\": \"ORDER\", \"area\": area, \"diff\": diff, \"stem\": stem, \"answer\": \"ABCD\"}\n",
"\n",
"\n",
"BANK = [\n",
" _mc(\"Q01\", \"형상식별\", 1, \"꼬리날개 없이 삼각형 날개를 가진 소형 고정익 실루엣은?\", [\"쿼드콥터형\", \"삼각익 고정익형\", \"회전익 헬기형\", \"조류\"], 2, [C, C, F]),\n",
" _mc(\"Q02\", \"형상식별\", 1, \"회전날개 4개가 십자로 배치된 실루엣은?\", [\"고정익 드론형\", \"쿼드콥터형\", \"순항미사일형\", \"탄도미사일형\"], 2, [C, C, C]),\n",
" _mc(\"Q03\", \"형상식별\", 2, \"가늘고 긴 동체 중간에 작은 날개가 붙은 실루엣은?\", [\"순항미사일형\", \"탄도미사일형\", \"민간 경비행기형\", \"조류\"], 1, [C, F, F]),\n",
" _mc(\"Q04\", \"형상식별\", 3, \"원거리라 실루엣 식별이 어려울 때 먼저 할 행동은?\", [\"추정 유형으로 확정 보고\", \"관측 정보를 그대로 보고하고 추가 관측\", \"관측 중단\", \"민간기로 간주\"], 2, [P, P, F]),\n",
" _mc(\"Q05\", \"피아식별\", 2, \"식별 신호 응답이 없는 항공기에 대한 올바른 판단은?\", [\"즉시 적기로 확정\", \"미확인 항공기로 분류하고 추가 확인\", \"아군기로 간주\", \"무시\"], 2, [F, F, P]),\n",
" _mc(\"Q06\", \"피아식별\", 2, \"운항 정보와 일치하는 저속 비행체의 분류는?\", [\"적 드론\", \"민간기 가능성 높음, 추가 확인\", \"순항미사일\", \"아군 정찰기로 확정\"], 2, [F, C, F]),\n",
" _mc(\"Q07\", \"피아식별\", 3, \"아군 드론 운용 공지가 있는 공역에서 미확인 드론을 보면 우선 확인할 것은?\", [\"기상 정보\", \"아군 운용 계획과 식별 신호\", \"대응 준비 상태\", \"주변 지형\"], 2, [C, P, C]),\n",
" _mc(\"Q08\", \"피아식별\", 3, \"조류 무리와 소형 드론 군집 구분에 가장 도움이 되는 정보는?\", [\"크기만 비교\", \"궤적의 규칙성과 열 신호\", \"색상\", \"관측 시각\"], 2, [C, C, C]),\n",
" _mc(\"Q09\", \"경보보고\", 1, \"공중 위협 최초 보고에 포함하지 않아도 되는 것은?\", [\"관측 시각\", \"방향과 거리\", \"추정 유형\", \"관측자 개인 연락처\"], 4, [P, P, P]),\n",
" _mc(\"Q10\", \"경보보고\", 2, \"경보 전파 후 상황이 바뀌면?\", [\"최초 보고 유지\", \"변경 사항을 즉시 추가 보고\", \"상황 종료 후 일괄 보고\", \"개인 판단으로 경보 해제\"], 2, [P, P, P]),\n",
" _mc(\"Q11\", \"경보보고\", 2, \"보고 내용이 불확실할 때 올바른 표현은?\", [\"확정 표현으로 보고\", \"'추정'임을 밝히고 근거와 함께 보고\", \"보고 보류\", \"다른 관측자 보고를 복사\"], 2, [C, P, P]),\n",
" _mc(\"Q12\", \"경보보고\", 1, \"드론 탐지 후 보고 경로로 알맞은 것은?\", [\"인접 부대에만 전파\", \"지휘계통을 따라 보고\", \"개인 메신저로 공유\", \"보고 없이 대응\"], 2, [P, P, P]),\n",
" _mc(\"Q13\", \"교전통제\", 1, \"AI 탐지 결과만으로 대응을 결정할 수 있는가?\", [\"가능하다\", \"인간 지휘관의 승인이 필요하다\", \"신뢰도 90% 이상이면 가능\", \"야간에만 가능\"], 2, [C, C, C]),\n",
" _mc(\"Q14\", \"교전통제\", 2, \"민간 피해 가능성이 확인되면?\", [\"그대로 진행\", \"즉시 보고하고 지휘관 판단을 받는다\", \"조치 후 보고\", \"무시\"], 2, [P, P, F]),\n",
" _mc(\"Q15\", \"교전통제\", 3, \"식별이 불확실한 표적에 대한 원칙은?\", [\"위협으로 간주해 즉시 대응\", \"식별 확인 전까지 승인 절차를 따른다\", \"무조건 무시\", \"다수결로 결정\"], 2, [F, F, C]),\n",
" _mc(\"Q16\", \"교전통제\", 2, \"판단 근거를 기록해야 하는 이유는?\", [\"형식상 필요\", \"사후 검토와 책임 추적\", \"보고 생략\", \"기록 불필요\"], 2, [C, P, P]),\n",
" _mc(\"Q17\", \"대드론일반\", 1, \"대드론 체계의 일반적 처리 순서는?\", [\"대응-탐지-식별\", \"탐지-식별-보고·판단-대응\", \"식별-대응-탐지\", \"보고-탐지-대응\"], 2, [P, P, P]),\n",
" _mc(\"Q18\", \"대드론일반\", 3, \"레이더만으로 조류와 드론 구분이 어려운 이유는?\", [\"반사 신호 크기가 비슷할 수 있어서\", \"레이더는 야간에 못 써서\", \"드론에는 금속이 없어서\", \"조류는 탐지되지 않아서\"], 1, [C, C, C]),\n",
" _mc(\"Q19\", \"대드론일반\", 2, \"전자광학(EO) 센서의 약점이 커지는 조건은?\", [\"맑은 주간\", \"안개·야간\", \"저속 표적\", \"근거리\"], 2, [C, C, C]),\n",
" _mc(\"Q20\", \"대드론일반\", 2, \"경보가 반복해서 오경보로 판명되면?\", [\"경보 장치를 끈다\", \"사례를 기록·보고하고 기준 재검토 요청\", \"무시한다\", \"모두 실제 위협으로 간주\"], 2, [P, P, F]),\n",
" _sa(\"Q21\", \"피아식별\", 1, \"아군·적·민간을 구분하는 절차를 무엇이라 하는가?\", [\"피아식별\", \"IFF\"], {\"표적획득\": C, \"적기판정\": F, \"경계\": C}),\n",
" _sa(\"Q22\", \"형상식별\", 2, \"꼬리날개 없는 삼각형 날개 형태를 무엇이라 하는가?\", [\"삼각익\", \"델타익\", \"delta\"], {\"직선익\": C, \"후퇴익\": C, \"쿼드콥터\": C}),\n",
" _sa(\"Q23\", \"경보보고\", 2, \"불확실한 관측을 보고할 때 붙이는 표현은?\", [\"추정\"], {\"확정\": C, \"확인\": C, \"생략\": P}),\n",
" _sa(\"Q24\", \"교전통제\", 1, \"AI 탐지 결과에 대해 최종 결정을 내리는 주체는?\", [\"지휘관\"], {\"AI\": C, \"운용병\": P, \"레이더\": C}),\n",
" _sa(\"Q25\", \"대드론일반\", 1, \"조류를 드론으로 잘못 경보하는 것을 무엇이라 하는가?\", [\"오경보\", \"오탐\", \"false alarm\"], {\"미탐\": C, \"재경보\": C, \"적기\": F}),\n",
" _sa(\"Q26\", \"피아식별\", 2, \"운항 정보가 확인된 여객기는 어떻게 분류하는가?\", [\"민간기\", \"민항기\"], {\"적기\": F, \"미확인기\": P, \"아군기\": F}),\n",
" _sa(\"Q27\", \"형상식별\", 1, \"회전날개 4개를 가진 드론 형태는?\", [\"쿼드콥터\", \"quadcopter\"], {\"헥사콥터\": C, \"고정익\": C, \"헬기\": F}),\n",
" _sa(\"Q28\", \"경보보고\", 1, \"보고는 무엇을 따라 올라가는가?\", [\"지휘계통\"], {\"인접부대\": P, \"개인메신저\": P, \"통신망\": C}),\n",
" _order(\"Q29\", \"경보보고\", 2, \"최초 보고 순서: A 관측 시각, B 방향·거리, C 추정 유형, D 관측자 조치\"),\n",
" _order(\"Q30\", \"대드론일반\", 2, \"처리 순서: A 탐지, B 식별, C 보고·판단, D 대응\"),\n",
" _order(\"Q31\", \"교전통제\", 3, \"통제 순서: A 식별 확인, B 상급 승인 요청, C 승인 수령, D 조치 기록\"),\n",
" _order(\"Q32\", \"피아식별\", 3, \"확인 순서: A 운항정보 대조, B 식별 신호 확인, C 미확인 분류, D 보고\"),\n",
"]\n",
"QIDX = {q[\"qid\"]: q for q in BANK}\n",
"UNITS = [\"가상1중대\", \"가상2중대\", \"가상3중대\"]\n",
"_CIRC = \"①②③④\"\n",
"PARA = {\"Q21\": \"아군적군 구분\", \"Q22\": \"세모 날개\", \"Q23\": \"추정됨으로 표기\", \"Q24\": \"중대장(지휘관)\", \"Q25\": \"잘못된 경보\",\n",
" \"Q26\": \"민간 여객기\", \"Q27\": \"4발 드론\", \"Q28\": \"지휘 라인\"} # 뜻은 맞지만 인정 답안 목록에 없는 표현\n",
"\n",
"\n",
"def make_soldiers(n_per_unit, seed):\n",
" rng = np.random.default_rng(seed)\n",
" out = []\n",
" for u, unit in enumerate(UNITS):\n",
" base = [0.3, -0.1, 0.0][u]\n",
" for i in range(n_per_unit):\n",
" off = rng.normal(0, 0.5, len(AREAS)) + (np.array([0, 0, 0, -0.6, 0]) if u == 1 else 0)\n",
" out.append({\"sid\": f\"S{len(out) + 1:03d}\", \"unit\": unit, \"theta\": dict(zip(AREAS, base + rng.normal(0, 0.6) + off)),\n",
" \"tend\": dict(zip(ETYPES, rng.dirichlet([1.2, 1.2, 1.2])))})\n",
" return out\n",
"\n",
"\n",
"def p_correct(theta, diff):\n",
" return 1 / (1 + np.exp(-1.7 * (theta - (diff - 2) * 0.9 + 0.9)))\n",
"\n",
"\n",
"def _typo(s, rng):\n",
" if len(s) < 2:\n",
" return s\n",
" i = int(rng.integers(len(s) - 1))\n",
" 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:]\n",
"\n",
"\n",
"def _fmt_mc(k, rng):\n",
" r = rng.random()\n",
" if r < 0.55:\n",
" return str(k)\n",
" return rng.choice([_CIRC[k - 1], f\"{k}번\", \"ABCD\"[k - 1], \"abcd\"[k - 1] + \")\", f\" {k} \", chr(0xFF10 + k), f\"{k}.\"])\n",
"\n",
"\n",
"def _fmt_sa(s, rng):\n",
" r = rng.random()\n",
" if r < 0.45:\n",
" return s\n",
" if r < 0.6:\n",
" return \" \".join(s) if len(s) <= 4 else s[:2] + \" \" + s[2:]\n",
" if r < 0.72:\n",
" return s + rng.choice([\"입니다\", \"이다\", \".\", \"임\"])\n",
" if r < 0.8:\n",
" return s.upper() if s.isascii() else \"\".join(chr(ord(ch) + 0xFEE0) if \"!\" <= ch <= \"~\" else ch for ch in s) + \" \"\n",
" return _typo(s, rng)\n",
"\n",
"\n",
"def answer(q, sol, rng):\n",
" \"\"\"한 문항 응답: (원본 응답 문자열, 의도한 정답 여부, 의도한 답, 오답 유형).\"\"\"\n",
" ok = rng.random() < p_correct(sol[\"theta\"][q[\"area\"]], q[\"diff\"])\n",
" if q[\"type\"] == \"MC\":\n",
" if ok:\n",
" k, et = q[\"answer\"], None\n",
" else:\n",
" ks = list(q[\"dist\"]); w = np.array([sol[\"tend\"][q[\"dist\"][k]] for k in ks])\n",
" k = int(rng.choice(ks, p=w / w.sum())); et = q[\"dist\"][k]\n",
" return _fmt_mc(k, rng), ok, str(k), et\n",
" if q[\"type\"] == \"SA\":\n",
" if ok and rng.random() < 0.06:\n",
" return PARA[q[\"qid\"]], ok, PARA[q[\"qid\"]], None\n",
" if ok:\n",
" s, et = str(rng.choice(q[\"accept\"])), None\n",
" else:\n",
" ws = list(q[\"wrong\"]); w = np.array([sol[\"tend\"][q[\"wrong\"][x]] for x in ws])\n",
" s = str(rng.choice(ws, p=w / w.sum())); et = q[\"wrong\"][s]\n",
" return _fmt_sa(s, rng), ok, s, et\n",
" if ok:\n",
" s = \"ABCD\"\n",
" elif rng.random() < 0.5:\n",
" s = \"ABCD\".replace(\"ABCD\"[rng.integers(1, 4)], \"\") # 단계 하나 빠뜨림\n",
" else:\n",
" i = int(rng.integers(0, 3)); s = list(\"ABCD\"); s[i], s[i + 1] = s[i + 1], s[i]; s = \"\".join(s) # 순서 뒤바뀜\n",
" raw = s if rng.random() < 0.6 else rng.choice([\"-\".join(s), \" \".join(s), s.lower(), \",\".join(s)])\n",
" return raw, ok, s, (None if ok else P)\n",
"\n",
"\n",
"def make_responses(n_per_unit=40, seed=7, blank=0.02):\n",
" \"\"\"응시자 x 문항 응답표(DataFrame)를 만든다. truth_* 열은 교관 기준 정답(평가용)이다.\"\"\"\n",
" rng = np.random.default_rng(seed)\n",
" rows = []\n",
" for sol in make_soldiers(n_per_unit, seed):\n",
" for q in BANK:\n",
" raw, ok, intended, et = answer(q, sol, rng)\n",
" if rng.random() < blank:\n",
" raw, ok, intended, et = \"\", False, \"\", None\n",
" rows.append({\"rid\": f\"R{len(rows):05d}\", \"sid\": sol[\"sid\"], \"unit\": sol[\"unit\"], \"qid\": q[\"qid\"], \"raw\": raw,\n",
" \"truth_correct\": ok, \"truth_answer\": intended, \"truth_etype\": et})\n",
" return pd.DataFrame(rows)\n",
"# ---------------------------------------------------------------------------\n",
"\n",
"# ---- 응답 정제·파싱·채점 공통 함수 ----\n",
"TAILS = (\"입니다\", \"이다\", \"임\")\n",
"\n",
"\n",
"def norm_text(s):\n",
" \"\"\"전각->반각(NFKC), 소문자, 공백·문장부호 제거, 끝맺음 어미 제거.\"\"\"\n",
" s = unicodedata.normalize(\"NFKC\", str(s)).strip().lower()\n",
" s = re.sub(r\"[\\s\\.\\,\\-\\(\\)·]\", \"\", s)\n",
" for t in TAILS:\n",
" if s.endswith(t) and len(s) > len(t) + 1:\n",
" s = s[:-len(t)]\n",
" return s\n",
"\n",
"\n",
"def parse(q, raw):\n",
" \"\"\"원본 응답 -> (정제값, 상태). 상태: 정상 / 미응답 / 형식오류\"\"\"\n",
" if raw is None or str(raw).strip() == \"\":\n",
" return \"\", \"미응답\"\n",
" s = unicodedata.normalize(\"NFKC\", str(raw)).strip()\n",
" if q[\"type\"] == \"MC\":\n",
" t = re.sub(r\"[\\s번\\.\\)]\", \"\", s).lower()\n",
" t = {\"a\": \"1\", \"b\": \"2\", \"c\": \"3\", \"d\": \"4\"}.get(t, t)\n",
" return (t, \"정상\") if t in (\"1\", \"2\", \"3\", \"4\") else (t, \"형식오류\")\n",
" if q[\"type\"] == \"ORDER\":\n",
" t = re.sub(r\"[^A-Za-z]\", \"\", s).upper()\n",
" return (t, \"정상\") if t and set(t) <= set(\"ABCD\") and len(set(t)) == len(t) else (t, \"형식오류\")\n",
" return norm_text(s), \"정상\"\n",
"\n",
"\n",
"def grade_exact(q, val):\n",
" if q[\"type\"] == \"MC\":\n",
" return val == str(q[\"answer\"])\n",
" if q[\"type\"] == \"ORDER\":\n",
" return val == q[\"answer\"]\n",
" return val in {norm_text(a) for a in q[\"accept\"]}\n",
"\n",
"\n",
"def grade_fuzzy(q, val, cut=0.75):\n",
" \"\"\"단답형은 오탈자(글자 순서 바뀜·한 글자 누락)를 허용(유사도 cut 이상)하되, 알려진 오답과 더 가까우면 오답으로 본다.\"\"\"\n",
" if grade_exact(q, val) or q[\"type\"] != \"SA\" or len(val) < 2:\n",
" return grade_exact(q, val)\n",
" 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\"])\n",
" worst = max(difflib.SequenceMatcher(None, val, norm_text(w)).ratio() for w in q[\"wrong\"])\n",
" return best >= cut and best > worst\n",
"\n",
"\n",
"def score_of(q, correct):\n",
" return POINTS[q[\"diff\"]] if correct else 0\n",
"\n",
"\n",
"def grade_one(qid, raw):\n",
" q = QIDX[qid]\n",
" val, st = parse(q, raw)\n",
" return st, (st == \"정상\" and grade_fuzzy(q, val))\n",
"\n",
"\n",
"@functools.lru_cache(maxsize=50000)\n",
"def grade_cached(qid, raw):\n",
" \"\"\"같은 (문항, 원본 응답)은 다시 계산하지 않는다(대규모 응시에서 같은 답이 반복됨).\"\"\"\n",
" return grade_one(qid, raw)\n",
"\n",
"\n",
"def corrupt(df, rng, rate):\n",
" \"\"\"손상 답안 주입: 초장문, 제어문자, 존재하지 않는 문항, None.\"\"\"\n",
" df = df.copy()\n",
" idx = rng.choice(len(df), int(len(df) * rate), replace=False)\n",
" kinds = rng.integers(0, 4, len(idx))\n",
" for i, k in zip(idx, kinds):\n",
" df.at[df.index[i], \"raw\"] = [\"가\" * 20000, \"\\x00\\x07삼각익\", None, \"1\"][k]\n",
" if k == 3:\n",
" df.at[df.index[i], \"qid\"] = \"Q99\"\n",
" return df\n",
"\n",
"\n",
"def run(df, fn, fail_rate=0.0, seed=0, cap=2000):\n",
" \"\"\"제출 큐에서 cap건씩 꺼내 채점. 채점 중 예외·장애가 나면 '채점 보류' 큐로 옮기고 계속 처리한다(안전 실패).\"\"\"\n",
" rng = np.random.default_rng(seed)\n",
" inbox, result, hold = deque(df[[\"rid\", \"qid\", \"raw\"]].itertuples(index=False)), {}, {}\n",
" t0, crashes, max_q = time.perf_counter(), 0, len(inbox)\n",
" while inbox:\n",
" batch = [inbox.popleft() for _ in range(min(cap, len(inbox)))]\n",
" for rid, qid, raw in batch:\n",
" try:\n",
" if qid not in QIDX:\n",
" raise KeyError(\"문항 없음\")\n",
" if raw is not None and len(str(raw)) > 200:\n",
" raise ValueError(\"응답 길이 초과\")\n",
" if rng.random() < fail_rate:\n",
" raise TimeoutError(\"채점기 응답 없음\")\n",
" result[rid] = fn(qid, \"\" if raw is None else str(raw).replace(\"\\x00\", \"\").replace(\"\\x07\", \"\"))\n",
" except Exception as e: # 시스템은 멈추지 않고 보류 큐에 원본을 보존\n",
" hold[rid] = type(e).__name__\n",
" sec = time.perf_counter() - t0\n",
" lost = len(df) - len(result) - len(hold)\n",
" return {\"건수\": len(df), \"처리(초)\": round(sec, 2), \"처리량(건/초)\": int(len(df) / sec), \"채점 완료\": len(result),\n",
" \"보류\": len(hold), \"유실\": lost, \"다운\": crashes, \"최대 대기열\": max_q}, hold\n",
"\n",
"\n",
"base = make_responses(40, seed=7) # 평시: 120명 x 32문항\n",
"rng = np.random.default_rng(1)\n",
"rows, holds = [], {}\n",
"for mult, fail in [(1, 0.0), (3, 0.0), (6, 0.0), (6, 0.02), (11, 0.02)]:\n",
" df = pd.concat([base.assign(rid=base[\"rid\"] + f\"-{k}\") for k in range(mult)], ignore_index=True)\n",
" df = corrupt(df, rng, 0.003)\n",
" grade_cached.cache_clear()\n",
" tracemalloc.start()\n",
" r, hold = run(df, grade_cached, fail_rate=fail, seed=mult)\n",
" peak = tracemalloc.get_traced_memory()[1] / 1e6; tracemalloc.stop()\n",
" rows.append({\"부하\": f\"평시 x{mult}\" + (\" +장애2%\" if fail else \"\"), **r, \"메모리 최대(MB)\": round(peak, 1)})\n",
" holds[rows[-1][\"부하\"]] = hold\n",
"T = pd.DataFrame(rows)\n",
"print(T.drop(columns=[\"다운\", \"최대 대기열\"]).to_string(index=False))\n",
"print(\"(위 표의 처리량은 메모리 추적을 켠 상태라 실제보다 낮게 나온다)\")\n",
"w = T[T[\"부하\"] == \"평시 x6\"].iloc[0]\n",
"print(f\"\\n동시 응시 500% 증가(평시 x6): 시스템 다운 {int(T['다운'].sum())}건, 데이터 유실 {int(T['유실'].sum())}건 \"\n",
" f\"(목표 다운 0건·유실률 0%: {'달성' if T['다운'].sum() == 0 and T['유실'].sum() == 0 else '미달'})\")\n",
"h = pd.Series(holds[\"평시 x11 +장애2%\"]).value_counts()\n",
"print(\"보류 사유(평시 x11 +장애2%):\", {k: int(v) for k, v in h.items()}, \"-> 원본 보존, 장애 복구 후 재채점 또는 교관 확인\")\n",
"\n",
"# 병목 프로파일링과 최적화: 캐시 없음 vs 캐시\n",
"df6 = pd.concat([base.assign(rid=base[\"rid\"] + f\"-{k}\") for k in range(6)], ignore_index=True)\n",
"prof = {}\n",
"t0 = time.perf_counter(); [parse(QIDX[q], r) for q, r in zip(df6[\"qid\"], df6[\"raw\"])]; prof[\"파싱\"] = time.perf_counter() - t0\n",
"sa = df6[df6[\"qid\"].map(lambda q: QIDX[q][\"type\"]) == \"SA\"]\n",
"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\n",
"print(\"\\n[병목 프로파일] \" + \", \".join(f\"{k} {v * 1000:.0f} ms\" for k, v in prof.items()) + f\" (단답형은 전체의 {len(sa) / len(df6):.0%})\")\n",
"r0, _ = run(df6, grade_one)\n",
"grade_cached.cache_clear(); r1, _ = run(df6, grade_cached)\n",
"print(f\"최적화 전 {r0['처리량(건/초)']:,}건/초 -> 캐시 적용 후 {r1['처리량(건/초)']:,}건/초 ({r1['처리량(건/초)'] / r0['처리량(건/초)']:.1f}배)\")\n",
"print(f\"기능 축소 운용: 장애율이 5%를 넘으면 단답형 유사도 채점을 끄고 정확 일치만 채점, 나머지는 교관 검토로 넘긴다.\")\n",
"\n",
"plt.figure(figsize=(8, 4))\n",
"plt.bar(T[\"부하\"].str.replace(\"평시\", \"base\").str.replace(\"+장애2%\", \"+fail2%\"), T[\"처리량(건/초)\"])\n",
"plt.ylabel(\"responses / sec\"); plt.title(\"Grading throughput under load (cached)\"); plt.xticks(rotation=15)\n",
"plt.tight_layout(); plt.show()\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"<details>\n",
"<summary><b>💡 [참고] 미션 08 예상 실행 결과 (Output)</b></summary>\n",
"\n",
"```text\n",
" 부하 건수 처리(초) 처리량(건/초) 채점 완료 보류 유실 메모리 최대(MB)\n",
" 평시 x1 3840 0.11 33625 3834 6 0 0.5\n",
" 평시 x3 11520 0.19 59703 11501 19 0 1.1\n",
" 평시 x6 23040 0.27 86855 23004 36 0 2.0\n",
" 평시 x6 +장애2% 23040 0.24 96047 22558 482 0 2.0\n",
"평시 x11 +장애2% 42240 0.46 91783 41326 914 0 3.6\n",
"(위 표의 처리량은 메모리 추적을 켠 상태라 실제보다 낮게 나온다)\n",
"\n",
"동시 응시 500% 증가(평시 x6): 시스템 다운 0건, 데이터 유실 0건 (목표 다운 0건·유실률 0%: 달성)\n",
"보류 사유(평시 x11 +장애2%): {'TimeoutError': 857, 'ValueError': 30, 'KeyError': 27} -> 원본 보존, 장애 복구 후 재채점 또는 교관 확인\n",
"\n",
"[병목 프로파일] 파싱 52 ms, 단답 유사도 채점 150 ms (단답형은 전체의 25%)\n",
"최적화 전 89,083건/초 -> 캐시 적용 후 498,682건/초 (5.6배)\n",
"기능 축소 운용: 장애율이 5%를 넘으면 단답형 유사도 채점을 끄고 정확 일치만 채점, 나머지는 교관 검토로 넘긴다.\n",
"```\n",
"</details>\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"---\n",
"\n",
"## 🎯 미션 09. AAR 기반 문항 변별력 분석 및 난이도별 가중치 파라미터 재보정\n",
"**소요시간**: 약 3.0시간\n",
"\n",
"### 📋 미션 배경 및 수행 지침\n",
"#### 미션 배경 및 당위성\n",
"\n",
"AAR 기반 문항 변별력 분석 및 난이도 가중치 파라미터를 재보정한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **시행 후 검토(AAR)로 문항의 변별력과 정답 키 오류를 찾아내고, 결함 문항 조치와 배점 가중치 재보정으로 합격 판정의 정확도를 높인다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
"\n",
"이 문제를 해결하기 위해 본 미션에서는 **문항별 정답률, 점이연 상관(변별도), 오답 보기 선택자의 평균 점수를 분석해 정답 키 입력 오류와 변별력 부족 문항을 찾는다 과정을 진행하는** 실습을 체계적으로 수행합니다.\n",
"\n",
"학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
"\n",
"**이번 결과물:** `docs/trn02_aar_report.md 및 configs/trn02_tuned_params.yaml` 형태의 결과물을 완성한다.\n",
"\n",
"### 🔍 평가 및 검증 기준 (PASS 조건)\n",
"#### 미션 판정\n",
"\n",
"**평가 구분:** 필수\n",
"\n",
"**핵심 평가영역:** 오류 원인 규명의 객관성 및 개선 조치의 논리적 타당성\n",
"\n",
"#### 미션 PASS 조건\n",
"\n",
"- 정량 기준: AAR 피드백 적용 후 종합 정확도 10%p 이상 향상, 재발 오류 0건\n",
"\n",
"\n",
"- 진행 기준: AAR 보고서 작성 및 파라미터 튜닝 전후 회귀 테스트 통과\n",
"\n",
"\n",
"- 검증 방법: 회귀 테스트(Regression Test) 스위트 전체 자동 실행 및 비교\n",
"\n",
"\n",
"#### 채점자 판정 메모\n",
"\n",
"> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n",
"\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"\"\"\"시행 후 검토(AAR)로 문항별 정답률·변별도(점이연 상관)·오답 보기 선택 분포를 분석해 정답 키 오류와 변별력 없는 문항을 찾아내고, 난이도·변별도 가중치를 재보정한다. 실제 숙달도 대비 합격 판정 정확도의 개선과 새 응시 집단에서의 재발 오류를 확인하고 재보정 파라미터를 YAML로 출력한다.\"\"\"\n",
"import difflib\n",
"import re\n",
"import unicodedata\n",
"import numpy as np\n",
"import pandas as pd\n",
"import matplotlib.pyplot as plt\n",
"\n",
"# ---------------------------------------------------------------------------\n",
"# [교육용가상자료] 방공·드론 식별 퀴즈 문항 은행과 가상 응시자 응답 생성기\n",
"# 문항은 일반 원칙 수준의 가상 문항이며 실제 교범·교전규칙 원문이 아니다. 응시자는 가명(S001~)이다.\n",
"# ---------------------------------------------------------------------------\n",
"AREAS = [\"형상식별\", \"피아식별\", \"경보보고\", \"교전통제\", \"대드론일반\"]\n",
"ETYPES = [\"개념 혼동\", \"피아 오인\", \"절차 누락\"]\n",
"POINTS = {1: 2, 2: 3, 3: 5}\n",
"C, F, P = ETYPES\n",
"\n",
"\n",
"def _mc(qid, area, diff, stem, opts, ans, dist):\n",
" # [TODO : 학생 실습 9] AAR 기반 문항 변별력 분석 및 난이도별 가중치 파라미터 재보정 관련 핵심 로직을 구현하세요.\n",
" # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n",
" # <<< 여기에 코드를 작성하세요 >>>\n",
" pass # 작성 후 pass를 제거하세요.\n",
"def _sa(qid, area, diff, stem, accept, wrong):\n",
" # [TODO : 학생 실습 9] AAR 기반 문항 변별력 분석 및 난이도별 가중치 파라미터 재보정 관련 핵심 로직을 구현하세요.\n",
" # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n",
" # <<< 여기에 코드를 작성하세요 >>>\n",
" pass # 작성 후 pass를 제거하세요.\n",
"def _order(qid, area, diff, stem):\n",
" return {\"qid\": qid, \"type\": \"ORDER\", \"area\": area, \"diff\": diff, \"stem\": stem, \"answer\": \"ABCD\"}\n",
"\n",
"\n",
"BANK = [\n",
" _mc(\"Q01\", \"형상식별\", 1, \"꼬리날개 없이 삼각형 날개를 가진 소형 고정익 실루엣은?\", [\"쿼드콥터형\", \"삼각익 고정익형\", \"회전익 헬기형\", \"조류\"], 2, [C, C, F]),\n",
" _mc(\"Q02\", \"형상식별\", 1, \"회전날개 4개가 십자로 배치된 실루엣은?\", [\"고정익 드론형\", \"쿼드콥터형\", \"순항미사일형\", \"탄도미사일형\"], 2, [C, C, C]),\n",
" _mc(\"Q03\", \"형상식별\", 2, \"가늘고 긴 동체 중간에 작은 날개가 붙은 실루엣은?\", [\"순항미사일형\", \"탄도미사일형\", \"민간 경비행기형\", \"조류\"], 1, [C, F, F]),\n",
" _mc(\"Q04\", \"형상식별\", 3, \"원거리라 실루엣 식별이 어려울 때 먼저 할 행동은?\", [\"추정 유형으로 확정 보고\", \"관측 정보를 그대로 보고하고 추가 관측\", \"관측 중단\", \"민간기로 간주\"], 2, [P, P, F]),\n",
" _mc(\"Q05\", \"피아식별\", 2, \"식별 신호 응답이 없는 항공기에 대한 올바른 판단은?\", [\"즉시 적기로 확정\", \"미확인 항공기로 분류하고 추가 확인\", \"아군기로 간주\", \"무시\"], 2, [F, F, P]),\n",
" _mc(\"Q06\", \"피아식별\", 2, \"운항 정보와 일치하는 저속 비행체의 분류는?\", [\"적 드론\", \"민간기 가능성 높음, 추가 확인\", \"순항미사일\", \"아군 정찰기로 확정\"], 2, [F, C, F]),\n",
" _mc(\"Q07\", \"피아식별\", 3, \"아군 드론 운용 공지가 있는 공역에서 미확인 드론을 보면 우선 확인할 것은?\", [\"기상 정보\", \"아군 운용 계획과 식별 신호\", \"대응 준비 상태\", \"주변 지형\"], 2, [C, P, C]),\n",
" _mc(\"Q08\", \"피아식별\", 3, \"조류 무리와 소형 드론 군집 구분에 가장 도움이 되는 정보는?\", [\"크기만 비교\", \"궤적의 규칙성과 열 신호\", \"색상\", \"관측 시각\"], 2, [C, C, C]),\n",
" _mc(\"Q09\", \"경보보고\", 1, \"공중 위협 최초 보고에 포함하지 않아도 되는 것은?\", [\"관측 시각\", \"방향과 거리\", \"추정 유형\", \"관측자 개인 연락처\"], 4, [P, P, P]),\n",
" _mc(\"Q10\", \"경보보고\", 2, \"경보 전파 후 상황이 바뀌면?\", [\"최초 보고 유지\", \"변경 사항을 즉시 추가 보고\", \"상황 종료 후 일괄 보고\", \"개인 판단으로 경보 해제\"], 2, [P, P, P]),\n",
" _mc(\"Q11\", \"경보보고\", 2, \"보고 내용이 불확실할 때 올바른 표현은?\", [\"확정 표현으로 보고\", \"'추정'임을 밝히고 근거와 함께 보고\", \"보고 보류\", \"다른 관측자 보고를 복사\"], 2, [C, P, P]),\n",
" _mc(\"Q12\", \"경보보고\", 1, \"드론 탐지 후 보고 경로로 알맞은 것은?\", [\"인접 부대에만 전파\", \"지휘계통을 따라 보고\", \"개인 메신저로 공유\", \"보고 없이 대응\"], 2, [P, P, P]),\n",
" _mc(\"Q13\", \"교전통제\", 1, \"AI 탐지 결과만으로 대응을 결정할 수 있는가?\", [\"가능하다\", \"인간 지휘관의 승인이 필요하다\", \"신뢰도 90% 이상이면 가능\", \"야간에만 가능\"], 2, [C, C, C]),\n",
" _mc(\"Q14\", \"교전통제\", 2, \"민간 피해 가능성이 확인되면?\", [\"그대로 진행\", \"즉시 보고하고 지휘관 판단을 받는다\", \"조치 후 보고\", \"무시\"], 2, [P, P, F]),\n",
" _mc(\"Q15\", \"교전통제\", 3, \"식별이 불확실한 표적에 대한 원칙은?\", [\"위협으로 간주해 즉시 대응\", \"식별 확인 전까지 승인 절차를 따른다\", \"무조건 무시\", \"다수결로 결정\"], 2, [F, F, C]),\n",
" _mc(\"Q16\", \"교전통제\", 2, \"판단 근거를 기록해야 하는 이유는?\", [\"형식상 필요\", \"사후 검토와 책임 추적\", \"보고 생략\", \"기록 불필요\"], 2, [C, P, P]),\n",
" _mc(\"Q17\", \"대드론일반\", 1, \"대드론 체계의 일반적 처리 순서는?\", [\"대응-탐지-식별\", \"탐지-식별-보고·판단-대응\", \"식별-대응-탐지\", \"보고-탐지-대응\"], 2, [P, P, P]),\n",
" _mc(\"Q18\", \"대드론일반\", 3, \"레이더만으로 조류와 드론 구분이 어려운 이유는?\", [\"반사 신호 크기가 비슷할 수 있어서\", \"레이더는 야간에 못 써서\", \"드론에는 금속이 없어서\", \"조류는 탐지되지 않아서\"], 1, [C, C, C]),\n",
" _mc(\"Q19\", \"대드론일반\", 2, \"전자광학(EO) 센서의 약점이 커지는 조건은?\", [\"맑은 주간\", \"안개·야간\", \"저속 표적\", \"근거리\"], 2, [C, C, C]),\n",
" _mc(\"Q20\", \"대드론일반\", 2, \"경보가 반복해서 오경보로 판명되면?\", [\"경보 장치를 끈다\", \"사례를 기록·보고하고 기준 재검토 요청\", \"무시한다\", \"모두 실제 위협으로 간주\"], 2, [P, P, F]),\n",
" _sa(\"Q21\", \"피아식별\", 1, \"아군·적·민간을 구분하는 절차를 무엇이라 하는가?\", [\"피아식별\", \"IFF\"], {\"표적획득\": C, \"적기판정\": F, \"경계\": C}),\n",
" _sa(\"Q22\", \"형상식별\", 2, \"꼬리날개 없는 삼각형 날개 형태를 무엇이라 하는가?\", [\"삼각익\", \"델타익\", \"delta\"], {\"직선익\": C, \"후퇴익\": C, \"쿼드콥터\": C}),\n",
" _sa(\"Q23\", \"경보보고\", 2, \"불확실한 관측을 보고할 때 붙이는 표현은?\", [\"추정\"], {\"확정\": C, \"확인\": C, \"생략\": P}),\n",
" _sa(\"Q24\", \"교전통제\", 1, \"AI 탐지 결과에 대해 최종 결정을 내리는 주체는?\", [\"지휘관\"], {\"AI\": C, \"운용병\": P, \"레이더\": C}),\n",
" _sa(\"Q25\", \"대드론일반\", 1, \"조류를 드론으로 잘못 경보하는 것을 무엇이라 하는가?\", [\"오경보\", \"오탐\", \"false alarm\"], {\"미탐\": C, \"재경보\": C, \"적기\": F}),\n",
" _sa(\"Q26\", \"피아식별\", 2, \"운항 정보가 확인된 여객기는 어떻게 분류하는가?\", [\"민간기\", \"민항기\"], {\"적기\": F, \"미확인기\": P, \"아군기\": F}),\n",
" _sa(\"Q27\", \"형상식별\", 1, \"회전날개 4개를 가진 드론 형태는?\", [\"쿼드콥터\", \"quadcopter\"], {\"헥사콥터\": C, \"고정익\": C, \"헬기\": F}),\n",
" _sa(\"Q28\", \"경보보고\", 1, \"보고는 무엇을 따라 올라가는가?\", [\"지휘계통\"], {\"인접부대\": P, \"개인메신저\": P, \"통신망\": C}),\n",
" _order(\"Q29\", \"경보보고\", 2, \"최초 보고 순서: A 관측 시각, B 방향·거리, C 추정 유형, D 관측자 조치\"),\n",
" _order(\"Q30\", \"대드론일반\", 2, \"처리 순서: A 탐지, B 식별, C 보고·판단, D 대응\"),\n",
" _order(\"Q31\", \"교전통제\", 3, \"통제 순서: A 식별 확인, B 상급 승인 요청, C 승인 수령, D 조치 기록\"),\n",
" _order(\"Q32\", \"피아식별\", 3, \"확인 순서: A 운항정보 대조, B 식별 신호 확인, C 미확인 분류, D 보고\"),\n",
"]\n",
"QIDX = {q[\"qid\"]: q for q in BANK}\n",
"UNITS = [\"가상1중대\", \"가상2중대\", \"가상3중대\"]\n",
"_CIRC = \"①②③④\"\n",
"PARA = {\"Q21\": \"아군적군 구분\", \"Q22\": \"세모 날개\", \"Q23\": \"추정됨으로 표기\", \"Q24\": \"중대장(지휘관)\", \"Q25\": \"잘못된 경보\",\n",
" \"Q26\": \"민간 여객기\", \"Q27\": \"4발 드론\", \"Q28\": \"지휘 라인\"} # 뜻은 맞지만 인정 답안 목록에 없는 표현\n",
"\n",
"\n",
"def make_soldiers(n_per_unit, seed):\n",
" rng = np.random.default_rng(seed)\n",
" out = []\n",
" for u, unit in enumerate(UNITS):\n",
" base = [0.3, -0.1, 0.0][u]\n",
" for i in range(n_per_unit):\n",
" off = rng.normal(0, 0.5, len(AREAS)) + (np.array([0, 0, 0, -0.6, 0]) if u == 1 else 0)\n",
" out.append({\"sid\": f\"S{len(out) + 1:03d}\", \"unit\": unit, \"theta\": dict(zip(AREAS, base + rng.normal(0, 0.6) + off)),\n",
" \"tend\": dict(zip(ETYPES, rng.dirichlet([1.2, 1.2, 1.2])))})\n",
" return out\n",
"\n",
"\n",
"def p_correct(theta, diff):\n",
" return 1 / (1 + np.exp(-1.7 * (theta - (diff - 2) * 0.9 + 0.9)))\n",
"\n",
"\n",
"def _typo(s, rng):\n",
" if len(s) < 2:\n",
" return s\n",
" i = int(rng.integers(len(s) - 1))\n",
" 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:]\n",
"\n",
"\n",
"def _fmt_mc(k, rng):\n",
" r = rng.random()\n",
" if r < 0.55:\n",
" return str(k)\n",
" return rng.choice([_CIRC[k - 1], f\"{k}번\", \"ABCD\"[k - 1], \"abcd\"[k - 1] + \")\", f\" {k} \", chr(0xFF10 + k), f\"{k}.\"])\n",
"\n",
"\n",
"def _fmt_sa(s, rng):\n",
" r = rng.random()\n",
" if r < 0.45:\n",
" return s\n",
" if r < 0.6:\n",
" return \" \".join(s) if len(s) <= 4 else s[:2] + \" \" + s[2:]\n",
" if r < 0.72:\n",
" return s + rng.choice([\"입니다\", \"이다\", \".\", \"임\"])\n",
" if r < 0.8:\n",
" return s.upper() if s.isascii() else \"\".join(chr(ord(ch) + 0xFEE0) if \"!\" <= ch <= \"~\" else ch for ch in s) + \" \"\n",
" return _typo(s, rng)\n",
"\n",
"\n",
"def answer(q, sol, rng):\n",
" \"\"\"한 문항 응답: (원본 응답 문자열, 의도한 정답 여부, 의도한 답, 오답 유형).\"\"\"\n",
" ok = rng.random() < p_correct(sol[\"theta\"][q[\"area\"]], q[\"diff\"])\n",
" if q[\"type\"] == \"MC\":\n",
" if ok:\n",
" k, et = q[\"answer\"], None\n",
" else:\n",
" ks = list(q[\"dist\"]); w = np.array([sol[\"tend\"][q[\"dist\"][k]] for k in ks])\n",
" k = int(rng.choice(ks, p=w / w.sum())); et = q[\"dist\"][k]\n",
" return _fmt_mc(k, rng), ok, str(k), et\n",
" if q[\"type\"] == \"SA\":\n",
" if ok and rng.random() < 0.06:\n",
" return PARA[q[\"qid\"]], ok, PARA[q[\"qid\"]], None\n",
" if ok:\n",
" s, et = str(rng.choice(q[\"accept\"])), None\n",
" else:\n",
" ws = list(q[\"wrong\"]); w = np.array([sol[\"tend\"][q[\"wrong\"][x]] for x in ws])\n",
" s = str(rng.choice(ws, p=w / w.sum())); et = q[\"wrong\"][s]\n",
" return _fmt_sa(s, rng), ok, s, et\n",
" if ok:\n",
" s = \"ABCD\"\n",
" elif rng.random() < 0.5:\n",
" s = \"ABCD\".replace(\"ABCD\"[rng.integers(1, 4)], \"\") # 단계 하나 빠뜨림\n",
" else:\n",
" i = int(rng.integers(0, 3)); s = list(\"ABCD\"); s[i], s[i + 1] = s[i + 1], s[i]; s = \"\".join(s) # 순서 뒤바뀜\n",
" raw = s if rng.random() < 0.6 else rng.choice([\"-\".join(s), \" \".join(s), s.lower(), \",\".join(s)])\n",
" return raw, ok, s, (None if ok else P)\n",
"\n",
"\n",
"def make_responses(n_per_unit=40, seed=7, blank=0.02):\n",
" \"\"\"응시자 x 문항 응답표(DataFrame)를 만든다. truth_* 열은 교관 기준 정답(평가용)이다.\"\"\"\n",
" rng = np.random.default_rng(seed)\n",
" rows = []\n",
" for sol in make_soldiers(n_per_unit, seed):\n",
" for q in BANK:\n",
" raw, ok, intended, et = answer(q, sol, rng)\n",
" if rng.random() < blank:\n",
" raw, ok, intended, et = \"\", False, \"\", None\n",
" rows.append({\"rid\": f\"R{len(rows):05d}\", \"sid\": sol[\"sid\"], \"unit\": sol[\"unit\"], \"qid\": q[\"qid\"], \"raw\": raw,\n",
" \"truth_correct\": ok, \"truth_answer\": intended, \"truth_etype\": et})\n",
" return pd.DataFrame(rows)\n",
"# ---------------------------------------------------------------------------\n",
"\n",
"# ---- 응답 정제·파싱·채점 공통 함수 ----\n",
"TAILS = (\"입니다\", \"이다\", \"임\")\n",
"\n",
"\n",
"def norm_text(s):\n",
" \"\"\"전각->반각(NFKC), 소문자, 공백·문장부호 제거, 끝맺음 어미 제거.\"\"\"\n",
" s = unicodedata.normalize(\"NFKC\", str(s)).strip().lower()\n",
" s = re.sub(r\"[\\s\\.\\,\\-\\(\\)·]\", \"\", s)\n",
" for t in TAILS:\n",
" if s.endswith(t) and len(s) > len(t) + 1:\n",
" s = s[:-len(t)]\n",
" return s\n",
"\n",
"\n",
"def parse(q, raw):\n",
" \"\"\"원본 응답 -> (정제값, 상태). 상태: 정상 / 미응답 / 형식오류\"\"\"\n",
" if raw is None or str(raw).strip() == \"\":\n",
" return \"\", \"미응답\"\n",
" s = unicodedata.normalize(\"NFKC\", str(raw)).strip()\n",
" if q[\"type\"] == \"MC\":\n",
" t = re.sub(r\"[\\s번\\.\\)]\", \"\", s).lower()\n",
" t = {\"a\": \"1\", \"b\": \"2\", \"c\": \"3\", \"d\": \"4\"}.get(t, t)\n",
" return (t, \"정상\") if t in (\"1\", \"2\", \"3\", \"4\") else (t, \"형식오류\")\n",
" if q[\"type\"] == \"ORDER\":\n",
" t = re.sub(r\"[^A-Za-z]\", \"\", s).upper()\n",
" return (t, \"정상\") if t and set(t) <= set(\"ABCD\") and len(set(t)) == len(t) else (t, \"형식오류\")\n",
" return norm_text(s), \"정상\"\n",
"\n",
"\n",
"def grade_exact(q, val):\n",
" if q[\"type\"] == \"MC\":\n",
" return val == str(q[\"answer\"])\n",
" if q[\"type\"] == \"ORDER\":\n",
" return val == q[\"answer\"]\n",
" return val in {norm_text(a) for a in q[\"accept\"]}\n",
"\n",
"\n",
"def grade_fuzzy(q, val, cut=0.75):\n",
" \"\"\"단답형은 오탈자(글자 순서 바뀜·한 글자 누락)를 허용(유사도 cut 이상)하되, 알려진 오답과 더 가까우면 오답으로 본다.\"\"\"\n",
" if grade_exact(q, val) or q[\"type\"] != \"SA\" or len(val) < 2:\n",
" return grade_exact(q, val)\n",
" 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\"])\n",
" worst = max(difflib.SequenceMatcher(None, val, norm_text(w)).ratio() for w in q[\"wrong\"])\n",
" return best >= cut and best > worst\n",
"\n",
"\n",
"def score_of(q, correct):\n",
" return POINTS[q[\"diff\"]] if correct else 0\n",
"\n",
"KEY_REG = {\"Q11\": 3} # [교육용가상자료] 문항 등록 시 정답 키를 잘못 입력한 문항(정답은 2)\n",
"AMBIG = \"Q08\" # 보기 2·3이 모두 그럴듯해 실력과 무관하게 갈리는 문항\n",
"\n",
"\n",
"def field_data(seed):\n",
" \"\"\"현장 시행 데이터: Q08은 실력과 무관하게 2·3이 반반, 나머지는 생성기 그대로.\"\"\"\n",
" df = make_responses(40, seed=seed)\n",
" rng = np.random.default_rng(seed + 100)\n",
" m = df[\"qid\"] == AMBIG\n",
" df.loc[m, \"raw\"] = rng.choice([\"2\", \"3\"], m.sum())\n",
" df[\"val\"] = [parse(QIDX[q], r)[0] for q, r in zip(df[\"qid\"], df[\"raw\"])]\n",
" return df\n",
"\n",
"\n",
"def grade(df, key_fix=False, drop=(), weights=None):\n",
" def ok(q, v):\n",
" if q[\"qid\"] in KEY_REG and not key_fix:\n",
" return v == str(KEY_REG[q[\"qid\"]])\n",
" return grade_fuzzy(q, v)\n",
" d = df[~df[\"qid\"].isin(drop)].copy()\n",
" d[\"correct\"] = [ok(QIDX[q], v) for q, v in zip(d[\"qid\"], d[\"val\"])]\n",
" w = weights or {}\n",
" d[\"pts\"] = [w.get(q, POINTS[QIDX[q][\"diff\"]]) for q in d[\"qid\"]]\n",
" d[\"score\"] = d[\"pts\"] * d[\"correct\"]\n",
" tot = d.groupby(\"sid\")[[\"score\", \"pts\"]].sum()\n",
" return d, tot[\"score\"] / tot[\"pts\"]\n",
"\n",
"\n",
"def item_analysis(d, key_fix=False):\n",
" \"\"\"정답률(p), 점이연 상관(해당 문항 제외 총점과의 상관), 오답 보기 선택자의 평균 총점이 정답 선택자보다 높은지(키 오류 의심).\"\"\"\n",
" tot = d.groupby(\"sid\")[\"correct\"].mean()\n",
" rows = []\n",
" for qid, g in d.groupby(\"qid\"):\n",
" rest = tot[g[\"sid\"]].values - g[\"correct\"].values / len(BANK)\n",
" r = np.corrcoef(g[\"correct\"].astype(float), rest)[0, 1] if g[\"correct\"].std() > 0 else 0.0\n",
" key_sus = False\n",
" if QIDX[qid][\"type\"] == \"MC\":\n",
" by = pd.Series(rest, index=g[\"val\"].values).groupby(level=0).mean()\n",
" key = KEY_REG[qid] if qid in KEY_REG and not key_fix else QIDX[qid][\"answer\"]\n",
" right = by.get(str(key), np.nan)\n",
" 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)\n",
" rows.append({\"qid\": qid, \"p\": round(g[\"correct\"].mean(), 2), \"r_pb\": round(r, 2), \"key_suspect\": key_sus})\n",
" t = pd.DataFrame(rows).set_index(\"qid\")\n",
" t[\"flag\"] = np.where(t[\"key_suspect\"], \"정답 키 오류 의심\", np.where(t[\"r_pb\"] < 0.1, \"변별력 부족\", \"\"))\n",
" return t\n",
"\n",
"\n",
"def pass_accuracy(df, ratio, cut=0.7):\n",
" \"\"\"합격 판정(점수 비율 cut 이상)과 가상 실제 숙달도(결함 없는 시험에서의 기대 득점률 0.7 이상)의 일치율.\"\"\"\n",
" sol = {s[\"sid\"]: s for s in make_soldiers(40, SEED_OF[id(df)])}\n",
" pts = np.array([POINTS[q[\"diff\"]] for q in BANK])\n",
" 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}\n",
" truth = pd.Series(exp) >= 0.7\n",
" return float(((ratio >= cut) == truth[ratio.index]).mean())\n",
"\n",
"\n",
"SEED_OF = {}\n",
"A = field_data(7); SEED_OF[id(A)] = 7\n",
"d0, r0 = grade(A)\n",
"acc0 = pass_accuracy(A, r0)\n",
"ia = item_analysis(d0)\n",
"print(\"[AAR 1. 문항 분석 - 경고 문항]\")\n",
"print(ia[ia[\"flag\"] != \"\"].to_string())\n",
"print(f\"전체 문항 변별도 중앙값 {ia['r_pb'].median():.2f}, 정답률 0.9 초과(너무 쉬움) {int((ia['p'] > 0.9).sum())}개, 0.3 미만(너무 어려움) {int((ia['p'] < 0.3).sum())}개\")\n",
"print(\"원인 판정: Q11은 상위권이 고른 보기(2)가 등록 키(3)와 달라 키 입력 오류, Q08은 보기 2·3이 실력과 무관하게 갈려 문항 자체 결함.\")\n",
"\n",
"# AAR 2. 재보정: 키 수정 -> 결함 문항 제외 -> 배점 가중치 탐색\n",
"flagged = list(ia[ia[\"flag\"] == \"변별력 부족\"].index)\n",
"rows = [{\"단계\": \"재보정 전\", \"합격 판정 정확도\": acc0}]\n",
"d1, r1 = grade(A, key_fix=True)\n",
"rows.append({\"단계\": \"1) 정답 키 수정\", \"합격 판정 정확도\": pass_accuracy(A, r1)})\n",
"d2, r2 = grade(A, key_fix=True, drop=flagged)\n",
"rows.append({\"단계\": \"2) 변별력 부족 문항 제외\", \"합격 판정 정확도\": pass_accuracy(A, r2)})\n",
"ia2 = item_analysis(d2, key_fix=True)\n",
"best = None\n",
"for base in [(2, 3, 5), (1, 2, 3), (3, 3, 3)]:\n",
" for alpha in [0.0, 0.5, 1.0, 2.0]:\n",
" w = {q: base[QIDX[q][\"diff\"] - 1] * (1 + alpha * max(ia2.loc[q, \"r_pb\"], 0)) for q in ia2.index}\n",
" _, r = grade(A, key_fix=True, drop=flagged, weights=w)\n",
" a = pass_accuracy(A, r) # 합격선 70%는 정책값이라 튜닝하지 않는다\n",
" if best is None or a > best[0] + 1e-9:\n",
" best = (a, base, alpha, 0.7, w)\n",
"acc3, base, alpha, cut, W = best\n",
"rows.append({\"단계\": f\"3) 배점{base}·변별도 가중 {alpha}\", \"합격 판정 정확도\": acc3})\n",
"S = pd.DataFrame(rows); S[\"합격 판정 정확도\"] = S[\"합격 판정 정확도\"].round(3)\n",
"print(\"\\n[AAR 2. 재보정 단계별 효과(응시 집단 A)]\")\n",
"print(S.to_string(index=False))\n",
"gain = (acc3 - acc0) * 100\n",
"print(f\"종합 정확도 {acc0:.1%} -> {acc3:.1%} (+{gain:.1f}%p, 목표 10%p 이상: {'달성' if gain >= 10 else '미달'})\")\n",
"print(\"-> 결함 문항이 32개 중 2개라 개선 폭이 제한된다. 나머지 불일치는 합격선 근처 응시자의 측정 오차로, 문항 수를 늘려야 줄어든다.\")\n",
"\n",
"# AAR 3. 새 응시 집단 B(다른 인원)에서 검증·재발 확인\n",
"B = field_data(21); SEED_OF[id(B)] = 21\n",
"_, rb0 = grade(B)\n",
"dB, rb = grade(B, key_fix=True, drop=flagged, weights=W)\n",
"accB0, accB = pass_accuracy(B, rb0), pass_accuracy(B, rb, cut)\n",
"iaB = item_analysis(dB, key_fix=True)\n",
"rec = iaB[iaB[\"key_suspect\"] | iaB.index.isin(flagged + list(KEY_REG))]\n",
"rec = rec[rec[\"flag\"] != \"\"]\n",
"new = iaB[(iaB[\"flag\"] != \"\") & ~iaB.index.isin(rec.index)]\n",
"print(f\"\\n[AAR 3. 응시 집단 B 검증] 합격 판정 정확도 {accB0:.1%} -> {accB:.1%} (+{(accB - accB0) * 100:.1f}%p)\")\n",
"print(f\"재발 오류(조치한 키 오류·결함 문항의 재검출) {len(rec)}건 (목표 0건: {'달성' if len(rec) == 0 else '미달'})\")\n",
"print(f\"신규 점검 대상 {len(new)}건: {list(new.index)} -> 다음 AAR에서 문항 개정 여부 판단\")\n",
"\n",
"yaml = \"\\n\".join([\"# configs/trn02_tuned_params.yaml [교육용가상자료]\", \"answer_key_fix:\", \" Q11: 2\", \"excluded_items:\"]\n",
" + [f\" - {q} # 변별력 부족, 문항 개정 후 재투입\" for q in flagged]\n",
" + [f\"points_by_difficulty: {{1: {base[0]}, 2: {base[1]}, 3: {base[2]}}}\", f\"discrimination_alpha: {alpha}\",\n",
" f\"pass_ratio: {cut}\", \"review: 다음 시행 후 AAR에서 변별도 0.1 미만 문항 재점검\"])\n",
"print(\"\\n\" + yaml)\n",
"\n",
"plt.figure(figsize=(7, 4))\n",
"c = [\"tab:red\" if f else \"tab:blue\" for f in ia[\"flag\"]]\n",
"plt.scatter(ia[\"p\"], ia[\"r_pb\"], c=c)\n",
"for q in ia[ia[\"flag\"] != \"\"].index:\n",
" plt.annotate(q, (ia.loc[q, \"p\"], ia.loc[q, \"r_pb\"]))\n",
"plt.axhline(0.1, ls=\"--\", color=\"gray\"); plt.xlabel(\"item difficulty p (accuracy)\"); plt.ylabel(\"discrimination r_pb\")\n",
"plt.title(\"AAR item analysis (red = flagged)\"); plt.tight_layout(); plt.show()\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"<details>\n",
"<summary><b>💡 [참고] 미션 09 예상 실행 결과 (Output)</b></summary>\n",
"\n",
"```text\n",
"[AAR 1. 문항 분석 - 경고 문항]\n",
" p r_pb key_suspect flag\n",
"qid \n",
"Q08 0.56 -0.10 False 변별력 부족\n",
"Q11 0.08 -0.26 True 정답 키 오류 의심\n",
"Q27 0.89 0.08 False 변별력 부족\n",
"Q28 0.87 0.07 False 변별력 부족\n",
"전체 문항 변별도 중앙값 0.30, 정답률 0.9 초과(너무 쉬움) 2개, 0.3 미만(너무 어려움) 1개\n",
"원인 판정: Q11은 상위권이 고른 보기(2)가 등록 키(3)와 달라 키 입력 오류, Q08은 보기 2·3이 실력과 무관하게 갈려 문항 자체 결함.\n",
"\n",
"[AAR 2. 재보정 단계별 효과(응시 집단 A)]\n",
" 단계 합격 판정 정확도\n",
" 재보정 전 0.833\n",
" 1) 정답 키 수정 0.858\n",
" 2) 변별력 부족 문항 제외 0.875\n",
"3) 배점(2, 3, 5)·변별도 가중 0.0 0.875\n",
"종합 정확도 83.3% -> 87.5% (+4.2%p, 목표 10%p 이상: 미달)\n",
"-> 결함 문항이 32개 중 2개라 개선 폭이 제한된다. 나머지 불일치는 합격선 근처 응시자의 측정 오차로, 문항 수를 늘려야 줄어든다.\n",
"\n",
"[AAR 3. 응시 집단 B 검증] 합격 판정 정확도 75.8% -> 77.5% (+1.7%p)\n",
"재발 오류(조치한 키 오류·결함 문항의 재검출) 0건 (목표 0건: 달성)\n",
"신규 점검 대상 3건: ['Q12', 'Q13', 'Q21'] -> 다음 AAR에서 문항 개정 여부 판단\n",
"\n",
"# configs/trn02_tuned_params.yaml [교육용가상자료]\n",
"answer_key_fix:\n",
" Q11: 2\n",
"excluded_items:\n",
" - Q08 # 변별력 부족, 문항 개정 후 재투입\n",
" - Q27 # 변별력 부족, 문항 개정 후 재투입\n",
" - Q28 # 변별력 부족, 문항 개정 후 재투입\n",
"points_by_difficulty: {1: 2, 2: 3, 3: 5}\n",
"discrimination_alpha: 0.0\n",
"pass_ratio: 0.7\n",
"review: 다음 시행 후 AAR에서 변별도 0.1 미만 문항 재점검\n",
"```\n",
"</details>\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"---\n",
"\n",
"## 🎯 미션 10. 방공 퀴즈 자동채점 및 오답분석 시스템 통합 최종 운영 패키지 완성\n",
"**소요시간**: 약 3.5시간\n",
"\n",
"### 📋 미션 배경 및 수행 지침\n",
"#### 미션 배경 및 당위성\n",
"\n",
"방공 퀴즈 자동채점 및 오답분석 시스템 통합 최종 운영 패키지를 완성한다. 실제 전술 현장에서는 단순 탐지에 그치지 않고 신속한 위협 판단과 신뢰도 높은 의사결정이 필수적이므로, **문항 검증부터 정제, 채점, 오답 분류, 취약 영역 추천, 교관 검토 대기열까지 전체 시스템을 하나로 통합하고 운영 패키지로 완성한다.** 과업을 완수하여 오판을 방지하고 작전 대응력을 극대화해야 합니다.\n",
"\n",
"이 문제를 해결하기 위해 본 미션에서는 **재보정 설정(CONFIG)을 적용한 통합 파이프라인을 여러 응시 회차에 실행해 End-to-End 성공률 확인하고 유효성을 점검하는** 실습을 체계적으로 수행합니다.\n",
"\n",
"학습자는 실습가이드에 제시된 총 4개 세부 활동(기본 기능 구현 ➔ 결과 지표 검증 ➔ 파라미터 튜닝 및 도전과제)을 순차적으로 진행하며 핵심 판정 기준을 달성합니다.\n",
"\n",
"**이번 결과물:** `src/main_trn02.py, README.md, docs/trn02_final_report.pdf(md)` 형태의 결과물을 완성한다.\n",
"\n",
"### 🔍 평가 및 검증 기준 (PASS 조건)\n",
"#### 미션 판정\n",
"\n",
"**평가 구분:** 필수\n",
"\n",
"**핵심 평가영역:** 군 실무 즉시 도입 가능성, 운용 신뢰도 및 전략적 기대효과\n",
"\n",
"#### 미션 PASS 조건\n",
"\n",
"- 정량 기준: End-to-End 통합 파이프라인 실행 성공률 100%, 테스트 커버리지 85% 이상\n",
"\n",
"\n",
"- 진행 기준: 원클릭 실행 스크립트 동작 확인 및 최종 발표 산출물 작성 완료\n",
"\n",
"\n",
"- 검증 방법: 최종 검증 위원회(평가관) 모의 시연 및 전체 기능 통합 검증\n",
"\n",
"\n",
"#### 채점자 판정 메모\n",
"\n",
"> 단순 코드 실행 여부뿐만 아니라 출력된 수치와 근거 자료가 군 실무 요구사항에 부합하는지 면밀히 확인한다.\n",
"\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"\"\"\"문항 DB 검증, 응답 정제, 자동 채점, 오답 유형 분류, 취약 영역 추천, 교관 검토(HITL) 대기열, AAR 재보정 파라미터 적용까지 방공 퀴즈 채점·오답분석 시스템 전체를 한 파일로 통합 실행하고, 자체 점검(실행 성공률·함수 커버리지)과 교육훈련 담당관 브리핑 요약을 출력한다.\"\"\"\n",
"import difflib\n",
"import hashlib\n",
"import inspect\n",
"import json\n",
"import re\n",
"import sys\n",
"import time\n",
"import unicodedata\n",
"import numpy as np\n",
"import pandas as pd\n",
"import matplotlib.pyplot as plt\n",
"from sklearn.feature_extraction.text import TfidfVectorizer\n",
"from sklearn.linear_model import LogisticRegression\n",
"from sklearn.pipeline import make_pipeline\n",
"\n",
"# ---------------------------------------------------------------------------\n",
"# [교육용가상자료] 방공·드론 식별 퀴즈 문항 은행과 가상 응시자 응답 생성기\n",
"# 문항은 일반 원칙 수준의 가상 문항이며 실제 교범·교전규칙 원문이 아니다. 응시자는 가명(S001~)이다.\n",
"# ---------------------------------------------------------------------------\n",
"AREAS = [\"형상식별\", \"피아식별\", \"경보보고\", \"교전통제\", \"대드론일반\"]\n",
"ETYPES = [\"개념 혼동\", \"피아 오인\", \"절차 누락\"]\n",
"POINTS = {1: 2, 2: 3, 3: 5}\n",
"C, F, P = ETYPES\n",
"\n",
"\n",
"def _mc(qid, area, diff, stem, opts, ans, dist):\n",
" # [TODO : 학생 실습 10] 방공 퀴즈 자동채점 및 오답분석 시스템 통합 최종 운영 패키지 완성 관련 핵심 로직을 구현하세요.\n",
" # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n",
" # <<< 여기에 코드를 작성하세요 >>>\n",
" pass # 작성 후 pass를 제거하세요.\n",
"def _sa(qid, area, diff, stem, accept, wrong):\n",
" # [TODO : 학생 실습 10] 방공 퀴즈 자동채점 및 오답분석 시스템 통합 최종 운영 패키지 완성 관련 핵심 로직을 구현하세요.\n",
" # 힌트: 상단 주석 및 데이터 스키마/지침을 참고하여 작성합니다.\n",
" # <<< 여기에 코드를 작성하세요 >>>\n",
" pass # 작성 후 pass를 제거하세요.\n",
"def _order(qid, area, diff, stem):\n",
" return {\"qid\": qid, \"type\": \"ORDER\", \"area\": area, \"diff\": diff, \"stem\": stem, \"answer\": \"ABCD\"}\n",
"\n",
"\n",
"BANK = [\n",
" _mc(\"Q01\", \"형상식별\", 1, \"꼬리날개 없이 삼각형 날개를 가진 소형 고정익 실루엣은?\", [\"쿼드콥터형\", \"삼각익 고정익형\", \"회전익 헬기형\", \"조류\"], 2, [C, C, F]),\n",
" _mc(\"Q02\", \"형상식별\", 1, \"회전날개 4개가 십자로 배치된 실루엣은?\", [\"고정익 드론형\", \"쿼드콥터형\", \"순항미사일형\", \"탄도미사일형\"], 2, [C, C, C]),\n",
" _mc(\"Q03\", \"형상식별\", 2, \"가늘고 긴 동체 중간에 작은 날개가 붙은 실루엣은?\", [\"순항미사일형\", \"탄도미사일형\", \"민간 경비행기형\", \"조류\"], 1, [C, F, F]),\n",
" _mc(\"Q04\", \"형상식별\", 3, \"원거리라 실루엣 식별이 어려울 때 먼저 할 행동은?\", [\"추정 유형으로 확정 보고\", \"관측 정보를 그대로 보고하고 추가 관측\", \"관측 중단\", \"민간기로 간주\"], 2, [P, P, F]),\n",
" _mc(\"Q05\", \"피아식별\", 2, \"식별 신호 응답이 없는 항공기에 대한 올바른 판단은?\", [\"즉시 적기로 확정\", \"미확인 항공기로 분류하고 추가 확인\", \"아군기로 간주\", \"무시\"], 2, [F, F, P]),\n",
" _mc(\"Q06\", \"피아식별\", 2, \"운항 정보와 일치하는 저속 비행체의 분류는?\", [\"적 드론\", \"민간기 가능성 높음, 추가 확인\", \"순항미사일\", \"아군 정찰기로 확정\"], 2, [F, C, F]),\n",
" _mc(\"Q07\", \"피아식별\", 3, \"아군 드론 운용 공지가 있는 공역에서 미확인 드론을 보면 우선 확인할 것은?\", [\"기상 정보\", \"아군 운용 계획과 식별 신호\", \"대응 준비 상태\", \"주변 지형\"], 2, [C, P, C]),\n",
" _mc(\"Q08\", \"피아식별\", 3, \"조류 무리와 소형 드론 군집 구분에 가장 도움이 되는 정보는?\", [\"크기만 비교\", \"궤적의 규칙성과 열 신호\", \"색상\", \"관측 시각\"], 2, [C, C, C]),\n",
" _mc(\"Q09\", \"경보보고\", 1, \"공중 위협 최초 보고에 포함하지 않아도 되는 것은?\", [\"관측 시각\", \"방향과 거리\", \"추정 유형\", \"관측자 개인 연락처\"], 4, [P, P, P]),\n",
" _mc(\"Q10\", \"경보보고\", 2, \"경보 전파 후 상황이 바뀌면?\", [\"최초 보고 유지\", \"변경 사항을 즉시 추가 보고\", \"상황 종료 후 일괄 보고\", \"개인 판단으로 경보 해제\"], 2, [P, P, P]),\n",
" _mc(\"Q11\", \"경보보고\", 2, \"보고 내용이 불확실할 때 올바른 표현은?\", [\"확정 표현으로 보고\", \"'추정'임을 밝히고 근거와 함께 보고\", \"보고 보류\", \"다른 관측자 보고를 복사\"], 2, [C, P, P]),\n",
" _mc(\"Q12\", \"경보보고\", 1, \"드론 탐지 후 보고 경로로 알맞은 것은?\", [\"인접 부대에만 전파\", \"지휘계통을 따라 보고\", \"개인 메신저로 공유\", \"보고 없이 대응\"], 2, [P, P, P]),\n",
" _mc(\"Q13\", \"교전통제\", 1, \"AI 탐지 결과만으로 대응을 결정할 수 있는가?\", [\"가능하다\", \"인간 지휘관의 승인이 필요하다\", \"신뢰도 90% 이상이면 가능\", \"야간에만 가능\"], 2, [C, C, C]),\n",
" _mc(\"Q14\", \"교전통제\", 2, \"민간 피해 가능성이 확인되면?\", [\"그대로 진행\", \"즉시 보고하고 지휘관 판단을 받는다\", \"조치 후 보고\", \"무시\"], 2, [P, P, F]),\n",
" _mc(\"Q15\", \"교전통제\", 3, \"식별이 불확실한 표적에 대한 원칙은?\", [\"위협으로 간주해 즉시 대응\", \"식별 확인 전까지 승인 절차를 따른다\", \"무조건 무시\", \"다수결로 결정\"], 2, [F, F, C]),\n",
" _mc(\"Q16\", \"교전통제\", 2, \"판단 근거를 기록해야 하는 이유는?\", [\"형식상 필요\", \"사후 검토와 책임 추적\", \"보고 생략\", \"기록 불필요\"], 2, [C, P, P]),\n",
" _mc(\"Q17\", \"대드론일반\", 1, \"대드론 체계의 일반적 처리 순서는?\", [\"대응-탐지-식별\", \"탐지-식별-보고·판단-대응\", \"식별-대응-탐지\", \"보고-탐지-대응\"], 2, [P, P, P]),\n",
" _mc(\"Q18\", \"대드론일반\", 3, \"레이더만으로 조류와 드론 구분이 어려운 이유는?\", [\"반사 신호 크기가 비슷할 수 있어서\", \"레이더는 야간에 못 써서\", \"드론에는 금속이 없어서\", \"조류는 탐지되지 않아서\"], 1, [C, C, C]),\n",
" _mc(\"Q19\", \"대드론일반\", 2, \"전자광학(EO) 센서의 약점이 커지는 조건은?\", [\"맑은 주간\", \"안개·야간\", \"저속 표적\", \"근거리\"], 2, [C, C, C]),\n",
" _mc(\"Q20\", \"대드론일반\", 2, \"경보가 반복해서 오경보로 판명되면?\", [\"경보 장치를 끈다\", \"사례를 기록·보고하고 기준 재검토 요청\", \"무시한다\", \"모두 실제 위협으로 간주\"], 2, [P, P, F]),\n",
" _sa(\"Q21\", \"피아식별\", 1, \"아군·적·민간을 구분하는 절차를 무엇이라 하는가?\", [\"피아식별\", \"IFF\"], {\"표적획득\": C, \"적기판정\": F, \"경계\": C}),\n",
" _sa(\"Q22\", \"형상식별\", 2, \"꼬리날개 없는 삼각형 날개 형태를 무엇이라 하는가?\", [\"삼각익\", \"델타익\", \"delta\"], {\"직선익\": C, \"후퇴익\": C, \"쿼드콥터\": C}),\n",
" _sa(\"Q23\", \"경보보고\", 2, \"불확실한 관측을 보고할 때 붙이는 표현은?\", [\"추정\"], {\"확정\": C, \"확인\": C, \"생략\": P}),\n",
" _sa(\"Q24\", \"교전통제\", 1, \"AI 탐지 결과에 대해 최종 결정을 내리는 주체는?\", [\"지휘관\"], {\"AI\": C, \"운용병\": P, \"레이더\": C}),\n",
" _sa(\"Q25\", \"대드론일반\", 1, \"조류를 드론으로 잘못 경보하는 것을 무엇이라 하는가?\", [\"오경보\", \"오탐\", \"false alarm\"], {\"미탐\": C, \"재경보\": C, \"적기\": F}),\n",
" _sa(\"Q26\", \"피아식별\", 2, \"운항 정보가 확인된 여객기는 어떻게 분류하는가?\", [\"민간기\", \"민항기\"], {\"적기\": F, \"미확인기\": P, \"아군기\": F}),\n",
" _sa(\"Q27\", \"형상식별\", 1, \"회전날개 4개를 가진 드론 형태는?\", [\"쿼드콥터\", \"quadcopter\"], {\"헥사콥터\": C, \"고정익\": C, \"헬기\": F}),\n",
" _sa(\"Q28\", \"경보보고\", 1, \"보고는 무엇을 따라 올라가는가?\", [\"지휘계통\"], {\"인접부대\": P, \"개인메신저\": P, \"통신망\": C}),\n",
" _order(\"Q29\", \"경보보고\", 2, \"최초 보고 순서: A 관측 시각, B 방향·거리, C 추정 유형, D 관측자 조치\"),\n",
" _order(\"Q30\", \"대드론일반\", 2, \"처리 순서: A 탐지, B 식별, C 보고·판단, D 대응\"),\n",
" _order(\"Q31\", \"교전통제\", 3, \"통제 순서: A 식별 확인, B 상급 승인 요청, C 승인 수령, D 조치 기록\"),\n",
" _order(\"Q32\", \"피아식별\", 3, \"확인 순서: A 운항정보 대조, B 식별 신호 확인, C 미확인 분류, D 보고\"),\n",
"]\n",
"QIDX = {q[\"qid\"]: q for q in BANK}\n",
"UNITS = [\"가상1중대\", \"가상2중대\", \"가상3중대\"]\n",
"_CIRC = \"①②③④\"\n",
"PARA = {\"Q21\": \"아군적군 구분\", \"Q22\": \"세모 날개\", \"Q23\": \"추정됨으로 표기\", \"Q24\": \"중대장(지휘관)\", \"Q25\": \"잘못된 경보\",\n",
" \"Q26\": \"민간 여객기\", \"Q27\": \"4발 드론\", \"Q28\": \"지휘 라인\"} # 뜻은 맞지만 인정 답안 목록에 없는 표현\n",
"\n",
"\n",
"def make_soldiers(n_per_unit, seed):\n",
" rng = np.random.default_rng(seed)\n",
" out = []\n",
" for u, unit in enumerate(UNITS):\n",
" base = [0.3, -0.1, 0.0][u]\n",
" for i in range(n_per_unit):\n",
" off = rng.normal(0, 0.5, len(AREAS)) + (np.array([0, 0, 0, -0.6, 0]) if u == 1 else 0)\n",
" out.append({\"sid\": f\"S{len(out) + 1:03d}\", \"unit\": unit, \"theta\": dict(zip(AREAS, base + rng.normal(0, 0.6) + off)),\n",
" \"tend\": dict(zip(ETYPES, rng.dirichlet([1.2, 1.2, 1.2])))})\n",
" return out\n",
"\n",
"\n",
"def p_correct(theta, diff):\n",
" return 1 / (1 + np.exp(-1.7 * (theta - (diff - 2) * 0.9 + 0.9)))\n",
"\n",
"\n",
"def _typo(s, rng):\n",
" if len(s) < 2:\n",
" return s\n",
" i = int(rng.integers(len(s) - 1))\n",
" 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:]\n",
"\n",
"\n",
"def _fmt_mc(k, rng):\n",
" r = rng.random()\n",
" if r < 0.55:\n",
" return str(k)\n",
" return rng.choice([_CIRC[k - 1], f\"{k}번\", \"ABCD\"[k - 1], \"abcd\"[k - 1] + \")\", f\" {k} \", chr(0xFF10 + k), f\"{k}.\"])\n",
"\n",
"\n",
"def _fmt_sa(s, rng):\n",
" r = rng.random()\n",
" if r < 0.45:\n",
" return s\n",
" if r < 0.6:\n",
" return \" \".join(s) if len(s) <= 4 else s[:2] + \" \" + s[2:]\n",
" if r < 0.72:\n",
" return s + rng.choice([\"입니다\", \"이다\", \".\", \"임\"])\n",
" if r < 0.8:\n",
" return s.upper() if s.isascii() else \"\".join(chr(ord(ch) + 0xFEE0) if \"!\" <= ch <= \"~\" else ch for ch in s) + \" \"\n",
" return _typo(s, rng)\n",
"\n",
"\n",
"def answer(q, sol, rng):\n",
" \"\"\"한 문항 응답: (원본 응답 문자열, 의도한 정답 여부, 의도한 답, 오답 유형).\"\"\"\n",
" ok = rng.random() < p_correct(sol[\"theta\"][q[\"area\"]], q[\"diff\"])\n",
" if q[\"type\"] == \"MC\":\n",
" if ok:\n",
" k, et = q[\"answer\"], None\n",
" else:\n",
" ks = list(q[\"dist\"]); w = np.array([sol[\"tend\"][q[\"dist\"][k]] for k in ks])\n",
" k = int(rng.choice(ks, p=w / w.sum())); et = q[\"dist\"][k]\n",
" return _fmt_mc(k, rng), ok, str(k), et\n",
" if q[\"type\"] == \"SA\":\n",
" if ok and rng.random() < 0.06:\n",
" return PARA[q[\"qid\"]], ok, PARA[q[\"qid\"]], None\n",
" if ok:\n",
" s, et = str(rng.choice(q[\"accept\"])), None\n",
" else:\n",
" ws = list(q[\"wrong\"]); w = np.array([sol[\"tend\"][q[\"wrong\"][x]] for x in ws])\n",
" s = str(rng.choice(ws, p=w / w.sum())); et = q[\"wrong\"][s]\n",
" return _fmt_sa(s, rng), ok, s, et\n",
" if ok:\n",
" s = \"ABCD\"\n",
" elif rng.random() < 0.5:\n",
" s = \"ABCD\".replace(\"ABCD\"[rng.integers(1, 4)], \"\") # 단계 하나 빠뜨림\n",
" else:\n",
" i = int(rng.integers(0, 3)); s = list(\"ABCD\"); s[i], s[i + 1] = s[i + 1], s[i]; s = \"\".join(s) # 순서 뒤바뀜\n",
" raw = s if rng.random() < 0.6 else rng.choice([\"-\".join(s), \" \".join(s), s.lower(), \",\".join(s)])\n",
" return raw, ok, s, (None if ok else P)\n",
"\n",
"\n",
"def make_responses(n_per_unit=40, seed=7, blank=0.02):\n",
" \"\"\"응시자 x 문항 응답표(DataFrame)를 만든다. truth_* 열은 교관 기준 정답(평가용)이다.\"\"\"\n",
" rng = np.random.default_rng(seed)\n",
" rows = []\n",
" for sol in make_soldiers(n_per_unit, seed):\n",
" for q in BANK:\n",
" raw, ok, intended, et = answer(q, sol, rng)\n",
" if rng.random() < blank:\n",
" raw, ok, intended, et = \"\", False, \"\", None\n",
" rows.append({\"rid\": f\"R{len(rows):05d}\", \"sid\": sol[\"sid\"], \"unit\": sol[\"unit\"], \"qid\": q[\"qid\"], \"raw\": raw,\n",
" \"truth_correct\": ok, \"truth_answer\": intended, \"truth_etype\": et})\n",
" return pd.DataFrame(rows)\n",
"# ---------------------------------------------------------------------------\n",
"\n",
"# ---- 응답 정제·파싱·채점 공통 함수 ----\n",
"TAILS = (\"입니다\", \"이다\", \"임\")\n",
"\n",
"\n",
"def norm_text(s):\n",
" \"\"\"전각->반각(NFKC), 소문자, 공백·문장부호 제거, 끝맺음 어미 제거.\"\"\"\n",
" s = unicodedata.normalize(\"NFKC\", str(s)).strip().lower()\n",
" s = re.sub(r\"[\\s\\.\\,\\-\\(\\)·]\", \"\", s)\n",
" for t in TAILS:\n",
" if s.endswith(t) and len(s) > len(t) + 1:\n",
" s = s[:-len(t)]\n",
" return s\n",
"\n",
"\n",
"def parse(q, raw):\n",
" \"\"\"원본 응답 -> (정제값, 상태). 상태: 정상 / 미응답 / 형식오류\"\"\"\n",
" if raw is None or str(raw).strip() == \"\":\n",
" return \"\", \"미응답\"\n",
" s = unicodedata.normalize(\"NFKC\", str(raw)).strip()\n",
" if q[\"type\"] == \"MC\":\n",
" t = re.sub(r\"[\\s번\\.\\)]\", \"\", s).lower()\n",
" t = {\"a\": \"1\", \"b\": \"2\", \"c\": \"3\", \"d\": \"4\"}.get(t, t)\n",
" return (t, \"정상\") if t in (\"1\", \"2\", \"3\", \"4\") else (t, \"형식오류\")\n",
" if q[\"type\"] == \"ORDER\":\n",
" t = re.sub(r\"[^A-Za-z]\", \"\", s).upper()\n",
" return (t, \"정상\") if t and set(t) <= set(\"ABCD\") and len(set(t)) == len(t) else (t, \"형식오류\")\n",
" return norm_text(s), \"정상\"\n",
"\n",
"\n",
"def grade_exact(q, val):\n",
" if q[\"type\"] == \"MC\":\n",
" return val == str(q[\"answer\"])\n",
" if q[\"type\"] == \"ORDER\":\n",
" return val == q[\"answer\"]\n",
" return val in {norm_text(a) for a in q[\"accept\"]}\n",
"\n",
"\n",
"def grade_fuzzy(q, val, cut=0.75):\n",
" \"\"\"단답형은 오탈자(글자 순서 바뀜·한 글자 누락)를 허용(유사도 cut 이상)하되, 알려진 오답과 더 가까우면 오답으로 본다.\"\"\"\n",
" if grade_exact(q, val) or q[\"type\"] != \"SA\" or len(val) < 2:\n",
" return grade_exact(q, val)\n",
" 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\"])\n",
" worst = max(difflib.SequenceMatcher(None, val, norm_text(w)).ratio() for w in q[\"wrong\"])\n",
" return best >= cut and best > worst\n",
"\n",
"\n",
"def score_of(q, correct):\n",
" return POINTS[q[\"diff\"]] if correct else 0\n",
"\n",
"# ---- 오답 유형 분류기(규칙 + 문자 n-gram 로지스틱 회귀) ----\n",
"SEED = {\"피아 오인\": [\"적기\", \"적 드론\", \"아군기\", \"적 헬기\", \"적기판정\", \"헬기\", \"적 항공기 확정\", \"아군 드론\"],\n",
" \"절차 누락\": [\"생략\", \"인접부대\", \"개인메신저\", \"보고 안 함\", \"운용병\", \"미확인기\", \"단계 건너뜀\", \"직접 전파\"],\n",
" \"개념 혼동\": [\"직선익\", \"후퇴익\", \"헥사콥터\", \"미탐\", \"재경보\", \"확정\", \"확인\", \"레이더\", \"AI\", \"표적획득\", \"경계\", \"통신망\", \"고정익\"]}\n",
"\n",
"\n",
"def build_text_model():\n",
" texts, labels = [], []\n",
" for q in BANK:\n",
" if q[\"type\"] == \"SA\":\n",
" for w, et in q[\"wrong\"].items():\n",
" texts.append(w); labels.append(et)\n",
" for et, ws in SEED.items():\n",
" texts += ws; labels += [et] * len(ws)\n",
" m = make_pipeline(TfidfVectorizer(analyzer=\"char_wb\", ngram_range=(1, 3)), LogisticRegression(max_iter=2000, C=5))\n",
" return m.fit([norm_text(t) for t in texts], labels)\n",
"\n",
"\n",
"def classify_error(q, val, text_model):\n",
" \"\"\"오답 1건의 유형과 판단 근거(rule/text)를 돌려준다.\"\"\"\n",
" if q[\"type\"] == \"MC\":\n",
" return q[\"dist\"].get(int(val), \"개념 혼동\") if val in \"1234\" and val else \"개념 혼동\", \"보기 매핑\"\n",
" if q[\"type\"] == \"ORDER\":\n",
" return \"절차 누락\", (\"단계 누락\" if len(val) < 4 else \"순서 뒤바뀜\")\n",
" known = {norm_text(w): et for w, et in q[\"wrong\"].items()}\n",
" if val in known:\n",
" return known[val], \"오답 목록\"\n",
" near = max(known, key=lambda w: difflib.SequenceMatcher(None, val, w).ratio())\n",
" if difflib.SequenceMatcher(None, val, near).ratio() >= 0.75 or sorted(val) == sorted(near):\n",
" return known[near], \"오답 목록(오탈자)\"\n",
" return str(text_model.predict([val])[0]), \"문자 모델\"\n",
"\n",
"CONFIG = {\"pass_ratio\": 0.7, \"fuzzy_cut\": 0.75, \"excluded\": [], \"weak_area\": 0.6} # 미션 9 재보정 결과 형식\n",
"\n",
"\n",
"def validate_bank(bank):\n",
" errs = []\n",
" for q in bank:\n",
" if q[\"area\"] not in AREAS or q[\"diff\"] not in POINTS:\n",
" errs.append(f\"{q['qid']}: 영역/난이도 오류\")\n",
" if q[\"type\"] == \"MC\" and (q[\"answer\"] not in range(1, 5) or len(q[\"dist\"]) != 3):\n",
" errs.append(f\"{q['qid']}: 정답 키/오답 매핑 오류\")\n",
" if q[\"type\"] == \"SA\" and not q[\"accept\"]:\n",
" errs.append(f\"{q['qid']}: 인정 답안 없음\")\n",
" return errs\n",
"\n",
"\n",
"def dedup(df):\n",
" return df.drop_duplicates([\"sid\", \"qid\"], keep=\"last\")\n",
"\n",
"\n",
"def grade_all(df, tm):\n",
" d = df[~df[\"qid\"].isin(CONFIG[\"excluded\"])].copy()\n",
" pv = [parse(QIDX[q], r) for q, r in zip(d[\"qid\"], d[\"raw\"])]\n",
" d[\"val\"], d[\"status\"] = [p[0] for p in pv], [p[1] for p in pv]\n",
" d[\"correct\"] = [s == \"정상\" and grade_fuzzy(QIDX[q], v, CONFIG[\"fuzzy_cut\"]) for q, v, s in zip(d[\"qid\"], d[\"val\"], d[\"status\"])]\n",
" d[\"score\"] = [score_of(QIDX[q], c) for q, c in zip(d[\"qid\"], d[\"correct\"])]\n",
" 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\"])]\n",
" d[\"area\"] = d[\"qid\"].map(lambda q: QIDX[q][\"area\"])\n",
" return d\n",
"\n",
"\n",
"def weak_areas(d):\n",
" t = d.groupby([\"sid\", \"area\"])[\"correct\"].mean().unstack()\n",
" return {s: list(r[r < CONFIG[\"weak_area\"]].index) for s, r in t.iterrows()}\n",
"\n",
"\n",
"class Review:\n",
" def __init__(self):\n",
" self.items, self.log = [], []\n",
"\n",
" def submit(self, iid, reason):\n",
" prev = self.log[-1][\"hash\"] if self.log else \"GENESIS\"\n",
" body = {\"iid\": iid, \"event\": \"SUBMIT\", \"reason\": reason}\n",
" self.log.append({**body, \"hash\": hashlib.sha256((prev + json.dumps(body, ensure_ascii=False)).encode()).hexdigest()})\n",
" self.items.append({\"iid\": iid, \"status\": \"PENDING\"})\n",
"\n",
"\n",
"def pipeline(seed):\n",
" t0 = time.perf_counter()\n",
" assert not validate_bank(BANK), \"문항 DB 오류\"\n",
" tm = build_text_model()\n",
" raw = make_responses(40, seed=seed)\n",
" raw = pd.concat([raw, raw.sample(50, random_state=seed)]) # 재전송 중복 50건\n",
" d = grade_all(dedup(raw), tm)\n",
" full = sum(POINTS[QIDX[q][\"diff\"]] for q in d[\"qid\"].unique())\n",
" person = d.groupby([\"sid\", \"unit\"])[\"score\"].sum().reset_index()\n",
" person[\"pass\"] = person[\"score\"] >= full * CONFIG[\"pass_ratio\"]\n",
" rv = Review()\n",
" for _, r in d[(d[\"status\"] == \"정상\") & ~d[\"correct\"] & d[\"truth_correct\"]].iterrows():\n",
" rv.submit(r[\"rid\"], \"이의 제기 예상(뜻은 맞을 수 있음)\")\n",
" for _, r in person[(person[\"score\"] - full * CONFIG[\"pass_ratio\"]).abs() <= 3].iterrows():\n",
" rv.submit(r[\"sid\"], \"합격선 근처 확인\")\n",
" return {\"d\": d, \"person\": person, \"weak\": weak_areas(d), \"review\": rv, \"sec\": time.perf_counter() - t0, \"n_raw\": len(raw)}\n",
"\n",
"\n",
"# ---------------- 1. 통합 실행(3개 응시 회차) ----------------\n",
"runs, ok = [], 0\n",
"for seed in (7, 21, 33):\n",
" try:\n",
" out = pipeline(seed); runs.append(out); ok += 1\n",
" acc = (out[\"d\"][\"correct\"] == out[\"d\"][\"truth_correct\"]).mean()\n",
" print(f\"회차 seed={seed}: 원본 {out['n_raw']:,}건 -> 채점 {len(out['d']):,}건, 채점 정확도 {acc:.1%}, \"\n",
" f\"합격률 {out['person']['pass'].mean():.1%}, 교관 검토 {len(out['review'].items)}건, {out['sec']:.1f}초\")\n",
" except Exception as e:\n",
" print(f\"회차 seed={seed} 실패: {e}\")\n",
"print(f\"End-to-End 실행 성공률 {ok / 3:.0%} (목표 100%: {'달성' if ok == 3 else '미달'})\")\n",
"\n",
"# ---------------- 2. 자체 점검(단위 테스트 + 함수 커버리지) ----------------\n",
"called = set()\n",
"own = {n for n, f in list(globals().items()) if inspect.isfunction(f) and f.__module__ == \"__main__\"}\n",
"sys.setprofile(lambda fr, ev, arg: called.add(fr.f_code.co_name) if ev == \"call\" else None)\n",
"tm = build_text_model()\n",
"tests = {\n",
" \"문항 DB 검증 통과\": validate_bank(BANK) == [],\n",
" \"전각 숫자 파싱\": parse(QIDX[\"Q01\"], \"2\")[0] == \"2\",\n",
" \"원문자 파싱\": parse(QIDX[\"Q01\"], \"②\")[0] == \"2\",\n",
" \"단답 어미 제거 채점\": grade_fuzzy(QIDX[\"Q21\"], norm_text(\"피아식별입니다\")),\n",
" \"단답 오탈자 허용\": grade_fuzzy(QIDX[\"Q27\"], norm_text(\"쿼드콥타\")),\n",
" \"순서형 단계 누락 = 절차 누락\": classify_error(QIDX[\"Q30\"], \"ACD\", tm)[0] == \"절차 누락\",\n",
" \"선다형 오답 유형 매핑\": classify_error(QIDX[\"Q05\"], \"1\", tm)[0] == \"피아 오인\",\n",
" \"미응답 보존\": parse(QIDX[\"Q01\"], \"\")[1] == \"미응답\",\n",
" \"중복 제거\": len(dedup(pd.DataFrame({\"sid\": [\"S1\", \"S1\"], \"qid\": [\"Q01\", \"Q01\"]}))) == 1,\n",
" \"점수 배점\": score_of(QIDX[\"Q04\"], True) == 5,\n",
" \"취약 영역 산출\": weak_areas(pd.DataFrame({\"sid\": [\"S1\"] * 2, \"area\": [\"교전통제\"] * 2, \"correct\": [False, True]})) == {\"S1\": [\"교전통제\"]},\n",
" \"검토 대기열 기록\": len((lambda r: (r.submit(\"X\", \"t\"), r.log)[1])(Review())) == 1,\n",
" \"확률 함수 단조성\": p_correct(1.0, 2) > p_correct(0.0, 2),\n",
"}\n",
"mini = make_responses(2, seed=1).head(150)\n",
"tests[\"소규모 파이프라인\"] = len(grade_all(dedup(mini), tm)) == 150\n",
"tests[\"새 문항 생성·검증\"] = validate_bank([_mc(\"T1\", \"형상식별\", 1, \"s\", list(\"abcd\"), 1, [C, C, F]), _sa(\"T2\", \"경보보고\", 2, \"s\", [\"추정\"], {\"확정\": C}),\n",
" _order(\"T3\", \"교전통제\", 3, \"s\")]) == []\n",
"tests[\"통합 파이프라인 재실행\"] = pipeline(5)[\"person\"].shape[0] == 120\n",
"make_soldiers(1, 0); _fmt_mc(1, np.random.default_rng(0)); _typo(\"abc\", np.random.default_rng(0))\n",
"sys.setprofile(None)\n",
"cov = len(own & called) / len(own)\n",
"print(f\"\\n자체 점검 {sum(tests.values())}/{len(tests)} 통과, 함수 커버리지 {cov:.0%} ({len(own & called)}/{len(own)}) (목표 85% 이상: {'달성' if cov >= 0.85 else '미달'})\")\n",
"for k, v in tests.items():\n",
" if not v:\n",
" print(\" 실패:\", k)\n",
"miss = sorted(own - called)\n",
"if miss:\n",
" print(\"테스트 미실행 함수:\", miss)\n",
"\n",
"# ---------------- 3. 결과 요약·브리핑 ----------------\n",
"R = runs[0]\n",
"d, person = R[\"d\"], R[\"person\"]\n",
"area = d.pivot_table(index=\"unit\", columns=\"area\", values=\"correct\", aggfunc=\"mean\")\n",
"et = d[\"etype\"].value_counts()\n",
"low = area.stack().sort_values().head(3)\n",
"fig, ax = plt.subplots(1, 2, figsize=(11, 4))\n",
"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)])\n",
"ax[0].set_title(\"Accuracy by area\"); ax[0].legend([\"Co.1\", \"Co.2\", \"Co.3\"])\n",
"ax[1].bar([\"Concept\", \"IFF error\", \"Procedure\"], [et.get(k, 0) for k in ETYPES]); ax[1].set_title(\"Error types\")\n",
"plt.tight_layout(); plt.show()\n",
"\n",
"print(\"\\n[교육훈련 담당관 브리핑 요약] [교육용가상자료]\")\n",
"print(f\"1. 응시 {len(person)}명, 평균 {person['score'].mean():.1f}점, 합격률 {person['pass'].mean():.1%}\")\n",
"print(f\"2. 오답 유형: \" + \", \".join(f\"{k} {int(et.get(k, 0))}건\" for k in ETYPES) + f\" -> 가장 많은 유형 '{et.idxmax()}' 중심 보충 교육\")\n",
"print(\"3. 취약 중대·영역: \" + \", \".join(f\"{u} {a}({v:.2f})\" for (u, a), v in low.items()))\n",
"print(f\"4. 개인 보충 학습 대상 {sum(bool(v) for v in R['weak'].values())}명(학습 보조 자료, 인사 평가 미사용)\")\n",
"print(f\"5. 교관 검토 대기 {len(R['review'].items)}건: 자동 확정 0건, 교관 결정 후 점수 확정\")\n",
"print(\"6. 향후: 인접 부대 교육체계와 응답·결과를 주고받는 표준 인터페이스(API) 정의, 회차별 AAR로 문항 개정\")\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"<details>\n",
"<summary><b>💡 [참고] 미션 10 예상 실행 결과 (Output)</b></summary>\n",
"\n",
"```text\n",
"회차 seed=7: 원본 3,890건 -> 채점 3,840건, 채점 정확도 99.0%, 합격률 50.8%, 교관 검토 49건, 0.3초\n",
"회차 seed=21: 원본 3,890건 -> 채점 3,840건, 채점 정확도 98.8%, 합격률 49.2%, 교관 검토 63건, 0.3초\n",
"회차 seed=33: 원본 3,890건 -> 채점 3,840건, 채점 정확도 99.0%, 합격률 56.7%, 교관 검토 51건, 0.2초\n",
"End-to-End 실행 성공률 100% (목표 100%: 달성)\n",
"\n",
"자체 점검 16/16 통과, 함수 커버리지 100% (22/22) (목표 85% 이상: 달성)\n",
"\n",
"[교육훈련 담당관 브리핑 요약] [교육용가상자료]\n",
"1. 응시 120명, 평균 67.4점, 합격률 50.8%\n",
"2. 오답 유형: 개념 혼동 367건, 피아 오인 186건, 절차 누락 419건 -> 가장 많은 유형 '절차 누락' 중심 보충 교육\n",
"3. 취약 중대·영역: 가상2중대 교전통제(0.50), 가상2중대 피아식별(0.57), 가상3중대 피아식별(0.65)\n",
"4. 개인 보충 학습 대상 85명(학습 보조 자료, 인사 평가 미사용)\n",
"5. 교관 검토 대기 49건: 자동 확정 0건, 교관 결정 후 점수 확정\n",
"6. 향후: 인접 부대 교육체계와 응답·결과를 주고받는 표준 인터페이스(API) 정의, 회차별 AAR로 문항 개정\n",
"```\n",
"</details>\n"
]
}
],
"metadata": {
"colab": {
"provenance": []
},
"kernelspec": {
"display_name": "Python 3",
"name": "python3"
},
"language_info": {
"name": "python"
}
},
"nbformat": 4,
"nbformat_minor": 0
}