diff --git a/docs/FINAL_REPORT.md b/docs/FINAL_REPORT.md index 52a4483..a3ac030 100644 --- a/docs/FINAL_REPORT.md +++ b/docs/FINAL_REPORT.md @@ -128,9 +128,14 @@ stroke session(다획 입력 버퍼 0.8초) → 특징 추출 → family 판정 | family별 첫 시도 성공률 | base family별 첫 attempt 성공 비율 분포 | RQ3 | attempts.csv | | 실패 유형 분포 | invalid/incomplete/dependency/detached/no-seal | RQ2 | attempts.csv | | assist level 도달률 | 힌트 단계 1/2/3 사용 횟수 | RQ2 | attempts.csv | -| 피드백 이해도 | 실패 후 다음 행동을 말로 설명 가능한지 | RQ2 | 인터뷰 | +| 피드백 이해도 | 실패 후 다음 행동을 말로 설명 가능한지 + 발화대로 다음 시도가 변했는지 로그 대조 | RQ2 | 인터뷰 + attempts.csv | | 막힘 지점 | 2분 이상 정체 또는 동일 목표 3회 이상 실패 | RQ1 | 관찰 | +| 세션 내 학습 곡선 | 시도 순서 rolling 성공률의 기울기 (양수 = 플레이만으로 향상) | RQ1 | attempts.csv 분석 | +| 힌트 전후 성공률 | 목표 단위 첫 힌트 노출 이전 vs 이후 성공률 비교 (escalator 효과의 준-인과 증거) | RQ2 | attempts.csv 분석 | | 설문 5점 척도 | 명확성·공정성·피드백 도움·조작감·몰입감 | 전체 | 설문 | +| SUS | System Usability Scale 10문항, 표준 채점 0~100 (기준선 68) | 전체 | 설문 | + +학습 곡선·힌트 전후·family 공정성 분석은 `scripts/playtest-attempts-analysis.py`가 자동 산출한다 (합성 데이터 자가 검증 포함). 단일 조건 관찰 연구의 한계는 §6.2에 명시하되, 힌트 전후 비교가 RQ2의 준-인과적 증거를 제공한다. ### 5.3 사전 수집: stroke 데이터 @@ -159,6 +164,8 @@ stroke session(다획 입력 버퍼 0.8초) → 특징 추출 → family 판정 - 인식 휴리스틱의 개인차: 특징 기반 판정은 여전히 일부 필체에 편향될 수 있다 (user profile 보정의 Unity 이식은 향후 과제). - 표본 크기 N=5~10, 단일 세션 측정 — 장기 학습 곡선은 다루지 않는다. +- **비교 조건(힌트 유/무) 없는 단일 조건 관찰 연구** — escalator의 효과는 힌트 노출 전후의 세션 내 비교로만 추정하며, 인과 주장은 하지 않는다. 형성적(formative) 평가로 프레이밍한다. +- quality 분리 설계는 운동 제약이 있는 사용자에게 체계적 약화 페널티가 될 수 있다 — 접근성 관점의 quality floor 옵션은 향후 과제. - 참가자 모집 풀의 동질성(대학 환경) 가능성. ### 6.3 Future Work diff --git a/docs/RESEARCH_PROTOCOL.md b/docs/RESEARCH_PROTOCOL.md index a0c0079..2a06e9c 100644 --- a/docs/RESEARCH_PROTOCOL.md +++ b/docs/RESEARCH_PROTOCOL.md @@ -38,7 +38,29 @@ | assist level 도달률 | assistLevel 1/2/3 사용 횟수 | attempts.csv | | 막힘 지점 | 2분 이상 진행 정체 또는 같은 목표 3회 이상 실패 | 관찰 | | 피드백 이해도 | 실패 후 다음 행동을 말로 설명 가능한지 | 인터뷰 | +| 피드백 이해의 행동 검증 | 참가자가 말한 교정 방향대로 다음 시도가 실제로 변했는지 (발화 ↔ attempts.csv 대조 코딩) | 인터뷰 + attempts.csv | | 몰입감 | 직접 마법을 시전한다는 느낌 | 설문 | +| SUS 점수 | System Usability Scale 10문항 (0~100) | 설문 | + +## 사후 설문 구성 + +1. 커스텀 5문항 (5점 척도): 명확성, 공정성, 피드백 도움, 조작감, 몰입감 + 자유 의견 +2. SUS 10문항 (5점 척도, 표준 채점: 홀수 문항 점수-1, 짝수 문항 5-점수, 합계 ×2.5) + +SUS 문항 (한국어 번안, "이 시스템" = 이 게임의 드로잉 입력): + +1. 이 게임을 자주 하고 싶다. +2. 이 게임의 조작이 불필요하게 복잡하다고 느꼈다. +3. 이 게임의 조작이 쉬웠다. +4. 이 게임을 하려면 누군가의 도움이 필요할 것 같다. +5. 이 게임의 여러 기능이 잘 어우러져 있었다. +6. 이 게임의 동작이 일관되지 않다고 느꼈다. +7. 대부분의 사람들이 이 게임의 조작을 빨리 익힐 것 같다. +8. 이 게임의 조작이 매우 번거로웠다. +9. 게임을 하는 동안 자신감을 느꼈다. +10. 이 게임을 시작하기 전에 알아야 할 것이 많았다. + +기준선: SUS 68점이 통상적 평균. 68 이상이면 평균 이상의 사용성으로 보고한다. ## 입력 버퍼 A/B @@ -54,9 +76,14 @@ 1. `attempts.csv`를 session 단위로 모은다. 2. 직접 식별 정보가 없는지 확인한다. -3. floor, phase, recognizedFamily, worldEffect, success, assistLevel을 pivot한다. -4. 상위 오인식/막힘 3개를 Phase 4/5 백로그로 옮긴다. -5. 입력 버퍼 D3 결정을 `docs/FINAL_COMPLETION_PLAN.md` 결정 로그에 기록한다. +3. `python scripts/playtest-attempts-analysis.py <세션폴더...> --out outputs/playtest-1/analysis`를 실행한다. 산출: + - RQ1: 세션 내 학습 곡선(rolling 성공률 기울기), 층별 첫 성공까지 시도 수 + - RQ2: 목표 단위 힌트 노출 전후 성공률 비교, assist level별 직후 성공률 + - RQ3: family별 첫 시도 성공률 분포와 편차 +4. floor, phase, recognizedFamily, worldEffect, success, assistLevel을 pivot한다. +5. 상위 오인식/막힘 3개를 Phase 4/5 백로그로 옮긴다. +6. 입력 버퍼 D3 결정을 `docs/FINAL_COMPLETION_PLAN.md` 결정 로그에 기록한다. +7. SUS를 표준 채점해 세션별 점수와 평균을 기록한다. ## 보고 산출물 diff --git a/scripts/playtest-attempts-analysis.py b/scripts/playtest-attempts-analysis.py new file mode 100644 index 0000000..78f1de8 --- /dev/null +++ b/scripts/playtest-attempts-analysis.py @@ -0,0 +1,352 @@ +#!/usr/bin/env python3 +"""플레이테스트 attempts.csv 분석 — RQ1/RQ2/RQ3 핵심 지표 산출. + +입력: ExamLogger가 남기는 attempts.csv (세션 폴더 또는 파일 경로, 복수 가능) +출력: 마크다운 요약 + 지표별 CSV (--out 디렉터리) + +산출 지표 + RQ1 (무설명 학습 가능성) + - 세션 내 학습 곡선: 시도 순서 rolling window 성공률 (기본 창 10) + - 층별 진입 후 첫 성공까지 시도 수 + RQ2 (실패 피드백의 효과) + - 목표(goal/family) 단위로 첫 힌트 노출 이전 vs 이후 성공률 비교 + - assist level별 직후 3시도 성공률 + RQ3 (필체 공정성) + - base family별 첫 시도 성공률 분포 + +사용 예 + python scripts/playtest-attempts-analysis.py <세션폴더|attempts.csv> [...] --out outputs/playtest-analysis + python scripts/playtest-attempts-analysis.py --self-test +""" + +from __future__ import annotations + +import argparse +import csv +import io +import math +import random +import sys +from collections import defaultdict +from pathlib import Path + +WINDOW = 10 +POST_HINT_WINDOW = 3 + + +def parse_bool(value: str) -> bool: + return str(value).strip().lower() == "true" + + +def load_attempts(path: Path) -> list[dict]: + """attempts.csv 한 파일을 읽어 dict 리스트로 반환. 세션 폴더를 주면 내부 파일을 찾는다.""" + if path.is_dir(): + candidate = path / "attempts.csv" + if not candidate.exists(): + raise FileNotFoundError(f"attempts.csv not found in {path}") + path = candidate + rows: list[dict] = [] + with open(path, encoding="utf-8-sig", newline="") as f: + for row in csv.DictReader(f): + rows.append( + { + "sessionId": row.get("sessionId", ""), + "floorId": row.get("floorId", ""), + "phase": row.get("phase", ""), + "family": row.get("targetFamily") or row.get("baseFamily") or "", + "goalId": row.get("intentGoalId", ""), + "status": row.get("status", ""), + "attemptIndex": int(float(row.get("attemptIndex") or 0)), + "elapsedMs": int(float(row.get("elapsedMs") or 0)), + "success": parse_bool(row.get("success", "")), + "hintShown": parse_bool(row.get("hintShown", "")), + "assistLevel": int(float(row.get("assistLevel") or 0)), + } + ) + rows.sort(key=lambda r: (r["sessionId"], r["attemptIndex"])) + return rows + + +def by_session(rows: list[dict]) -> dict[str, list[dict]]: + sessions: dict[str, list[dict]] = defaultdict(list) + for row in rows: + sessions[row["sessionId"]].append(row) + return dict(sessions) + + +# ---------------------------------------------------------------- RQ1 + +def learning_curve(rows: list[dict], window: int = WINDOW) -> list[tuple[int, float]]: + """시도 순서 기준 rolling 성공률. (창 중심 시도 번호, 성공률) 목록.""" + points = [] + successes = [1 if r["success"] else 0 for r in rows] + for end in range(window, len(successes) + 1): + chunk = successes[end - window : end] + points.append((end, sum(chunk) / window)) + return points + + +def curve_slope(points: list[tuple[int, float]]) -> float: + """학습 곡선의 단순 선형 기울기 (양수면 세션 내 향상).""" + n = len(points) + if n < 2: + return 0.0 + xs = [p[0] for p in points] + ys = [p[1] for p in points] + mx, my = sum(xs) / n, sum(ys) / n + denom = sum((x - mx) ** 2 for x in xs) + if denom == 0: + return 0.0 + return sum((x - mx) * (y - my) for x, y in zip(xs, ys)) / denom + + +def attempts_to_first_success_per_floor(rows: list[dict]) -> dict[str, int]: + result: dict[str, int] = {} + counts: dict[str, int] = defaultdict(int) + for row in rows: + floor = row["floorId"] + if floor in result: + continue + counts[floor] += 1 + if row["success"]: + result[floor] = counts[floor] + return result + + +# ---------------------------------------------------------------- RQ2 + +def goal_key(row: dict) -> str: + return row["goalId"] or f"{row['floorId']}:{row['family']}" + + +def hint_before_after(rows: list[dict]) -> dict[str, dict]: + """목표 단위로 첫 힌트 노출 이전/이후 성공률을 비교한다. + + 힌트 노출 = hintShown 또는 assistLevel >= 1 인 시도. + 이전/이후 양쪽에 시도가 있는 목표만 비교에 포함한다. + """ + grouped: dict[str, list[dict]] = defaultdict(list) + for row in rows: + grouped[goal_key(row)].append(row) + + comparisons = {} + for key, attempts in grouped.items(): + hint_at = next( + (i for i, a in enumerate(attempts) if a["hintShown"] or a["assistLevel"] >= 1), + None, + ) + if hint_at is None or hint_at == 0 or hint_at == len(attempts) - 1: + continue + before = attempts[:hint_at] + after = attempts[hint_at + 1 :] + if not before or not after: + continue + comparisons[key] = { + "before_n": len(before), + "before_rate": sum(a["success"] for a in before) / len(before), + "after_n": len(after), + "after_rate": sum(a["success"] for a in after) / len(after), + } + return comparisons + + +def post_assist_success(rows: list[dict], window: int = POST_HINT_WINDOW) -> dict[int, tuple[int, float]]: + """assist level별로, 해당 레벨 힌트가 보인 시도 직후 window회의 성공률.""" + buckets: dict[int, list[int]] = defaultdict(list) + for i, row in enumerate(rows): + if row["assistLevel"] >= 1 and (row["hintShown"] or not row["success"]): + following = rows[i + 1 : i + 1 + window] + for a in following: + buckets[row["assistLevel"]].append(1 if a["success"] else 0) + return { + level: (len(vals), sum(vals) / len(vals)) + for level, vals in sorted(buckets.items()) + if vals + } + + +# ---------------------------------------------------------------- RQ3 + +def family_first_attempt_rate(rows: list[dict]) -> dict[str, tuple[int, float]]: + """family별 '그 family에 대한 세션 내 첫 시도' 성공률. (표본 수, 성공률)""" + first_attempts: dict[tuple[str, str], dict] = {} + for row in rows: + if not row["family"]: + continue + key = (row["sessionId"], row["family"]) + if key not in first_attempts: + first_attempts[key] = row + rates: dict[str, list[int]] = defaultdict(list) + for (_, family), row in first_attempts.items(): + rates[family].append(1 if row["success"] else 0) + return { + family: (len(vals), sum(vals) / len(vals)) + for family, vals in sorted(rates.items()) + } + + +# ---------------------------------------------------------------- 리포트 + +def write_report(all_rows: list[dict], out_dir: Path) -> str: + out_dir.mkdir(parents=True, exist_ok=True) + sessions = by_session(all_rows) + md = io.StringIO() + md.write("# Playtest attempts 분석\n\n") + md.write(f"세션 {len(sessions)}개, 시도 {len(all_rows)}건\n\n") + + md.write("## RQ1 — 세션 내 학습 곡선\n\n") + md.write(f"| 세션 | 시도 수 | 전체 성공률 | rolling({WINDOW}) 기울기 | 해석 |\n|---|---|---|---|---|\n") + curve_csv = [("sessionId", "attemptEnd", "rollingSuccessRate")] + for sid, rows in sessions.items(): + rate = sum(r["success"] for r in rows) / len(rows) + points = learning_curve(rows) + slope = curve_slope(points) + for end, value in points: + curve_csv.append((sid, end, f"{value:.3f}")) + verdict = "세션 내 향상" if slope > 0 else "향상 없음/감소" + md.write(f"| {sid} | {len(rows)} | {rate:.2f} | {slope:+.4f} | {verdict} |\n") + with open(out_dir / "learning-curve.csv", "w", encoding="utf-8", newline="") as f: + csv.writer(f).writerows(curve_csv) + + md.write("\n층별 첫 성공까지 시도 수:\n\n| 세션 | " ) + floors = sorted({r["floorId"] for r in all_rows if r["floorId"]}) + md.write(" | ".join(floors) + " |\n|" + "---|" * (len(floors) + 1) + "\n") + for sid, rows in sessions.items(): + per_floor = attempts_to_first_success_per_floor(rows) + md.write(f"| {sid} | " + " | ".join(str(per_floor.get(f, "-")) for f in floors) + " |\n") + + md.write("\n## RQ2 — 힌트 노출 전후 성공률 (목표 단위)\n\n") + pooled_before, pooled_after = [], [] + comp_csv = [("sessionId", "goal", "beforeN", "beforeRate", "afterN", "afterRate")] + for sid, rows in sessions.items(): + for key, c in hint_before_after(rows).items(): + comp_csv.append((sid, key, c["before_n"], f"{c['before_rate']:.3f}", c["after_n"], f"{c['after_rate']:.3f}")) + pooled_before.append(c["before_rate"]) + pooled_after.append(c["after_rate"]) + with open(out_dir / "hint-before-after.csv", "w", encoding="utf-8", newline="") as f: + csv.writer(f).writerows(comp_csv) + if pooled_before: + mb = sum(pooled_before) / len(pooled_before) + ma = sum(pooled_after) / len(pooled_after) + md.write(f"비교 가능한 목표 {len(pooled_before)}개 — 힌트 이전 평균 성공률 **{mb:.2f}** → 이후 **{ma:.2f}** (변화 {ma - mb:+.2f})\n\n") + else: + md.write("비교 가능한 목표 없음 (힌트 전후 양쪽에 시도가 있는 목표가 없음)\n\n") + + md.write(f"assist level별 직후 {POST_HINT_WINDOW}시도 성공률:\n\n| level | 표본 | 성공률 |\n|---|---|---|\n") + for level, (n, rate) in post_assist_success(all_rows).items(): + md.write(f"| {level} | {n} | {rate:.2f} |\n") + + md.write("\n## RQ3 — family별 첫 시도 성공률\n\n| family | 표본(세션 수) | 성공률 |\n|---|---|---|\n") + fam_csv = [("family", "n", "firstAttemptSuccessRate")] + rates = family_first_attempt_rate(all_rows) + for family, (n, rate) in rates.items(): + md.write(f"| {family} | {n} | {rate:.2f} |\n") + fam_csv.append((family, n, f"{rate:.3f}")) + with open(out_dir / "family-first-attempt.csv", "w", encoding="utf-8", newline="") as f: + csv.writer(f).writerows(fam_csv) + if rates: + values = [rate for _, rate in rates.values()] + spread = max(values) - min(values) + md.write(f"\nfamily 간 첫 시도 성공률 편차(최대-최소): **{spread:.2f}** — 0.3 이상이면 해당 family 인식 보정 검토.\n") + + report = md.getvalue() + (out_dir / "report.md").write_text(report, encoding="utf-8") + return report + + +# ---------------------------------------------------------------- self test + +def make_synthetic(out_path: Path, seed: int = 7) -> None: + """알려진 성질의 합성 데이터: 학습할수록 성공률 상승, 힌트 후 성공률 상승, water만 첫 시도 성공률 낮음.""" + rng = random.Random(seed) + header = "sessionId,trialId,targetFamily,recognizedFamily,phase,baseFamily,overlayStack,sealId,floorId,targetObject,worldEffect,customShapeId,customShapeLabel,customShapeToken,mappedFamily,customEventId,customEventLabel,customEventKind,customEventRole,customEventUsesDirection,customEventOperatorOnly,customEventBlocks,customEventBlocked,customEventBlockedBy,customEventOriginX,customEventOriginY,customEventDirectionX,customEventDirectionY,status,confidence,customScore,defaultSimilarityScore,intentFamily,intentGoalId,intentSource,intentStrength,intentSimilarityScore,intentWeakConsiderationApplied,intentTutorialCaptureCount,intentStrongConsiderationEnabled,preIntentFamily,preIntentConfidence,intentStrongConsiderationApplied,intentScoreLift,closure,smoothness,tempo,stability,rotationBias,worldX,worldY,bufferStrokeCount,attemptIndex,elapsedMs,feedbackViewed,success,hintShown,assistLevel,assisted" + lines = [header] + families = ["fire", "water", "wind", "earth", "life"] + for s in range(3): + sid = f"synthetic-{s}" + idx = 0 + elapsed = 0 + for goal_n in range(10): + family = families[goal_n % 5] + goal = f"goal-{goal_n}" + floor = f"floor-{goal_n // 2 + 1}" + fails_before_hint = rng.randint(2, 3) + for k in range(fails_before_hint + 4): + idx += 1 + elapsed += rng.randint(2000, 9000) + base_skill = min(0.15 + idx * 0.015, 0.85) # 학습 곡선 + hint_seen = k >= fails_before_hint + p = base_skill + (0.35 if hint_seen else 0.0) + if family == "water" and k == 0: + p = 0.05 # water 첫 시도만 불리 + success = rng.random() < p + hint_flag = "true" if (k == fails_before_hint) else "false" + assist = 1 if hint_seen else 0 + lines.append( + f'"{sid}","t{idx}","{family}","{family if success else ""}","base","{family}","","","{floor}","","","","","","","","","","",false,false,false,false,"",0,0,0,0,"{ "recognized" if success else "invalid" }",0.5,0,0,"","{goal}","",0,0,false,0,false,"",0,false,0,0.5,0.5,0.5,0.5,0,0,0,1,{idx},{elapsed},true,{"true" if success else "false"},{hint_flag},{assist},false' + ) + if success and k >= 1: + break + out_path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def self_test(tmp_dir: Path) -> int: + tmp_dir.mkdir(parents=True, exist_ok=True) + csv_path = tmp_dir / "attempts.csv" + make_synthetic(csv_path) + rows = load_attempts(csv_path) + sessions = by_session(rows) + failures = [] + + slopes = [curve_slope(learning_curve(r)) for r in sessions.values()] + if not all(s > 0 for s in slopes): + failures.append(f"학습 곡선 기울기가 양수가 아님: {slopes}") + + pooled = [] + for r in sessions.values(): + for c in hint_before_after(r).values(): + pooled.append(c["after_rate"] - c["before_rate"]) + if not pooled or sum(pooled) / len(pooled) <= 0: + failures.append(f"힌트 전후 개선이 검출되지 않음 (n={len(pooled)})") + + rates = family_first_attempt_rate(rows) + if rates and not (rates.get("water", (0, 1.0))[1] <= min(v for k, (_, v) in rates.items() if k != "water")): + failures.append(f"water 첫 시도 불리 조건이 재현되지 않음: {rates}") + + write_report(rows, tmp_dir / "report") + + if failures: + print("SELF-TEST FAILED") + for f in failures: + print(" -", f) + return 1 + print(f"SELF-TEST OK — 세션 {len(sessions)}, 시도 {len(rows)}, 힌트 비교쌍 {len(pooled)}") + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("inputs", nargs="*", help="세션 폴더 또는 attempts.csv 경로 (복수 가능)") + parser.add_argument("--out", default="outputs/playtest-analysis", help="결과 출력 폴더") + parser.add_argument("--self-test", action="store_true", help="합성 데이터로 자가 검증") + args = parser.parse_args() + + if args.self_test: + return self_test(Path("outputs/playtest-analysis-selftest")) + + if not args.inputs: + parser.error("입력 경로가 없습니다 (또는 --self-test 사용)") + + all_rows: list[dict] = [] + for raw in args.inputs: + all_rows.extend(load_attempts(Path(raw))) + if not all_rows: + print("시도 데이터가 없습니다", file=sys.stderr) + return 1 + report = write_report(all_rows, Path(args.out)) + print(report) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())