{"metadata":{"kernelspec":{"language":"python","display_name":"Python 3","name":"python3"},"language_info":{"name":"python","version":"3.11.13","mimetype":"text/x-python","codemirror_mode":{"name":"ipython","version":3},"pygments_lexer":"ipython3","nbconvert_exporter":"python","file_extension":".py"},"kaggle":{"accelerator":"none","dataSources":[{"sourceId":59156,"databundleVersionId":13719397,"sourceType":"competition"}],"dockerImageVersionId":31089,"isInternetEnabled":false,"language":"python","sourceType":"notebook","isGpuEnabled":false}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"markdown","source":"","metadata":{}},{"cell_type":"code","source":"# ===============================================\n# MABe mouse: train 인덱싱 + 메타 매칭 + EDA (robust) + tqdm\n# ===============================================\nimport os, glob, json, random, warnings\nfrom pathlib import Path\nfrom collections import Counter\n\nimport numpy as np\nimport pandas as pd\nimport matplotlib.pyplot as plt\nfrom tqdm.auto import tqdm\ntqdm.pandas()  # pandas progress_apply 활성화\n\nwarnings.filterwarnings(\"ignore\")\n\n# ------------------------\n# 경로/설정\n# ------------------------\nROOT = \"/kaggle/input/MABe-mouse-behavior-detection\"\nSPLIT = \"train\"   # \"test\"로 바꿔도 동작\nTRACKING_DIR = f\"{ROOT}/{SPLIT}_tracking\"\nANNOT_DIR    = f\"{ROOT}/{SPLIT}_annotation\"\nMETA_CSV     = f\"{ROOT}/{SPLIT}.csv\"\n\n# 진행바 설정: 바깥 루프/안쪽 루프(leave 충돌 방지)\nTQDM_OUTER = dict(mininterval=0.2, leave=True)\nTQDM_INNER = dict(mininterval=0.2, leave=False)\n\n# ------------------------\n# 유틸\n# ------------------------\ndef normalize_cols(df: pd.DataFrame) -> pd.DataFrame:\n    \"\"\"열 이름을 소문자 + 공백→언더스코어로 변경.\"\"\"\n    df = df.copy()\n    df.columns = [c.strip().lower().replace(\" \", \"_\") for c in df.columns]\n    return df\n\ndef as_str(x):\n    \"\"\"video_id 등 키를 문자열로 강제.\"\"\"\n    if pd.isna(x):\n        return None\n    return str(x)\n\ndef _is_file_ok(p: Path):\n    try:\n        return p.is_file()\n    except Exception:\n        return False\n\ndef _clean_path_list(x):\n    \"\"\"\n    리스트/스칼라 어떤 형태로 와도, 실제 존재하는 파일 경로(str)만 걸러서 리스트로.\n    디렉토리는 제외.\n    \"\"\"\n    if isinstance(x, list):\n        cand = x\n    elif pd.isna(x):\n        cand = []\n    else:\n        cand = [x]\n    out = []\n    for v in cand:\n        if isinstance(v, str) and v.lower() != \"nan\" and len(v.strip()) > 0:\n            p = Path(v)\n            if _is_file_ok(p):\n                out.append(v)\n    return out\n\ndef _sum_lists_strict(series):\n    \"\"\"groupby.agg 때 사용: 유효 파일 경로만 묶어서 리스트 반환\"\"\"\n    out = []\n    for v in series:\n        if isinstance(v, list):\n            for s in v:\n                if isinstance(s, str) and _is_file_ok(Path(s)):\n                    out.append(s)\n        elif isinstance(v, str) and _is_file_ok(Path(v)):\n            out.append(v)\n    return out\n\n# ------------------------\n# 1) 메타 로드\n# ------------------------\nmeta = pd.read_csv(META_CSV)\nmeta = normalize_cols(meta)\n\nif \"video_id\" not in meta.columns:\n    raise RuntimeError(\"train.csv에 video_id 컬럼이 없습니다.\")\nmeta[\"video_id\"] = meta[\"video_id\"].map(as_str)\n\nprint(\"[meta] columns:\", list(meta.columns)[:12], \"...\")\n\n# ------------------------\n# 2) 디렉토리 인덱싱 (tqdm 적용)\n#    구조: /train_tracking/<lab_id>/<video_id>/*.parquet|*.csv\n#          /train_annotation/<lab_id>/<video_id>/*.parquet|*.csv|*.json|*.jsonl\n# ------------------------\ndef index_side(root_dir: str, kind: str):\n    \"\"\"\n    kind: \"tracking\" or \"annotation\"\n    각 lab_id 폴더 하위에서 video_id별 파일 경로를 수집\n    \"\"\"\n    rows = []\n    root = Path(root_dir)\n    if not root.exists():\n        return pd.DataFrame(columns=[\"lab_id\",\"video_id\",f\"{kind}_files\"])\n\n    labs = sorted([d for d in root.iterdir() if d.is_dir()])\n    for lab_dir in tqdm(labs, desc=f\"Index labs ({kind})\", **TQDM_OUTER):\n        lab_id = lab_dir.name\n\n        vids = sorted([d for d in lab_dir.iterdir() if d.is_dir()])\n        for vid_dir in tqdm(vids, desc=f\"videos in {lab_id}\", **TQDM_INNER):\n            video_id = vid_dir.name\n            files = [str(p) for p in vid_dir.iterdir() if p.is_file()]\n            rows.append({\"lab_id\": lab_id, \"video_id\": as_str(video_id), f\"{kind}_files\": files})\n\n        # lab 루트에 바로 놓인 파일도 처리(드물지만 대비)\n        top_files = [str(p) for p in lab_dir.iterdir() if p.is_file()]\n        for f in top_files:\n            p = Path(f)\n            if p.suffix.lower() in [\".parquet\", \".csv\", \".json\", \".jsonl\"]:\n                stem = p.stem\n                video_guess = stem.split(\"_\")[0]\n                rows.append({\"lab_id\": lab_id, \"video_id\": as_str(video_guess), f\"{kind}_files\": [str(p)]})\n\n    if not rows:\n        return pd.DataFrame(columns=[\"lab_id\",\"video_id\",f\"{kind}_files\"])\n\n    df = pd.DataFrame(rows)\n    df = (df.groupby([\"lab_id\",\"video_id\"], as_index=False)\n            .agg({f\"{kind}_files\": _sum_lists_strict}))\n    return df\n\ntrk_index = index_side(TRACKING_DIR, \"tracking\")\nann_index = index_side(ANNOT_DIR,    \"annotation\")\n\n# ------------------------\n# 3) 트래킹/어노테이션 인덱스 병합 + 경로 정리\n# ------------------------\nindex_df = trk_index.merge(ann_index, on=[\"lab_id\",\"video_id\"], how=\"outer\")\n\nfor col in [\"tracking_files\",\"annotation_files\"]:\n    if col not in index_df.columns:\n        index_df[col] = [[] for _ in range(len(index_df))]\n    index_df[col] = index_df[col].apply(_clean_path_list)\n\nprint(\"\\n[index] rows:\", len(index_df))\nprint(index_df.head(3))\n\n# ------------------------\n# 4) 메타 병합 (lab_id + video_id → 실패 시 video_id만으로 보강)\n# ------------------------\nbase = index_df.copy()\n\nmerged = base.merge(meta, on=[\"lab_id\",\"video_id\"], how=\"left\")\nif \"frames_per_second\" in merged.columns and merged[\"frames_per_second\"].isna().all():\n    # lab_id가 다르거나 비어있는 공개 복제셋 대비: video_id만으로 fallback\n    meta_by_vid = meta.drop_duplicates(\"video_id\")\n    # 중복 컬럼 정리 후 재머지\n    drop_cols = [c for c in meta.columns if c in merged.columns and c not in [\"lab_id\",\"video_id\"]]\n    merged = merged.drop(columns=drop_cols, errors=\"ignore\")\n    merged = merged.merge(meta_by_vid, on=\"video_id\", how=\"left\", suffixes=(\"\",\"_byvid\"))\n\nfps_col_exists = \"frames_per_second\" in merged.columns\nfps_filled = int(merged[\"frames_per_second\"].notna().sum()) if fps_col_exists else 0\nprint(f\"\\n총 비디오(인덱스): {len(merged)}\")\nprint(f\"FPS가 채워진 비디오 수: {fps_filled}/{len(merged)}  (FPS 컬럼 존재={fps_col_exists})\")\n\n# ------------------------\n# 5) 요약 통계 계산 (빠른 추정 + tqdm)\n# ------------------------\ndef frames_from_parquet_metadata(files, max_files=3):\n    \"\"\"Parquet metadata.num_rows 또는 CSV 일부 읽기로 대략적인 프레임수 추정\"\"\"\n    import pyarrow.parquet as pq\n    nmax = None\n    if not isinstance(files, list) or len(files) == 0:\n        return np.nan\n    cnt = 0\n    for f in files:\n        if cnt >= max_files: break\n        p = Path(f)\n        if not p.is_file(): \n            continue\n        suf = p.suffix.lower()\n        try:\n            if suf == \".parquet\":\n                pf = pq.ParquetFile(f)\n                n = pf.metadata.num_rows\n                nmax = n if nmax is None else max(nmax, n)\n                cnt += 1\n            elif suf == \".csv\":\n                # csv는 video_frame만 샘플 일부 로드\n                tmp = pd.read_csv(f, usecols=[\"video_frame\"], nrows=200000)\n                if \"video_frame\" in tmp.columns and len(tmp):\n                    m = pd.to_numeric(tmp[\"video_frame\"], errors=\"coerce\").max()\n                    if pd.notna(m):\n                        nmax = int(max(nmax or 0, m))\n                cnt += 1\n        except:\n            pass\n    return np.nan if nmax is None else int(nmax)\n\ndef rough_counts_from_tracking_fast(files, nrows=10000, max_files=3):\n    \"\"\"mouse_id/bodypart 고유 개수 빠른 추정\"\"\"\n    mice, parts = set(), set()\n    if not isinstance(files, list) or len(files) == 0:\n        return pd.Series([np.nan, np.nan])\n    cnt = 0\n    for f in files:\n        if cnt >= max_files: break\n        p = Path(f)\n        if not p.is_file(): continue\n        try:\n            if p.suffix.lower() == \".parquet\":\n                tmp = pd.read_parquet(f, columns=[\"mouse_id\",\"bodypart\"])\n            elif p.suffix.lower() == \".csv\":\n                tmp = pd.read_csv(f, usecols=[\"mouse_id\",\"bodypart\"], nrows=nrows)\n            else:\n                tmp = None\n            if tmp is not None:\n                if \"mouse_id\" in tmp.columns:\n                    mice |= set(tmp[\"mouse_id\"].dropna().astype(str).unique())\n                if \"bodypart\" in tmp.columns:\n                    parts |= set(tmp[\"bodypart\"].dropna().astype(str).unique())\n                cnt += 1\n        except:\n            pass\n    nm = len(mice) if mice else np.nan\n    nb = len(parts) if parts else np.nan\n    return pd.Series([nm, nb])\n\ndef count_segments_from_ann(files, max_files=60):\n    \"\"\"annotation에서 세그먼트(행동 타임스팬) 개수 카운트\"\"\"\n    n = 0\n    if not isinstance(files, list) or len(files) == 0:\n        return 0\n    cnt = 0\n    for f in files:\n        if cnt >= max_files:\n            break\n        try:\n            p = Path(f)\n            if not _is_file_ok(p) or p.suffix.lower() not in [\".parquet\",\".csv\",\".json\",\".jsonl\"]:\n                continue\n            if p.suffix.lower() == \".parquet\":\n                tmp = pd.read_parquet(p)\n            elif p.suffix.lower() == \".csv\":\n                tmp = pd.read_csv(p)\n            elif p.suffix.lower() in [\".json\",\".jsonl\"]:\n                tmp = pd.read_json(f, lines=(p.suffix.lower()==\".jsonl\"))\n            else:\n                tmp = None\n            if tmp is not None and len(tmp):\n                n += len(tmp)\n            cnt += 1\n        except Exception:\n            pass\n    return int(n)\n\n# summary 생성\nsummary = merged[[\"lab_id\",\"video_id\",\"tracking_files\",\"annotation_files\"]].copy()\nsummary[\"has_annotation\"] = summary[\"annotation_files\"].apply(lambda x: bool(isinstance(x, list) and len(x)))\n# tqdm로 세그먼트 카운트\nsummary[\"n_segments\"] = summary[\"annotation_files\"].progress_apply(count_segments_from_ann)\n# tqdm로 프레임수 추정\nsummary[\"frames_est\"] = summary[\"tracking_files\"].progress_apply(frames_from_parquet_metadata)\n# tqdm로 mouse/bodypart 추정\nsummary[[\"n_mice_est\",\"n_bodyparts_est\"]] = summary[\"tracking_files\"].progress_apply(\n    rough_counts_from_tracking_fast\n)\n\n# 메타 필드 keep\nkeep_cols = [\n    \"frames_per_second\",\"video_duration_sec\",\"pix_per_cm_approx\",\n    \"video_width_pix\",\"video_height_pix\",\n    \"arena_width_cm\",\"arena_height_cm\",\"arena_shape\",\"arena_type\",\n    \"body_parts_tracked\",\"behaviors_labeled\",\"tracking_method\"\n]\nfor c in keep_cols:\n    if c not in merged.columns:\n        merged[c] = np.nan\n\nper_video = summary.merge(merged[[\"lab_id\",\"video_id\"]+keep_cols],\n                          on=[\"lab_id\",\"video_id\"], how=\"left\")\n\nprint(\"\\n[Per-Video Summary] shape:\", per_video.shape)\ndisp_cols = [\"lab_id\",\"video_id\",\"has_annotation\",\"n_segments\",\"frames_est\",\n             \"n_mice_est\",\"n_bodyparts_est\",\"frames_per_second\",\"video_duration_sec\",\n             \"video_width_pix\",\"video_height_pix\"]\nprint(per_video[disp_cols].head(8).to_string(index=False))\n\n# ------------------------\n# 6) 상위 행동 카운트 (annotation → action)\n# ------------------------\ndef top_actions_global(df_index, max_files=400, nrows_each=20000, topk=20):\n    files = []\n    for _, r in df_index.iterrows():\n        fs = r.get(\"annotation_files\") or []\n        files.extend([f for f in fs if isinstance(f, str) and Path(f).is_file()])\n\n    random.seed(0)\n    random.shuffle(files)\n    files = files[:max_files]\n\n    cnt = Counter()\n    for f in tqdm(files, desc=\"Top actions scan\", **TQDM_OUTER):\n        try:\n            p = Path(f)\n            if p.suffix.lower() == \".parquet\":\n                tmp = pd.read_parquet(f, columns=[\"action\"])\n            elif p.suffix.lower() == \".csv\":\n                tmp = pd.read_csv(f, usecols=[\"action\"], nrows=nrows_each)\n            elif p.suffix.lower() in [\".json\",\".jsonl\"]:\n                tmp = pd.read_json(f, lines=(p.suffix.lower()==\".jsonl\"))\n                tmp = tmp[[\"action\"]] if \"action\" in tmp.columns else None\n            else:\n                tmp = None\n            if tmp is None or \"action\" not in tmp.columns:\n                continue\n            vc = tmp[\"action\"].dropna().astype(str).value_counts()\n            for k, v in vc.items():\n                cnt[k] += int(v)\n        except Exception:\n            pass\n\n    if not cnt:\n        return pd.DataFrame(columns=[\"action\",\"count\"])\n    return pd.DataFrame(cnt.most_common(topk), columns=[\"action\",\"count\"])\n\ntop20_actions = top_actions_global(merged, max_files=400)\nprint(\"\\n[Top Actions] (up to 20)\")\nprint(top20_actions.to_string(index=False))\n\n# ------------------------\n# 7) EDA plots\n# ------------------------\nplt.figure(figsize=(14,4))\nax = plt.subplot(1,3,1)\nper_video[\"lab_id\"].value_counts().head(15).plot(kind=\"bar\", ax=ax)\nax.set_title(\"Top labs by #videos\")\nax.set_ylabel(\"#videos\"); ax.set_xlabel(\"lab_id\"); ax.tick_params(axis='x', labelrotation=75)\n\nax = plt.subplot(1,3,2)\nper_video[\"frames_per_second\"].dropna().astype(float).plot(kind=\"hist\", bins=30, alpha=0.8, ax=ax)\nax.set_title(\"FPS distribution\"); ax.set_xlabel(\"FPS\")\n\nax = plt.subplot(1,3,3)\nper_video[\"video_duration_sec\"].dropna().astype(float).plot(kind=\"hist\", bins=30, alpha=0.8, ax=ax)\nax.set_title(\"Duration (sec) distribution\"); ax.set_xlabel(\"seconds\")\nplt.tight_layout()\nplt.show()\n\nplt.figure(figsize=(14,4))\nax = plt.subplot(1,3,1)\nper_video[\"frames_est\"].dropna().astype(int).plot(kind=\"hist\", bins=40, alpha=0.8, ax=ax)\nax.set_title(\"Estimated #frames (from tracking)\"); ax.set_xlabel(\"#frames\")\n\nax = plt.subplot(1,3,2)\nper_video[\"n_mice_est\"].dropna().astype(int).plot(kind=\"hist\", bins=10, alpha=0.8, ax=ax)\nax.set_title(\"Estimated #mice (from tracking)\"); ax.set_xlabel(\"#mice\")\n\nax = plt.subplot(1,3,3)\nper_video[\"n_bodyparts_est\"].dropna().astype(int).plot(kind=\"hist\", bins=20, alpha=0.8, ax=ax)\nax.set_title(\"Estimated #bodyparts (from tracking)\"); ax.set_xlabel(\"#bodyparts\")\nplt.tight_layout()\nplt.show()\n\nif len(top20_actions):\n    plt.figure(figsize=(8,4))\n    plt.barh(top20_actions[\"action\"], top20_actions[\"count\"])\n    plt.gca().invert_yaxis()\n    plt.title(\"Top actions (global)\")\n    plt.xlabel(\"count\")\n    plt.tight_layout()\n    plt.show()\n\n# ------------------------\n# 8) 저장\n# ------------------------\nper_video.to_csv(\"/kaggle/working/mabe_train_per_video_summary.csv\", index=False)\nprint(\"\\nSaved: /kaggle/working/mabe_train_per_video_summary.csv\")\n","metadata":{"_uuid":"8f2839f25d086af736a60e9eeb907d3b93b6e0e5","_cell_guid":"b1076dfc-b9ad-4769-8c92-a6c4dae69d19","trusted":true,"execution":{"iopub.status.busy":"2025-09-19T20:25:30.910154Z","iopub.execute_input":"2025-09-19T20:25:30.910546Z","iopub.status.idle":"2025-09-19T20:35:26.447653Z","shell.execute_reply.started":"2025-09-19T20:25:30.910517Z","shell.execute_reply":"2025-09-19T20:35:26.446615Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ============================================================\n# MABe: 행동 중심 EDA (루트/하위 혼합 구조 robust) + tqdm\n# ============================================================\nimport os, random, warnings\nfrom pathlib import Path\nfrom collections import defaultdict, Counter\n\nimport numpy as np\nimport pandas as pd\nimport matplotlib.pyplot as plt\nfrom tqdm.auto import tqdm\nwarnings.filterwarnings(\"ignore\")\n\ntry:\n    import seaborn as sns\n    HAS_SNS = True\n    sns.set_context(\"notebook\")\nexcept:\n    HAS_SNS = False\n\nROOT = \"/kaggle/input/MABe-mouse-behavior-detection\"\nSPLIT = \"train\"\nTRACK_DIR = f\"{ROOT}/{SPLIT}_tracking\"\nANN_DIR   = f\"{ROOT}/{SPLIT}_annotation\"\nMETA_CSV  = f\"{ROOT}/{SPLIT}.csv\"\n\nALLOWED_TRK = {\".parquet\",\".csv\"}\nALLOWED_ANN = {\".parquet\",\".csv\",\".json\",\".jsonl\"}\n\nSAMPLE_N  = 24     # 필요시 증가\nMAX_RETRY = 3\n\n# ---------------- Meta ----------------\nmeta = pd.read_csv(META_CSV)\nmeta.columns = [c.strip().lower().replace(\" \",\"_\") for c in meta.columns]\nif \"video_id\" not in meta.columns:\n    raise RuntimeError(\"train.csv에 video_id 컬럼이 없습니다.\")\nmeta[\"video_id\"] = meta[\"video_id\"].astype(str)\n\n# ---------------- 인덱싱: tracking ----------------\ndef fast_unique_video_ids(file_path, max_rows=200000):\n    \"\"\"루트 파일에서 video_id 고유값만 빠르게 추출(없으면 None).\"\"\"\n    p = Path(file_path)\n    try:\n        if p.suffix.lower()==\".parquet\":\n            df = pd.read_parquet(p, columns=[\"video_id\"])\n        elif p.suffix.lower()==\".csv\":\n            df = pd.read_csv(p, usecols=[\"video_id\"], nrows=max_rows)\n        else:\n            return None\n        if \"video_id\" in df.columns:\n            vids = df[\"video_id\"].dropna().astype(str).unique().tolist()\n            return vids\n    except Exception:\n        pass\n    return None\n\ndef index_tracking(root):\n    \"\"\"\n    trk_by_vid[(lab,vid)] = [files...]   # 하위 비디오 폴더\n    trk_by_lab[lab]       = [files...]   # lab 루트 파일\n    trk_file_vids[file]   = set(video_ids) or None\n    \"\"\"\n    trk_by_vid = defaultdict(list)\n    trk_by_lab = defaultdict(list)\n    trk_file_vids = dict()\n\n    root = Path(root)\n    if not root.exists(): \n        return trk_by_vid, trk_by_lab, trk_file_vids\n\n    labs = sorted([d for d in root.iterdir() if d.is_dir()])\n    for lab_dir in tqdm(labs, desc=\"Index tracking labs\"):\n        lab = lab_dir.name\n        # 하위 비디오 폴더\n        vids = sorted([d for d in lab_dir.iterdir() if d.is_dir()])\n        for vd in vids:\n            files = [str(p) for p in vd.iterdir() if p.is_file() and p.suffix.lower() in ALLOWED_TRK]\n            if files:\n                trk_by_vid[(lab, vd.name)].extend(files)\n        # lab 루트 파일\n        top = [str(p) for p in lab_dir.iterdir() if p.is_file() and p.suffix.lower() in ALLOWED_TRK]\n        if top:\n            trk_by_lab[lab].extend(top)\n            # video_id 추정(가벼운 샘플)\n            for f in top:\n                vids = fast_unique_video_ids(f, max_rows=100000)\n                trk_file_vids[f] = set(vids) if vids else None\n    return trk_by_vid, trk_by_lab, trk_file_vids\n\n# ---------------- 인덱싱: annotation ----------------\ndef index_annotations(root):\n    ann_by_vid = defaultdict(list)\n    ann_by_lab = defaultdict(list)\n    ann_file_vids = dict()\n\n    root = Path(root)\n    if not root.exists(): \n        return ann_by_vid, ann_by_lab, ann_file_vids\n\n    labs = sorted([d for d in root.iterdir() if d.is_dir()])\n    for lab_dir in tqdm(labs, desc=\"Index annotation labs\"):\n        lab = lab_dir.name\n        # 하위 비디오 폴더\n        vids = sorted([d for d in lab_dir.iterdir() if d.is_dir()])\n        for vd in vids:\n            files = [str(p) for p in vd.iterdir() if p.is_file() and p.suffix.lower() in ALLOWED_ANN]\n            if files:\n                ann_by_vid[(lab, vd.name)].extend(files)\n        # lab 루트 파일\n        top = [str(p) for p in lab_dir.iterdir() if p.is_file() and p.suffix.lower() in ALLOWED_ANN]\n        if top:\n            ann_by_lab[lab].extend(top)\n            for f in top:\n                # video_id 빠르게 추정\n                try:\n                    p = Path(f)\n                    if p.suffix.lower()==\".parquet\":\n                        df = pd.read_parquet(f, columns=[\"video_id\"])\n                    elif p.suffix.lower()==\".csv\":\n                        df = pd.read_csv(f, usecols=[\"video_id\"], nrows=150000)\n                    elif p.suffix.lower() in [\".json\",\".jsonl\"]:\n                        df = pd.read_json(f, lines=(p.suffix.lower()==\".jsonl\"))\n                        df = df[[\"video_id\"]] if \"video_id\" in df.columns else None\n                    else:\n                        df = None\n                    vids = df[\"video_id\"].dropna().astype(str).unique().tolist() if (df is not None and \"video_id\" in df.columns) else None\n                except Exception:\n                    vids = None\n                ann_file_vids[f] = set(vids) if vids else None\n    return ann_by_vid, ann_by_lab, ann_file_vids\n\ntrk_by_vid, trk_by_lab, trk_file_vids = index_tracking(TRACK_DIR)\nann_by_vid, ann_by_lab, ann_file_vids = index_annotations(ANN_DIR)\n\nprint(f\"[index] tracking vid-folders: {len(trk_by_vid)} | labs with root-track files: {len(trk_by_lab)}\")\nprint(f\"[index] annotation vid-folders: {len(ann_by_vid)} | labs with root-ann files: {len(ann_by_lab)}\")\n\n# ---------------- 후보 (lab, video) 만들기 ----------------\n# 메타에서 lab별 video_id 리스트\nmeta_labs = meta[[\"lab_id\",\"video_id\"]].dropna()\nmeta_labs[\"lab_id\"] = meta_labs[\"lab_id\"].astype(str)\nmeta_labs[\"video_id\"] = meta_labs[\"video_id\"].astype(str)\n\ncandidates = []\nfor lab, g in meta_labs.groupby(\"lab_id\"):\n    vids = g[\"video_id\"].astype(str).unique().tolist()\n    has_ann = (lab in ann_by_lab) or any((lab, v) in ann_by_vid for v in vids)\n    has_trk = (lab in trk_by_lab) or any((lab, v) in trk_by_vid for v in vids)\n    if not (has_ann and has_trk):\n        continue\n    for v in vids:\n        candidates.append((lab, v))\n\nprint(f\"[candidates] from meta & availability: {len(candidates)}\")\n\n# ---------------- 로더 ----------------\ndef read_annotations_any(path):\n    p = Path(path)\n    try:\n        if p.suffix.lower()==\".parquet\":\n            df = pd.read_parquet(p)\n        elif p.suffix.lower()==\".csv\":\n            df = pd.read_csv(p)\n        elif p.suffix.lower() in [\".json\",\".jsonl\"]:\n            df = pd.read_json(p, lines=(p.suffix.lower()==\".jsonl\"))\n        else:\n            return None\n    except Exception:\n        return None\n    # 컬럼 통일\n    ren = {\n        \"agent_id\":\"agent_id\",\"target_id\":\"target_id\",\"action\":\"action\",\n        \"start_frame\":\"start_frame\",\"stop_frame\":\"stop_frame\",\n        \"start\":\"start_frame\",\"stop\":\"stop_frame\",\"end_frame\":\"stop_frame\",\n        \"video_id\":\"video_id\"\n    }\n    keep = [c for c in df.columns if c in ren]\n    if not keep: return None\n    df = df.rename(columns={c:ren[c] for c in keep})\n    need = [\"action\",\"start_frame\",\"stop_frame\"]\n    if not all(c in df.columns for c in need): return None\n    for c in [\"start_frame\",\"stop_frame\"]:\n        df[c] = pd.to_numeric(df[c], errors=\"coerce\")\n    df = df.dropna(subset=[\"action\",\"start_frame\",\"stop_frame\"])\n    return df\n\ndef read_tracking_any(path, usecols=None, nrows=None):\n    p = Path(path)\n    try:\n        if p.suffix.lower()==\".parquet\":\n            return pd.read_parquet(p, columns=usecols)\n        elif p.suffix.lower()==\".csv\":\n            return pd.read_csv(p, usecols=usecols, nrows=nrows)\n    except Exception:\n        return None\n    return None\n\ndef robust_xy_cols(df):\n    cols = df.columns\n    vf = \"video_frame\" if \"video_frame\" in cols else None\n    mid= \"mouse_id\"    if \"mouse_id\"    in cols else None\n    x  = \"x\" if \"x\" in cols else (\"X\" if \"X\" in cols else None)\n    y  = \"y\" if \"y\" in cols else (\"Y\" if \"Y\" in cols else None)\n    if None in [vf, mid, x, y]: \n        return None\n    return dict(video_frame=vf, mouse_id=mid, x=x, y=y)\n\n# ---------------- (lab, vid) → 파일 선택 ----------------\ndef tracking_files_for(lab, vid):\n    \"\"\"가능하면 vid 폴더, 없으면 lab 루트 파일로 대체.\"\"\"\n    files = []\n    if (lab, vid) in trk_by_vid:\n        files += trk_by_vid[(lab, vid)]\n    files += trk_by_lab.get(lab, [])\n    return files\n\ndef annotation_files_for(lab, vid):\n    files = []\n    if (lab, vid) in ann_by_vid:\n        files += ann_by_vid[(lab, vid)]\n    files += ann_by_lab.get(lab, [])\n    return files\n\n# ---------------- 세그먼트 로드 ----------------\ndef load_segments_for_video(lab, vid):\n    files = annotation_files_for(lab, vid)\n    if not files: \n        return None\n    out = []\n    for f in files:\n        df = read_annotations_any(f)\n        if df is None or df.empty:\n            continue\n        if \"video_id\" in df.columns:\n            m = df[\"video_id\"].astype(str)==str(vid)\n            if not m.any():\n                # 루트 파일이지만 이 비디오가 없을 수 있음 → 스킵\n                continue\n            df = df[m]\n        df[\"lab_id\"]=lab; df[\"video_id\"]=str(vid)\n        if \"agent_id\" not in df.columns:  df[\"agent_id\"]=np.nan\n        if \"target_id\" not in df.columns: df[\"target_id\"]=df[\"agent_id\"]\n        out.append(df[[\"lab_id\",\"video_id\",\"agent_id\",\"target_id\",\"action\",\"start_frame\",\"stop_frame\"]])\n    return pd.concat(out, ignore_index=True) if out else None\n\n# ---------------- 트래킹 → 센트로이드/쌍특징 ----------------\ndef centroid_per_mouse_frame(df, col):\n    g = df.groupby([col[\"video_frame\"], col[\"mouse_id\"]], as_index=False)[[col[\"x\"], col[\"y\"]]].mean()\n    g = g.rename(columns={col[\"video_frame\"]:\"video_frame\", col[\"mouse_id\"]:\"mouse_id\", col[\"x\"]:\"cx\", col[\"y\"]:\"cy\"})\n    g = g.sort_values([\"mouse_id\",\"video_frame\"])\n    g[\"vx\"] = g.groupby(\"mouse_id\")[\"cx\"].diff()\n    g[\"vy\"] = g.groupby(\"mouse_id\")[\"cy\"].diff()\n    g[\"speed_px_per_frame\"] = np.sqrt(g[\"vx\"]**2 + g[\"vy\"]**2)\n    return g\n\ndef pairwise_features(df_cent):\n    out=[]\n    for f, sub in df_cent.groupby(\"video_frame\"):\n        arr = sub[[\"mouse_id\",\"cx\",\"cy\",\"speed_px_per_frame\"]].values\n        for i in range(len(arr)):\n            for j in range(i+1,len(arr)):\n                mi, xi, yi, si = arr[i]\n                mj, xj, yj, sj = arr[j]\n                dist = float(np.hypot(xi-xj, yi-yj))\n                rels = float(abs(si-sj))\n                out.append([int(f), str(int(mi)), str(int(mj)), dist, rels])\n                out.append([int(f), str(int(mj)), str(int(mi)), dist, rels])\n    return pd.DataFrame(out, columns=[\"video_frame\",\"agent_id\",\"target_id\",\"pair_dist\",\"pair_rel_speed\"])\n\ndef build_pairwise_for_video(lab, vid, fps, file_cap=3, nrows_cap=250_000):\n    files = tracking_files_for(lab, vid)\n    if not files: \n        return None\n    # 우선순위: (루트 파일 중 video_id 포함/일치) + (vid 폴더 파일)\n    def score(f):\n        vs = trk_file_vids.get(f)\n        return 0 if (vs is not None and str(vid) in vs) else 1\n    files = sorted(files, key=score)[:file_cap]\n\n    parts=[]\n    for f in files:\n        df = read_tracking_any(f, usecols=[\"video_frame\",\"mouse_id\",\"bodypart\",\"x\",\"y\"], nrows=nrows_cap)\n        if df is None or df.empty:\n            df = read_tracking_any(f, usecols=[\"video_frame\",\"mouse_id\",\"x\",\"y\"], nrows=nrows_cap)\n        if df is None or df.empty: \n            continue\n        # video_id 컬럼이 있으면 필터\n        if \"video_id\" in df.columns:\n            df = df[df[\"video_id\"].astype(str)==str(vid)]\n        col = robust_xy_cols(df)\n        if col is None: \n            continue\n        parts.append(df[[col[\"video_frame\"], col[\"mouse_id\"], col[\"x\"], col[\"y\"]]])\n    if not parts:\n        return None\n\n    T = pd.concat(parts, ignore_index=True)\n    col = robust_xy_cols(T)\n    cent = centroid_per_mouse_frame(T, col)\n    pairs = pairwise_features(cent)\n    if pairs.empty: \n        return None\n    pairs[\"lab_id\"]=lab; pairs[\"video_id\"]=str(vid)\n    pairs[\"fps\"]=fps\n    pairs[\"pair_rel_speed_px_s\"] = pairs[\"pair_rel_speed\"] * fps if pd.notna(fps) and fps>0 else np.nan\n    return pairs\n\n# ---------------- 샘플 & 로드 ----------------\nrandom.seed(0)\n\ndef sample_with_retry(cands, n, max_retry):\n    for k in range(max_retry):\n        take = cands if len(cands)<=n*(k+1) else random.sample(cands, n*(k+1))\n        segs=[]\n        for lab, vid in tqdm(take, desc=f\"scan annotations (try {k+1})\"):\n            d = load_segments_for_video(lab, vid)\n            if d is not None and not d.empty:\n                segs.append(d)\n        if segs:\n            return take, pd.concat(segs, ignore_index=True)\n    return take, pd.DataFrame(columns=[\"lab_id\",\"video_id\",\"agent_id\",\"target_id\",\"action\",\"start_frame\",\"stop_frame\"])\n\nsampled, segs = sample_with_retry(candidates, SAMPLE_N, MAX_RETRY)\nprint(f\"[info] sampled pairs: {len(sampled)} | seg rows: {len(segs)}\")\n\n# ---------------- 행동 EDA ----------------\nif len(segs)==0:\n    print(\"⚠️ 유효한 annotation 세그먼트를 찾지 못했습니다. SAMPLE_N을 더 키우거나 다른 lab을 선택하세요.\")\nelse:\n    segs[\"start_frame\"] = pd.to_numeric(segs[\"start_frame\"], errors=\"coerce\").astype(\"Int64\")\n    segs[\"stop_frame\"]  = pd.to_numeric(segs[\"stop_frame\"],  errors=\"coerce\").astype(\"Int64\")\n    segs = segs.dropna(subset=[\"start_frame\",\"stop_frame\",\"action\"]).reset_index(drop=True)\n\n    fps_lookup = meta.set_index(\"video_id\")[\"frames_per_second\"].to_dict()\n    segs[\"fps\"] = segs[\"video_id\"].map(fps_lookup)\n    segs[\"dur_frames\"] = (segs[\"stop_frame\"] - segs[\"start_frame\"] + 1).clip(lower=0)\n    segs[\"dur_sec\"]    = segs[\"dur_frames\"] / segs[\"fps\"]\n\n    # 1) 행동 분포\n    act_counts = segs[\"action\"].value_counts().sort_values(ascending=False)\n    plt.figure(figsize=(9,4))\n    if HAS_SNS: sns.barplot(x=act_counts.index[:20], y=act_counts.values[:20], color=\"#6aa6ff\")\n    else:       plt.bar(act_counts.index[:20], act_counts.values[:20])\n    plt.xticks(rotation=60, ha=\"right\"); plt.title(\"Action counts (sample)\"); plt.tight_layout(); plt.show()\n\n    # 2) 행동별 지속시간\n    top_actions = act_counts.head(10).index.tolist()\n    dur_plot = segs.dropna(subset=[\"dur_sec\"])\n    dur_plot = dur_plot[dur_plot[\"action\"].isin(top_actions)]\n    if len(dur_plot):\n        plt.figure(figsize=(10,4))\n        if HAS_SNS:\n            sns.boxplot(data=dur_plot, x=\"action\", y=\"dur_sec\", showfliers=False)\n            sns.stripplot(data=dur_plot.sample(n=min(2500, len(dur_plot)), random_state=0),\n                          x=\"action\", y=\"dur_sec\", color=\"k\", alpha=.2, jitter=.25, size=2)\n        else:\n            for i,a in enumerate(top_actions,1):\n                v = dur_plot[dur_plot[\"action\"]==a][\"dur_sec\"].dropna()\n                plt.boxplot([v], positions=[i])\n            plt.xticks(range(1,len(top_actions)+1), top_actions, rotation=60, ha=\"right\")\n        plt.ylabel(\"duration (sec)\"); plt.title(\"Duration per action (top)\"); plt.tight_layout(); plt.show()\n\n    # 3) 행동 전이\n    trans = Counter()\n    for (lab, vid), g in segs.groupby([\"lab_id\",\"video_id\"]):\n        g2 = g.sort_values([\"agent_id\",\"start_frame\"])\n        for agent, gg in g2.groupby(\"agent_id\"):\n            prev = None\n            for a in gg[\"action\"]:\n                if prev is not None and a!=prev:\n                    trans[(prev, a)] += 1\n                prev = a\n    if trans:\n        trans_df = pd.DataFrame([(a,b,c) for (a,b),c in trans.items()], columns=[\"from\",\"to\",\"#\"])\n        piv = trans_df.pivot_table(index=\"from\", columns=\"to\", values=\"#\", fill_value=0).astype(int)\n        plt.figure(figsize=(max(6,0.6*len(piv.columns)), max(4,0.6*len(piv.index))))\n        if HAS_SNS: sns.heatmap(piv, cmap=\"Reds\")\n        else:\n            plt.imshow(piv.values, cmap=\"Reds\"); plt.colorbar()\n            plt.xticks(range(len(piv.columns)), piv.columns, rotation=60, ha=\"right\")\n            plt.yticks(range(len(piv.index)),   piv.index)\n        plt.title(\"Action transition (agent-wise)\"); plt.tight_layout(); plt.show()\n\n# ---------------- pairwise 트래킹 특징 ↔ 라벨 비교 ----------------\nbeh_feats=[]\nfor (lab, vid) in tqdm(sampled, desc=\"build pairwise feats\"):\n    fps = meta.loc[meta[\"video_id\"]==str(vid), \"frames_per_second\"].dropna()\n    fps = float(fps.iloc[0]) if len(fps) else np.nan\n    bf = build_pairwise_for_video(lab, vid, fps)\n    if bf is not None and not bf.empty:\n        beh_feats.append(bf)\npair_df = pd.concat(beh_feats, ignore_index=True) if beh_feats else pd.DataFrame()\n\nif len(segs) and not pair_df.empty:\n    # frame-level 라벨 확장\n    def expand_labels(seg_df, actions_keep):\n        lab=[]\n        for _, r in seg_df.iterrows():\n            if r[\"action\"] not in actions_keep: \n                continue\n            lab.append(pd.DataFrame({\n                \"video_frame\": np.arange(int(r[\"start_frame\"]), int(r[\"stop_frame\"])+1, dtype=int),\n                \"agent_id\":    str(r.get(\"agent_id\", np.nan)),\n                \"target_id\":   str(r.get(\"target_id\", np.nan)),\n                \"action\":      r[\"action\"]\n            }))\n        return pd.concat(lab, ignore_index=True) if lab else pd.DataFrame(columns=[\"video_frame\",\"agent_id\",\"target_id\",\"action\"])\n\n    top_actions = segs[\"action\"].value_counts().head(6).index.tolist()\n    frame_labels=[]\n    for (lab, vid), g in segs[segs[\"action\"].isin(top_actions)].groupby([\"lab_id\",\"video_id\"]):\n        fl = expand_labels(g, top_actions)\n        if len(fl):\n            fl[\"lab_id\"]=lab; fl[\"video_id\"]=str(vid)\n            frame_labels.append(fl)\n    frame_labels = pd.concat(frame_labels, ignore_index=True) if frame_labels else pd.DataFrame()\n\n    if not frame_labels.empty:\n        key = [\"lab_id\",\"video_id\",\"video_frame\",\"agent_id\",\"target_id\"]\n        for df in (pair_df, frame_labels):\n            df[key] = df[key].astype(str)\n        joined = pair_df.merge(frame_labels[key+[\"action\"]], on=key, how=\"left\")\n        for a in top_actions:\n            sub = joined.copy()\n            sub[\"pos\"] = sub[\"action\"].eq(a)\n            pos = sub[sub[\"pos\"]==True]\n            neg = sub[sub[\"pos\"]==False]\n            if len(pos)==0 or len(neg)==0:\n                continue\n            neg = neg.sample(n=min(len(pos)*2, len(neg)), random_state=0)\n\n            fig, axes = plt.subplots(1,2, figsize=(10,3.4))\n            for ax, col, title in zip(\n                axes,\n                [\"pair_dist\",\"pair_rel_speed_px_s\"],\n                [\"pairwise distance (px)\", \"relative speed (px/s)\"],\n            ):\n                posv = np.log1p(pos[col].dropna()); negv = np.log1p(neg[col].dropna())\n                if HAS_SNS:\n                    sns.kdeplot(posv, ax=ax, label=f\"{a}=ON\", fill=True, alpha=.35)\n                    sns.kdeplot(negv, ax=ax, label=\"OFF\",  fill=True, alpha=.35)\n                else:\n                    ax.hist(posv, bins=40, alpha=.5, label=f\"{a}=ON\", density=True)\n                    ax.hist(negv, bins=40, alpha=.5, label=\"OFF\", density=True)\n                ax.set_title(f\"{a}: {title} (log1p)\"); ax.legend()\n            plt.tight_layout(); plt.show()\nelse:\n    print(\"⚠️ segs 또는 pair_df가 비어 on/off 비교 스킵.\")\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-09-19T20:35:26.449552Z","iopub.execute_input":"2025-09-19T20:35:26.450220Z","iopub.status.idle":"2025-09-19T20:51:27.532466Z","shell.execute_reply.started":"2025-09-19T20:35:26.450183Z","shell.execute_reply":"2025-09-19T20:51:27.530833Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ============================================================\n# Fast & Clean pipeline (effective tweaks only)\n#  - Train: scan only videos that have annotations\n#  - Speed toggles: FRAME_STRIDE / NEIGHBOR_TOPK / NEG_RATIO / CV_SPLITS\n#  - Robust segmentation: smoothing + hysteresis + min frames + gap merge\n#  - Remove zero-length segments (start==stop)\n#  - Cache train pairs/labels/actions\n#  - LightGBM: GPU try -> CPU fallback, early stopping\n# ============================================================\n\nimport os, gc, json, math, random, shutil, subprocess\nimport numpy as np\nimport pandas as pd\nfrom pathlib import Path\nfrom tqdm.auto import tqdm\nfrom sklearn.model_selection import GroupKFold\nfrom sklearn.metrics import f1_score, roc_auc_score\n\n# ---------------- Paths ----------------\nROOT = \"/kaggle/input/MABe-mouse-behavior-detection\"\nTRK_DIR = f\"{ROOT}/train_tracking\"\nANN_DIR = f\"{ROOT}/train_annotation\"\nTEST_TRK_DIR = f\"{ROOT}/test_tracking\"\n\n# ---------------- Config (speed/quality knobs) ----------------\nTOP_ACTIONS_K       = None        # None=all actions in annotations\nFRAME_STRIDE        = 16           # use every Nth frame (train & test)\nEXPAND_STEP         = FRAME_STRIDE\nSCALE_XY            = 0.5         # scale coords (I/O 영향 X, 속도 안전)\nNEG_RATIO           = 3           # negatives downsample per action\nCV_SPLITS           = 3           # GroupKFold splits\nNEIGHBOR_TOPK       = 2           # None=all targets, else top-K nearest only\nMAX_TRACK_FILES     = 1           # <=1 file per video (속도↑)\nCSV_NROWS_CAP       = None        # None=all rows\nMAX_SECONDS_TRAIN   = None        # cut head seconds if fps known (None=all)\nTRAIN_ONLY_ANN_PAIRS= True        # build only annotated (agent,target) pairs\nUSE_GPU_TRY         = False       # try LightGBM GPU; fallback to CPU\n\nDEBUG_MODE          = False       # True => keep only a fraction of videos\nDEBUG_FRACTION      = 0.10        # when DEBUG_MODE=True\n\nRANDOM_STATE = 0\nnp.random.seed(RANDOM_STATE)\nrandom.seed(RANDOM_STATE)\n\n# ---------------- LightGBM / Fallback ----------------\ntry:\n    import lightgbm as lgb\n    HAS_LGB = True\nexcept Exception:\n    HAS_LGB = False\n    from sklearn.preprocessing import StandardScaler\n    from sklearn.linear_model import LogisticRegression\n\n# ============================================================\n# Helpers\n# ============================================================\ndef _which(cmd):\n    try:\n        return shutil.which(cmd)\n    except Exception:\n        return None\n\n# FPS lookup from meta if present\nfps_lookup = {}\nfor meta in [\"train.csv\", \"test.csv\"]:\n    mp = Path(ROOT) / meta\n    if mp.exists():\n        try:\n            m = pd.read_csv(mp)\n            if {\"video_id\",\"frames_per_second\"}.issubset(m.columns):\n                fps_lookup.update(\n                    m.assign(video_id=m[\"video_id\"].astype(str))\n                     .set_index(\"video_id\")[\"frames_per_second\"].to_dict()\n                )\n        except Exception:\n            pass\n\ndef _iter_lab_entries(root_dir):\n    root = Path(root_dir)\n    if not root.exists(): return []\n    labs = sorted([d for d in root.iterdir() if d.is_dir()])\n    for lab_dir in labs:\n        for ent in sorted(lab_dir.iterdir()):\n            if ent.is_file() and ent.suffix.lower() in (\".parquet\",\".csv\"):\n                yield lab_dir.name, ent.stem, [ent]\n            elif ent.is_dir():\n                files = [p for p in ent.iterdir() if p.is_file() and p.suffix.lower() in (\".parquet\",\".csv\")]\n                if files:\n                    yield lab_dir.name, ent.name, files\n\ndef _select_track_files(files):\n    # prefer parquet, limit count\n    return sorted(files, key=lambda p: (p.suffix.lower() != \".parquet\", str(p)))[:MAX_TRACK_FILES]\n\ndef _read_tracking_cols(fp: Path):\n    usecols = [\"video_frame\",\"mouse_id\",\"x\",\"y\",\"bodypart\"]\n    try:\n        if fp.suffix.lower()==\".parquet\":\n            df = pd.read_parquet(fp, columns=usecols)\n        else:\n            hdr = pd.read_csv(fp, nrows=0)\n            cols = [c for c in usecols if c in hdr.columns]\n            df = pd.read_csv(fp, usecols=cols, nrows=CSV_NROWS_CAP)\n\n        if \"bodypart\" not in df.columns:\n            df[\"bodypart\"] = np.nan\n\n        df[\"video_frame\"] = pd.to_numeric(df[\"video_frame\"], errors=\"coerce\").astype(\"Int64\")\n        df = df.dropna(subset=[\"video_frame\",\"mouse_id\",\"x\",\"y\"])\n        # scale coords (no I/O effect, but consistent with train/test)\n        df[\"x\"] = (df[\"x\"].astype(np.float32) * SCALE_XY).astype(np.float32)\n        df[\"y\"] = (df[\"y\"].astype(np.float32) * SCALE_XY).astype(np.float32)\n        df[\"mouse_id\"] = df[\"mouse_id\"].astype(str)\n        return df\n    except Exception:\n        return pd.DataFrame(columns=[\"video_frame\",\"mouse_id\",\"x\",\"y\",\"bodypart\"])\n\ndef _centroid_from_tracking(trk, fps):\n    cent = (trk.groupby([\"video_frame\",\"mouse_id\"], as_index=False)\n               .agg(x=(\"x\",\"mean\"), y=(\"y\",\"mean\")))\n    if FRAME_STRIDE and FRAME_STRIDE>1:\n        cent = cent[cent[\"video_frame\"] % FRAME_STRIDE == 0]\n    cent = cent.sort_values([\"mouse_id\",\"video_frame\"])\n    cent[\"vx_pf\"] = cent.groupby(\"mouse_id\")[\"x\"].diff().fillna(0).astype(np.float32)\n    cent[\"vy_pf\"] = cent.groupby(\"mouse_id\")[\"y\"].diff().fillna(0).astype(np.float32)\n    if fps and np.isfinite(fps) and fps>0:\n        cent[\"vx_ps\"] = (cent[\"vx_pf\"] * fps).astype(np.float32)\n        cent[\"vy_ps\"] = (cent[\"vy_pf\"] * fps).astype(np.float32)\n    else:\n        cent[\"vx_ps\"] = cent[\"vx_pf\"].astype(np.float32)\n        cent[\"vy_ps\"] = cent[\"vy_pf\"].astype(np.float32)\n    if MAX_SECONDS_TRAIN and fps and fps>0:\n        max_frame = int(fps * MAX_SECONDS_TRAIN)\n        cent = cent[cent[\"video_frame\"] <= max_frame]\n    return cent\n\ndef _pair_rows_from_cent(cent, lab_id, video_id, fps, ann_pair_filter=None):\n    rows = []\n    for vf, g in cent.groupby(\"video_frame\"):\n        ms = g[\"mouse_id\"].tolist()\n        n = len(ms)\n        if n < 2: continue\n        g = g.set_index(\"mouse_id\")\n        xs, ys = g[\"x\"].to_numpy(), g[\"y\"].to_numpy()\n        vxs, vys = g[\"vx_ps\"].to_numpy(), g[\"vy_ps\"].to_numpy()\n        dx = xs[:,None] - xs[None,:]\n        dy = ys[:,None] - ys[None,:]\n        dist = np.hypot(dx, dy)\n        rvx = vxs[:,None] - vxs[None,:]\n        rvy = vys[:,None] - vys[None,:]\n        rs  = np.hypot(rvx, rvy)\n        ids = np.array(ms, dtype=object)\n\n        for i in range(n):\n            d = dist[i].copy(); d[i] = np.inf\n            if NEIGHBOR_TOPK is not None and NEIGHBOR_TOPK < n:\n                nbr_idx = np.argpartition(d, NEIGHBOR_TOPK)[:NEIGHBOR_TOPK]\n            else:\n                nbr_idx = [j for j in range(n) if j != i]\n            a = str(ids[i])\n            for j in nbr_idx:\n                b = str(ids[j])\n                if (ann_pair_filter is not None) and ((str(lab_id), str(video_id), a, b) not in ann_pair_filter):\n                    continue\n                rows.append([lab_id, str(video_id), int(vf), a, b,\n                             float(dist[i,j]), float(rs[i,j]), fps])\n    return rows\n\ndef build_pair_df_from_dir(TRK_DIR, ann_pair_filter=None, keep_only=None):\n    rows = []\n    it = list(_iter_lab_entries(TRK_DIR))\n    if keep_only is not None:\n        keep_only = set(keep_only)\n        it = [t for t in it if (t[0], t[1]) in keep_only]\n\n    for lab_id, video_id, files in tqdm(it, desc=f\"scan {Path(TRK_DIR).name}\"):\n        files = _select_track_files(files)\n        parts = []\n        for fp in files:\n            df = _read_tracking_cols(fp)\n            if len(df): parts.append(df)\n        if not parts: continue\n        trk = pd.concat(parts, ignore_index=True)\n        trk[\"video_frame\"] = trk[\"video_frame\"].astype(\"Int64\")\n        fps = float(fps_lookup.get(str(video_id), 0) or 0)\n        cent = _centroid_from_tracking(trk, fps)\n        if len(cent)==0: continue\n        rows.extend(_pair_rows_from_cent(cent, lab_id, video_id, fps, ann_pair_filter=ann_pair_filter))\n        del trk, cent; gc.collect()\n\n    cols = [\"lab_id\",\"video_id\",\"video_frame\",\"agent_id\",\"target_id\",\n            \"pair_dist\",\"pair_rel_speed_px_s\",\"fps\"]\n    df = pd.DataFrame(rows, columns=cols)\n    if len(df):\n        df[[\"pair_dist\",\"pair_rel_speed_px_s\",\"fps\"]] = df[[\"pair_dist\",\"pair_rel_speed_px_s\",\"fps\"]].astype(np.float32)\n        for c in [\"lab_id\",\"video_id\",\"agent_id\",\"target_id\"]:\n            df[c] = df[c].astype(str)\n        df[\"video_frame\"] = df[\"video_frame\"].astype(np.int32)\n    return df\n\n# ---------------- Annotations ----------------\ndef load_train_annotations(ANN_DIR):\n    rows = []\n    it = list(_iter_lab_entries(ANN_DIR))\n    for lab_id, video_id, files in tqdm(it, desc=\"scan train ann\"):\n        for f in files:\n            try:\n                if f.suffix.lower()==\".parquet\": df = pd.read_parquet(f)\n                elif f.suffix.lower()==\".csv\":   df = pd.read_csv(f)\n                else:                             df = pd.read_json(f, lines=(f.suffix.lower()==\".jsonl\"))\n            except Exception:\n                continue\n            ren = {\"start\":\"start_frame\",\"stop\":\"stop_frame\",\"end_frame\":\"stop_frame\"}\n            df = df.rename(columns=ren)\n            need = {\"action\",\"start_frame\",\"stop_frame\"}\n            if not need.issubset(df.columns): continue\n            for c in [\"start_frame\",\"stop_frame\"]:\n                df[c] = pd.to_numeric(df[c], errors=\"coerce\")\n            df = df.dropna(subset=[\"action\",\"start_frame\",\"stop_frame\"])\n            if \"agent_id\" not in df.columns:  df[\"agent_id\"] = np.nan\n            if \"target_id\" not in df.columns: df[\"target_id\"] = df[\"agent_id\"]\n            df[\"lab_id\"] = lab_id; df[\"video_id\"] = str(video_id)\n            rows.append(df[[\"lab_id\",\"video_id\",\"agent_id\",\"target_id\",\"action\",\"start_frame\",\"stop_frame\"]])\n    ann = (pd.concat(rows, ignore_index=True)\n           if rows else pd.DataFrame(columns=[\"lab_id\",\"video_id\",\"agent_id\",\"target_id\",\"action\",\"start_frame\",\"stop_frame\"]))\n    if len(ann):\n        for c in [\"lab_id\",\"video_id\",\"agent_id\",\"target_id\",\"action\"]:\n            ann[c] = ann[c].astype(str)\n        ann[\"start_frame\"] = ann[\"start_frame\"].astype(np.int32)\n        ann[\"stop_frame\"]  = ann[\"stop_frame\"].astype(np.int32)\n    return ann\n\ndef expand_segments_to_frames(seg_df, actions_keep=None, step=1):\n    outs = []\n    for _, r in seg_df.iterrows():\n        a = r[\"action\"]\n        if actions_keep is not None and a not in actions_keep: continue\n        s, e = int(r[\"start_frame\"]), int(r[\"stop_frame\"])\n        if e < s: continue\n        vf = np.arange(s, e+1, step=step, dtype=np.int32)\n        outs.append(pd.DataFrame({\n            \"video_frame\": vf,\n            \"agent_id\":   str(r.get(\"agent_id\", np.nan)),\n            \"target_id\":  str(r.get(\"target_id\", np.nan)),\n            \"action\":     a,\n            \"lab_id\":     str(r.get(\"lab_id\",\"\")),\n            \"video_id\":   str(r.get(\"video_id\",\"\")),\n        }))\n    return (pd.concat(outs, ignore_index=True)\n            if outs else pd.DataFrame(columns=[\"video_frame\",\"agent_id\",\"target_id\",\"action\",\"lab_id\",\"video_id\"]))\n\ndef build_features(df):\n    df = df.copy()\n    df[\"pair_dist\"] = pd.to_numeric(df.get(\"pair_dist\"), errors=\"coerce\").astype(np.float32)\n    df[\"pair_rel_speed_px_s\"] = pd.to_numeric(df.get(\"pair_rel_speed_px_s\"), errors=\"coerce\").astype(np.float32)\n    df[\"fps_f\"] = pd.to_numeric(df.get(\"fps\"), errors=\"coerce\").astype(np.float32)\n    df[\"pair_dist_log1p\"] = np.log1p(df[\"pair_dist\"].clip(lower=0)).astype(np.float32)\n    df[\"rel_speed_log1p\"] = np.log1p(df[\"pair_rel_speed_px_s\"].clip(lower=0)).astype(np.float32)\n    feats = [\"pair_dist\",\"pair_rel_speed_px_s\",\"pair_dist_log1p\",\"rel_speed_log1p\",\"fps_f\"]\n    return df, feats\n\n# ---------------- Segmentation (robust) ----------------\ndef moving_average(x, w):\n    if w <= 1: return x\n    return np.convolve(x, np.ones(w)/w, mode=\"same\")\n\ndef probs_to_segments(df_prob, action, thr, fps_lookup,\n                      smooth_frac=0.35, min_len_sec=0.6, max_gap_sec=0.6,\n                      frame_stride=1, thr_low_frac=0.8):\n    def _fps_eff(vid):\n        raw = float(fps_lookup.get(str(vid), 0) or 0)\n        return (raw / max(1, frame_stride)) if raw>0 else 4.0\n\n    out = []\n    for (lab, vid, ag, tg), g in df_prob.groupby([\"lab_id\",\"video_id\",\"agent_id\",\"target_id\"]):\n        g = g.sort_values(\"video_frame\")\n        vf = g[\"video_frame\"].to_numpy(dtype=int)\n        p  = g[\"prob\"].to_numpy(dtype=float)\n        fps_eff = _fps_eff(vid)\n        win = max(1, int(round(smooth_frac * fps_eff)))\n        sm = moving_average(p, win) if win>1 else p\n\n        hi, lo = float(thr), float(thr)*float(thr_low_frac)\n        on = False\n        mask = np.zeros_like(sm, dtype=bool)\n        for i, val in enumerate(sm):\n            if on:\n                if val >= lo: mask[i] = True\n                else: on = False\n            else:\n                if val >= hi: on = True; mask[i] = True\n\n        starts, ends = [], []\n        for i in range(len(mask)):\n            if mask[i] and (i==0 or not mask[i-1]): starts.append(vf[i])\n            if mask[i] and (i==len(mask)-1 or not mask[i+1]): ends.append(vf[i])\n        k = min(len(starts), len(ends)); starts, ends = starts[:k], ends[:k]\n        segs = [[int(s), int(e)] for s, e in zip(starts, ends)]\n\n        min_len_frames = max(3, int(round(min_len_sec*fps_eff)))   # >=3 frames\n        max_gap_frames = max(1, int(round(max_gap_sec*fps_eff)))\n\n        # length filter\n        segs = [s for s in segs if (s[1]-s[0]+1) >= min_len_frames]\n\n        # merge close segments\n        merged = []\n        for s,e in segs:\n            if not merged: merged.append([s,e]); continue\n            ps,pe = merged[-1]\n            if s - pe - 1 <= max_gap_frames:\n                merged[-1][1] = max(pe, e)\n            else:\n                merged.append([s,e])\n\n        for s,e in merged:\n            if e > s:  # remove start==stop\n                out.append([lab, vid, ag, tg, action, int(s), int(e)])\n\n    return pd.DataFrame(out, columns=[\"lab_id\",\"video_id\",\"agent_id\",\"target_id\",\"action\",\"start_frame\",\"stop_frame\"])\n\n# ---------------- LGB utils ----------------\ndef make_lgb_params(try_gpu=True):\n    base = dict(\n        objective=\"binary\", metric=\"auc\",\n        learning_rate=0.05, num_leaves=31,\n        feature_fraction=0.9, bagging_fraction=0.8, bagging_freq=1,\n        min_data_in_leaf=50, verbose=-1, force_col_wise=True,\n        num_threads=os.cpu_count() or 4\n    )\n    if try_gpu:\n        base.update({\"device\":\"gpu\",\"device_type\":\"gpu\"})\n    else:\n        base.update({\"device\":\"cpu\",\"device_type\":\"cpu\"})\n    return base\n\ndef make_lgb_callbacks(early_stopping_rounds, debug_mode):\n    cb = [lgb.early_stopping(early_stopping_rounds)]\n    cb.append(lgb.log_evaluation(period=0 if debug_mode else 50))\n    return cb\n\ndef _neg_downsample(df, label_col=\"y\", ratio=NEG_RATIO):\n    if not ratio or ratio<=0: return df\n    pos = df[df[label_col]==1]; neg = df[df[label_col]==0]\n    if len(pos)==0 or len(neg)==0: return df\n    need = min(len(neg), int(len(pos)*ratio))\n    neg_s = neg.sample(n=need, random_state=RANDOM_STATE) if len(neg)>need else neg\n    return pd.concat([pos, neg_s], ignore_index=True)\n\n# ============================================================\n# Cache\n# ============================================================\nDBG_SIG = f\"_dbg{int(DEBUG_MODE)}_{str(DEBUG_FRACTION).replace('.','p')}\"\nCFG_SIG = f\"s{FRAME_STRIDE}_exp{EXPAND_STEP}_sc{SCALE_XY}_k{NEIGHBOR_TOPK or 'all'}_ann{int(TRAIN_ONLY_ANN_PAIRS)}_sec{MAX_SECONDS_TRAIN or 0}_mf{MAX_TRACK_FILES}{DBG_SIG}\"\npair_cache = f\"/kaggle/working/pair_df_tr__{CFG_SIG}.parquet\"\nframe_labels_cache = f\"/kaggle/working/frame_labels__{CFG_SIG}.parquet\"\nactions_cache = f\"/kaggle/working/actions__{CFG_SIG}.json\"\n\n# ============================================================\n# Load / Build Train\n#  - **효율 포인트**: 훈련 스캔은 주석이 존재하는 비디오만!\n# ============================================================\nif Path(pair_cache).exists() and Path(frame_labels_cache).exists() and Path(actions_cache).exists():\n    pair_df_tr = pd.read_parquet(pair_cache)\n    frame_labels = pd.read_parquet(frame_labels_cache)\n    ACTIONS = json.loads(Path(actions_cache).read_text())\n    print(\"[cache] loaded train pairs/labels/actions\")\nelse:\n    segs = load_train_annotations(ANN_DIR)\n    if segs.empty:\n        raise RuntimeError(\"No training annotations found.\")\n    vc = segs[\"action\"].value_counts()\n    ACTIONS = (vc.index.tolist() if TOP_ACTIONS_K is None else vc.head(TOP_ACTIONS_K).index.tolist())\n\n    # whitelist of (lab_id, video_id) that actually have annotations\n    keep_only = segs[[\"lab_id\",\"video_id\"]].drop_duplicates()\n    if DEBUG_MODE:\n        sampled = []\n        for lab, sub in keep_only.groupby(\"lab_id\"):\n            k = max(1, int(math.ceil(len(sub)*DEBUG_FRACTION)))\n            sampled.append(sub.sample(n=k, random_state=RANDOM_STATE))\n        keep_only = pd.concat(sampled, ignore_index=True)\n    keep_only = list(map(tuple, keep_only.values.tolist()))\n\n    ann_pair_filter = None\n    if TRAIN_ONLY_ANN_PAIRS:\n        uniq_pairs = segs[[\"lab_id\",\"video_id\",\"agent_id\",\"target_id\"]].drop_duplicates()\n        ann_pair_filter = set(map(tuple, uniq_pairs.values.tolist()))\n\n    pair_df_tr = build_pair_df_from_dir(TRK_DIR, ann_pair_filter=ann_pair_filter, keep_only=keep_only)\n    frame_labels = expand_segments_to_frames(segs, actions_keep=ACTIONS, step=EXPAND_STEP)\n\n    for df_ in (pair_df_tr, frame_labels):\n        for c in [\"lab_id\",\"video_id\",\"video_frame\",\"agent_id\",\"target_id\"]:\n            if c in df_.columns:\n                df_[c] = df_[c].astype(str)\n\n    pair_df_tr.to_parquet(pair_cache, index=False)\n    frame_labels.to_parquet(frame_labels_cache, index=False)\n    Path(actions_cache).write_text(json.dumps(ACTIONS))\n    print(\"[cache] saved train pairs/labels/actions\")\n\nprint(f\"[train] actions: {len(ACTIONS)} | train pairs: {pair_df_tr.shape}\")\n\n# ============================================================\n# Train per action\n# ============================================================\nMODELS, VAL_SCORES = {}, {}\nkey_cols = [\"lab_id\",\"video_id\",\"video_frame\",\"agent_id\",\"target_id\"]\nNUM_BOOST_ROUND = 600 if DEBUG_MODE else 1200\nEARLY_STOP_ROUND = 20 if DEBUG_MODE else 50\n\nfor action in ACTIONS:\n    print(f\"\\n=== Train [{action}] ===\")\n    lab_a = frame_labels[frame_labels[\"action\"]==action].copy()\n    lab_a[\"y\"] = 1\n    data = pair_df_tr.merge(lab_a[key_cols+[\"y\"]], on=key_cols, how=\"left\")\n    data[\"y\"] = data[\"y\"].fillna(0).astype(np.int8)\n\n    data, feat_cols = build_features(data)\n    data = _neg_downsample(data, \"y\", NEG_RATIO)\n\n    X = data[feat_cols]\n    y = data[\"y\"].values.astype(np.int8)\n    groups = data[\"video_id\"].astype(str).values\n\n    gkf = GroupKFold(n_splits=max(2, min(CV_SPLITS, len(np.unique(groups)))))\n    f1s, aucs, thr_list, iter_list = [], [], [], []\n    if HAS_LGB: params = make_lgb_params(try_gpu=USE_GPU_TRY)\n    oof = np.zeros(len(X), dtype=np.float32)\n\n    for tr_idx, va_idx in gkf.split(X, y, groups):\n        Xtr, Xva = X.iloc[tr_idx], X.iloc[va_idx]\n        ytr, yva = y[tr_idx], y[va_idx]\n\n        if HAS_LGB:\n            dtr, dva = lgb.Dataset(Xtr, label=ytr), lgb.Dataset(Xva, label=yva, reference=None)\n            tried_gpu = USE_GPU_TRY\n            try:\n                booster = lgb.train(\n                    params, dtr,\n                    num_boost_round=NUM_BOOST_ROUND,\n                    valid_sets=[dva], valid_names=[\"val\"],\n                    callbacks=make_lgb_callbacks(EARLY_STOP_ROUND, DEBUG_MODE),\n                )\n            except Exception as e:\n                if tried_gpu:\n                    print(f\"⚠️ GPU failed → CPU: {e}\")\n                    booster = lgb.train(\n                        make_lgb_params(try_gpu=False), dtr,\n                        num_boost_round=NUM_BOOST_ROUND,\n                        valid_sets=[dva], valid_names=[\"val\"],\n                        callbacks=make_lgb_callbacks(EARLY_STOP_ROUND, DEBUG_MODE),\n                    )\n                else:\n                    raise\n            p = booster.predict(Xva, num_iteration=booster.best_iteration)\n            best_iter = booster.best_iteration\n        else:\n            from sklearn.preprocessing import StandardScaler\n            from sklearn.linear_model import LogisticRegression\n            sc = StandardScaler()\n            Xtr_s, Xva_s = sc.fit_transform(Xtr), sc.transform(Xva)\n            clf = LogisticRegression(max_iter=200, class_weight=\"balanced\")\n            clf.fit(Xtr_s, ytr)\n            p = clf.predict_proba(Xva_s)[:,1]\n            best_iter = None\n\n        oof[va_idx] = p\n        thrs = np.linspace(0.1, 0.9, 17)\n        f1_list = [f1_score(yva, (p>=t).astype(int)) for t in thrs]\n        thr_list.append(float(thrs[int(np.argmax(f1_list))]))\n        try: aucs.append(roc_auc_score(yva, p))\n        except ValueError: aucs.append(np.nan)\n        f1s.append(max(f1_list))\n        if best_iter is not None: iter_list.append(best_iter)\n\n    # full fit with median best_iter\n    if HAS_LGB:\n        best_iters = int(np.median(iter_list)) if len(iter_list) else (300 if DEBUG_MODE else 600)\n        dall = lgb.Dataset(X, label=y)\n        try:\n            booster = lgb.train(make_lgb_params(try_gpu=USE_GPU_TRY), dall,\n                                num_boost_round=best_iters,\n                                valid_sets=[dall], valid_names=[\"train\"])\n        except Exception as e:\n            if USE_GPU_TRY:\n                print(f\"⚠️ full-train GPU→CPU: {e}\")\n                booster = lgb.train(make_lgb_params(try_gpu=False), dall,\n                                    num_boost_round=best_iters,\n                                    valid_sets=[dall], valid_names=[\"train\"])\n            else:\n                raise\n        entry = dict(model=booster, feat_cols=feat_cols,\n                     thr=float(np.nanmedian(thr_list)),\n                     best_iter=best_iters, kind=\"lgb\")\n    else:\n        from sklearn.preprocessing import StandardScaler\n        from sklearn.linear_model import LogisticRegression\n        sc_all = StandardScaler(); Xs = sc_all.fit_transform(X)\n        clf_all = LogisticRegression(max_iter=200, class_weight=\"balanced\")\n        clf_all.fit(Xs, y)\n        entry = dict(model=(sc_all, clf_all), feat_cols=feat_cols,\n                     thr=float(np.nanmedian(thr_list)),\n                     best_iter=None, kind=\"logreg\")\n\n    MODELS[action] = entry\n    VAL_SCORES[action] = dict(\n        auc=float(np.nanmean(aucs)), f1=float(np.nanmean(f1s)), thr=float(np.nanmedian(thr_list))\n    )\n    print(f\"[{action}] CV AUC={VAL_SCORES[action]['auc']:.3f} | F1={VAL_SCORES[action]['f1']:.3f} | thr={VAL_SCORES[action]['thr']:.2f}\")\n    del data, X, y; gc.collect()\n\n# ============================================================\n# Build test pairs (same stride & scaling)\n# ============================================================\ndef build_pair_df_test(TEST_TRK_DIR):\n    # test 전부 스캔 (훈련처럼 필터 불가)\n    return build_pair_df_from_dir(TEST_TRK_DIR, ann_pair_filter=None, keep_only=None)\n\npair_df_test = build_pair_df_test(TEST_TRK_DIR)\nprint(\"[test] pairs:\", pair_df_test.shape)\n\nimport re\n\ndef _nat_key(s):\n    s = str(s)\n    return [int(t) if t.isdigit() else t.lower() for t in re.findall(r'\\d+|\\D+', s)]\n\ndef build_mouse_label_map(pair_df):\n    if pair_df is None or len(pair_df) == 0:\n        return {}\n    m = {}\n    for vid, g in pair_df.groupby(\"video_id\"):\n        ids = (\n            pd.Index(g[\"agent_id\"].astype(str))\n              .append(pd.Index(g[\"target_id\"].astype(str)))\n              .unique()\n        )\n        ids_sorted = sorted(ids, key=_nat_key)\n        for i, mid in enumerate(ids_sorted):\n            m[(str(vid), str(mid))] = f\"mouse{i+1}\"\n    return m\n\nmouse_map = build_mouse_label_map(pair_df_test)\n\n# ============================================================\n# Predict & segment\n# ============================================================\ndef predict_action_probs(test_df, action, entry):\n    feat_cols = entry[\"feat_cols\"]; X = test_df[feat_cols]\n    if entry[\"kind\"]==\"lgb\":\n        booster = entry[\"model\"]; return booster.predict(X, num_iteration=entry[\"best_iter\"])\n    sc, clf = entry[\"model\"];    return clf.predict_proba(sc.transform(X))[:,1]\n\nif len(pair_df_test):\n    test_df, _ = build_features(pair_df_test)\n    key_cols = [\"lab_id\",\"video_id\",\"agent_id\",\"target_id\",\"video_frame\"]\n    seg_list = []\n    for action in ACTIONS:\n        entry = MODELS[action]\n        for c in entry[\"feat_cols\"]:\n            if c not in test_df.columns: test_df[c] = np.nan\n        probs = predict_action_probs(test_df, action, entry)\n        df_prob = pd.concat([test_df[key_cols].reset_index(drop=True),\n                             pd.Series(probs, name=\"prob\")], axis=1)\n        segs = probs_to_segments(\n            df_prob, action, entry[\"thr\"], fps_lookup,\n            smooth_frac=0.8, min_len_sec=2, max_gap_sec=1,\n            frame_stride=FRAME_STRIDE, thr_low_frac=0.8\n        )\n        seg_list.append(segs)\n\n    final_pred = (pd.concat(seg_list, ignore_index=True)\n                  if seg_list else pd.DataFrame(columns=[\"lab_id\",\"video_id\",\"agent_id\",\"target_id\",\"action\",\"start_frame\",\"stop_frame\"]))\nelse:\n    final_pred = pd.DataFrame(columns=[\"lab_id\",\"video_id\",\"agent_id\",\"target_id\",\"action\",\"start_frame\",\"stop_frame\"])\n\n# (double guard) remove zero-length once more if any\nif len(final_pred):\n    final_pred = final_pred[final_pred[\"stop_frame\"] > final_pred[\"start_frame\"]].reset_index(drop=True)\n\nprint(f\"[pred] segments: {len(final_pred)}\")\n\n\n# ============================================================\n# 🔧 Clean & Coalesce before saving (drop-in patch)\n#  - 허용 라벨만 유지\n#  - 접합/오탈자 라벨 자동 보정\n#  - 가까운 구간 병합(프레임 간격 <= FRAME_STRIDE)\n# ============================================================\n\n# 1) 허용 라벨 셋 (훈련 주석에서 뽑은 ACTIONS와 교집합)\nKNOWN_ALLOWED = {\"approach\", \"avoid\", \"chase\", \"attack\"}\nALLOWED_ACTIONS = [a.lower() for a in ACTIONS if str(a).lower() in KNOWN_ALLOWED]\nif not ALLOWED_ACTIONS:  # 캐시/주석 이상 시 안전 기본값\n    ALLOWED_ACTIONS = sorted(list(KNOWN_ALLOWED))\nALLOWED_SET = set(ALLOWED_ACTIONS)\n\ndef _clean_action(a, allowed_set=ALLOWED_SET, priority=ALLOWED_ACTIONS):\n    if a is None or (isinstance(a, float) and np.isnan(a)): \n        return np.nan\n    s = str(a).strip().lower()\n    # 정확 매치\n    if s in allowed_set:\n        return s\n    # 붙거나 섞인 케이스(\"chaseattack\", \"submitchase\" 등): 포함된 합법 토큰만 추출\n    hits = [lab for lab in allowed_set if lab in s]\n    if len(hits) == 1:\n        return hits[0]\n    # 여러 개가 동시에 들어있다면 우선순위로 1개 선택\n    for lab in priority:\n        if lab in hits:\n            return lab\n    return np.nan  # 끝까지 결정 못 하면 버림\n\ndef _coalesce_segments(df, max_gap_frames=FRAME_STRIDE):\n    \"\"\"같은 (video, agent, target, action)에서 인접/겹침 구간 병합\"\"\"\n    keys = [\"video_id\",\"agent_id\",\"target_id\",\"action\"]\n    out = []\n    df2 = df.copy()\n    df2[\"start_frame\"] = df2[\"start_frame\"].astype(int)\n    df2[\"stop_frame\"]  = df2[\"stop_frame\"].astype(int)\n    for key, g in df2.sort_values(keys + [\"start_frame\",\"stop_frame\"]).groupby(keys, dropna=False):\n        cur_s = cur_e = None\n        for s, e in g[[\"start_frame\",\"stop_frame\"]].itertuples(index=False, name=None):\n            if cur_s is None:\n                cur_s, cur_e = s, e\n                continue\n            # 인접/겹침이면 병합 (간격 <= max_gap_frames)\n            if s <= cur_e + max_gap_frames:\n                cur_e = max(cur_e, e)\n            else:\n                out.append((*key, cur_s, cur_e))\n                cur_s, cur_e = s, e\n        if cur_s is not None:\n            out.append((*key, cur_s, cur_e))\n    return pd.DataFrame(out, columns=keys+[\"start_frame\",\"stop_frame\"])\n\n# --- 적용: final_pred 정리 ---\nif len(final_pred):\n    # 1) 라벨 정리\n    final_pred[\"action\"] = final_pred[\"action\"].apply(_clean_action)\n    final_pred = final_pred.dropna(subset=[\"action\"]).reset_index(drop=True)\n\n    # 2) (옵션) 구간 병합 – stride 간격 이하면 하나로 합치기\n    final_pred = _coalesce_segments(final_pred, max_gap_frames=FRAME_STRIDE)\n\n    # 3) 잘못된 구간 제거\n    final_pred = final_pred[final_pred[\"stop_frame\"] > final_pred[\"start_frame\"]].reset_index(drop=True)\n\n    # 디버그: 남은 라벨 확인\n    print(\"[post] actions kept:\", sorted(final_pred[\"action\"].unique()))\n\n\n\n# ============================================================\n# Save submission\n# ============================================================\nsub = final_pred[[\"video_id\",\"agent_id\",\"target_id\",\"action\",\"start_frame\",\"stop_frame\"]].copy()\n\nif len(sub):\n    sub[\"agent_id\"]  = [mouse_map.get((str(v), str(a)),  \"mouse1\") for v, a in zip(sub[\"video_id\"], sub[\"agent_id\"])]\n    sub[\"target_id\"] = [mouse_map.get((str(v), str(t)),  \"mouse1\") for v, t in zip(sub[\"video_id\"], sub[\"target_id\"])]\nsub = sub.reset_index(drop=True)\nsub.insert(0, \"row_id\", np.arange(len(sub)))\n\nsave_path = \"/kaggle/working/submission.csv\"\nsub.to_csv(save_path, index=False)\nprint(f\"[save] {save_path} (rows={len(sub)})\")\ntry:\n    display(sub.head(20))\nexcept Exception:\n    pass\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-09-19T21:22:03.886326Z","iopub.execute_input":"2025-09-19T21:22:03.886715Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"","metadata":{"trusted":true},"outputs":[],"execution_count":null}]}