{"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":"nvidiaTeslaT4","dataSources":[{"sourceId":59156,"databundleVersionId":13874099,"sourceType":"competition"}],"dockerImageVersionId":31089,"isInternetEnabled":false,"language":"python","sourceType":"notebook","isGpuEnabled":true}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"markdown","source":"# Credit \n- Author Name: Taylor S. Amarel\n- Notebook Link [https://www.kaggle.com/code/taylorsamarel/v2-mabe-remix]","metadata":{}},{"cell_type":"markdown","source":"# 1. Configuration","metadata":{}},{"cell_type":"code","source":"import os\n\nclass Config:\n    \"\"\"A central class to hold all configuration parameters.\"\"\"\n    \n    # --- Data Paths ---\n    BASE_DIR = '/kaggle/input/MABe-mouse-behavior-detection'\n    TRAIN_CSV = os.path.join(BASE_DIR, 'train.csv')\n    TEST_CSV = os.path.join(BASE_DIR, 'test.csv')\n    TRAIN_TRACKING_DIR = os.path.join(BASE_DIR, 'train_tracking')\n    TEST_TRACKING_DIR = os.path.join(BASE_DIR, 'test_tracking')\n    TRAIN_ANNOTATION_DIR = os.path.join(BASE_DIR, 'train_annotation')\n\n    # --- Output ---\n    SUBMISSION_FILE = 'submission.csv'\n\n    # --- Model & Training Settings ---\n    RANDOM_SEED = 42\n    N_SAMPLES_MAX_SINGLE = 100_000\n    N_SAMPLES_MAX_PAIR = 80_000\n    \n    # --- Feature Engineering ---\n    # Body parts to exclude from high-dimensional datasets\n    DROP_BODY_PARTS = [\n        'headpiece_bottombackleft', 'headpiece_bottombackright', \n        'headpiece_bottomfrontleft', 'headpiece_bottomfrontright', \n        'headpiece_topbackleft', 'headpiece_topbackright', \n        'headpiece_topfrontleft', 'headpiece_topfrontright', \n        'spine_1', 'spine_2', 'tail_middle_1', \n        'tail_middle_2', 'tail_midpoint'\n    ]\n    \n    # --- Post-Processing ---\n    DEFAULT_THRESHOLD = 0.27\n    MIN_DURATION_FRAMES = 3 # Minimum length of a predicted behavior","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-10-09T17:10:21.135094Z","iopub.execute_input":"2025-10-09T17:10:21.135633Z","iopub.status.idle":"2025-10-09T17:10:21.145373Z","shell.execute_reply.started":"2025-10-09T17:10:21.135612Z","shell.execute_reply":"2025-10-09T17:10:21.144691Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# 2.  Imports and Setup","metadata":{}},{"cell_type":"code","source":"import pandas as pd\nimport numpy as np\nfrom tqdm.notebook import tqdm\nimport itertools\nimport warnings\nimport json\nimport gc\nimport lightgbm\nimport polars as pl\nfrom collections import defaultdict\nfrom typing import List, Union, Dict, Tuple\n\n# --- Import models if available ---\ntry:\n    from xgboost import XGBClassifier\n    XGBOOST_AVAILABLE = True\nexcept ImportError:\n    XGBOOST_AVAILABLE = False\n    \ntry:\n    from catboost import CatBoostClassifier\n    CATBOOST_AVAILABLE = True\nexcept ImportError:\n    CATBOOST_AVAILABLE = False\n\n# --- Scikit-learn ---\nfrom sklearn.base import ClassifierMixin, BaseEstimator, clone\nfrom sklearn.pipeline import make_pipeline\nfrom sklearn.impute import SimpleImputer\nfrom sklearn.model_selection import StratifiedShuffleSplit\n\n# --- Setup ---\nwarnings.filterwarnings('ignore')\nnp.random.seed(Config.RANDOM_SEED)\n\nprint(f\"XGBoost available: {XGBOOST_AVAILABLE}\")\nprint(f\"CatBoost available: {CATBOOST_AVAILABLE}\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-10-09T17:10:21.146471Z","iopub.execute_input":"2025-10-09T17:10:21.147092Z","iopub.status.idle":"2025-10-09T17:10:28.095131Z","shell.execute_reply.started":"2025-10-09T17:10:21.147069Z","shell.execute_reply":"2025-10-09T17:10:28.094249Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# 3. Lightweight Sparse Temporal Features (Vectorized)","metadata":{}},{"cell_type":"code","source":"def frames_since_event(\n    series: pd.Series,\n    threshold: float,\n    above: bool = True,\n    cap: int = 999\n) -> np.ndarray:\n    \"\"\"\n    Calculates the number of frames since the last event occurred.\n    An event is defined as the series value crossing a given threshold.\n    \n    Args:\n        series: The input time series data.\n        threshold: The value threshold to define an event.\n        above: If True, event is series > threshold. If False, event is series < threshold.\n        cap: The maximum value for the counter.\n\n    Returns:\n        A NumPy array with the count of frames since the last event.\n    \"\"\"\n    s_numpy = series.to_numpy()\n    event_mask = s_numpy > threshold if above else s_numpy < threshold\n    \n    event_indices = np.where(event_mask)[0]\n    event_indices = np.insert(event_indices, 0, -1)\n    \n    last_event_map = event_indices[np.searchsorted(event_indices, np.arange(len(s_numpy)), side='right') - 1]\n    \n    result = np.arange(len(s_numpy)) - last_event_map\n    return np.minimum(result, cap)\n\ndef rolling_chunk_stats(\n    series: pd.Series, \n    statistic: str = 'var', \n    chunk_size: int = 10, \n    window_chunks: int = 36\n) -> pd.Series:\n    \"\"\"\n    Calculates statistics over rolling windows of chunk means.\n    \n    Args:\n        series: Input time series.\n        statistic: The statistic to compute ('var' or 'trend').\n        chunk_size: The number of frames in each small chunk.\n        window_chunks: The number of chunks in the main rolling window.\n        \n    Returns:\n        A pandas Series with the calculated statistic.\n    \"\"\"\n    window_size = chunk_size * window_chunks\n    \n    # Calculate means of non-overlapping chunks\n    chunk_means = series.rolling(chunk_size).mean().iloc[chunk_size-1::chunk_size]\n    \n    if statistic == 'var':\n        # Variance of the chunk means in a larger rolling window\n        rolling_stat = chunk_means.rolling(window_chunks, min_periods=2).var()\n    elif statistic == 'trend':\n        # Linear trend of the chunk means\n        def trend(x):\n            if len(x) < 2: return 0\n            return np.polyfit(np.arange(len(x)), x, 1)[0]\n        rolling_stat = chunk_means.rolling(window_chunks, min_periods=3).apply(trend, raw=True)\n    else:\n        raise ValueError(\"`statistic` must be 'var' or 'trend'\")\n        \n    # Upsample to original series frequency\n    return rolling_stat.reindex(series.index, method='ffill').fillna(0)\n\ndef add_sparse_temporal_features(X: pd.DataFrame, center_x: pd.Series, center_y: pd.Series) -> pd.DataFrame:\n    \"\"\"Adds lightweight sparse temporal features to the feature set.\"\"\"\n    try:\n        speed = pd.Series(\n            np.sqrt(center_x.diff().pow(2) + center_y.diff().pow(2)),\n            index=center_x.index\n        ).fillna(0)\n        \n        # 1. Time-since-event features\n        X['fs_still'] = frames_since_event(speed, 0.5, above=False)\n        X['fs_fast'] = frames_since_event(speed, 5.0, above=True)\n        \n        # 2. Chunked statistics (360 frames = 36 chunks of 10)\n        X['chk_var'] = rolling_chunk_stats(speed, 'var', chunk_size=10, window_chunks=36)\n        X['chk_trend'] = rolling_chunk_stats(speed, 'trend', chunk_size=10, window_chunks=36)\n        \n    except Exception as e:\n        print(f\"  Warning: Sparse temporal features failed. Error: {str(e)[:100]}\")\n    \n    return X\n\ndef add_sparse_interaction_features(X: pd.DataFrame, mouse_pair: pd.DataFrame) -> pd.DataFrame:\n    \"\"\"Adds sparse social interaction features.\"\"\"\n    try:\n        if ('A', 'body_center', 'x') not in mouse_pair.columns or ('B', 'body_center', 'x') not in mouse_pair.columns:\n            return X\n            \n        rel_dist = pd.Series(\n            np.sqrt(\n                (mouse_pair['A']['body_center']['x'] - mouse_pair['B']['body_center']['x'])**2 +\n                (mouse_pair['A']['body_center']['y'] - mouse_pair['B']['body_center']['y'])**2\n            ), index=mouse_pair.index\n        ).fillna(method='bfill').fillna(method='ffill')\n\n        # Time since close proximity\n        X['fs_close'] = frames_since_event(rel_dist, 10.0, above=False)\n        \n        # Chunked distance variability and trend\n        X['d_chk_var'] = rolling_chunk_stats(rel_dist, 'var', chunk_size=10, window_chunks=24)\n        X['d_trend'] = rolling_chunk_stats(rel_dist, 'trend', chunk_size=10, window_chunks=24)\n        \n    except (np.linalg.LinAlgError, ValueError) as e:\n        print(f\"  Warning: Sparse interaction features failed with numerical error: {str(e)[:100]}\")\n        # On failure, impute neutral values for these features\n        for col in ['fs_close', 'd_chk_var', 'd_trend']:\n            X[col] = 0\n    except Exception as e:\n        print(f\"  Warning: Sparse interaction features failed. Error: {str(e)[:100]}\")\n\n    return X","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-10-09T17:10:28.096537Z","iopub.execute_input":"2025-10-09T17:10:28.097108Z","iopub.status.idle":"2025-10-09T17:10:28.113624Z","shell.execute_reply.started":"2025-10-09T17:10:28.097079Z","shell.execute_reply":"2025-10-09T17:10:28.112752Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# 4. Main Feature Engineering","metadata":{}},{"cell_type":"code","source":"def transform_single(single_mouse: pd.DataFrame, body_parts_tracked: List[str]) -> pd.DataFrame:\n    \"\"\"Transforms tracking data for a single mouse into a feature matrix.\"\"\"\n    available_body_parts = single_mouse.columns.get_level_values(0)\n    \n    # Base distance features\n    feature_dict = {\n        f\"{p1}+{p2}\": np.square(single_mouse[p1] - single_mouse[p2]).sum(axis=1)\n        for p1, p2 in itertools.combinations(body_parts_tracked, 2) \n        if p1 in available_body_parts and p2 in available_body_parts\n    }\n    X = pd.DataFrame(feature_dict, index=single_mouse.index)\n    \n    # Core temporal features\n    if 'body_center' in available_body_parts:\n        cx = single_mouse['body_center']['x']\n        cy = single_mouse['body_center']['y']\n        \n        for w in [5, 15, 30, 60]:\n            X[f'cx_s{w}'] = cx.rolling(w, min_periods=1, center=True).std()\n            X[f'cy_s{w}'] = cy.rolling(w, min_periods=1, center=True).std()\n            X[f'disp{w}'] = np.sqrt(cx.diff().rolling(w, min_periods=1).sum()**2 + cy.diff().rolling(w, min_periods=1).sum()**2)\n            X[f'act{w}'] = np.sqrt(cx.diff().rolling(w, min_periods=1).var() + cy.diff().rolling(w, min_periods=1).var())\n        \n        # Add lightweight sparse temporal features\n        X = add_sparse_temporal_features(X, cx, cy)\n        \n    return X\n\ndef transform_pair(mouse_pair: pd.DataFrame, body_parts_tracked: List[str]) -> pd.DataFrame:\n    \"\"\"Transforms tracking data for a pair of mice into a feature matrix.\"\"\"\n    avail_A = mouse_pair['A'].columns.get_level_values(0)\n    avail_B = mouse_pair['B'].columns.get_level_values(0)\n    \n    # Inter-mouse distances\n    feature_dict = {\n        f\"12+{p1}+{p2}\": np.square(mouse_pair['A'][p1] - mouse_pair['B'][p2]).sum(axis=1)\n        for p1, p2 in itertools.product(body_parts_tracked, repeat=2) \n        if p1 in avail_A and p2 in avail_B\n    }\n    X = pd.DataFrame(feature_dict, index=mouse_pair.index)\n\n    # Temporal interaction features\n    if 'body_center' in avail_A and 'body_center' in avail_B:\n        cd_full = np.square(mouse_pair['A']['body_center'] - mouse_pair['B']['body_center']).sum(axis=1)\n        \n        for w in [5, 15, 30, 60]:\n            X[f'd_m{w}'] = cd_full.rolling(w, min_periods=1, center=True).mean()\n            X[f'd_s{w}'] = cd_full.rolling(w, min_periods=1, center=True).std()\n            X[f'd_mx{w}'] = cd_full.rolling(w, min_periods=1, center=True).max()\n        \n        Axd = mouse_pair['A']['body_center']['x'].diff()\n        Ayd = mouse_pair['A']['body_center']['y'].diff()\n        Bxd = mouse_pair['B']['body_center']['x'].diff()\n        Byd = mouse_pair['B']['body_center']['y'].diff()\n        coord = Axd * Bxd + Ayd * Byd\n        X[f'co_m30'] = coord.rolling(30, min_periods=1, center=True).mean()\n        \n        # Add sparse interaction features\n        X = add_sparse_interaction_features(X, mouse_pair)\n        \n    return X","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-10-09T17:10:28.114682Z","iopub.execute_input":"2025-10-09T17:10:28.115028Z","iopub.status.idle":"2025-10-09T17:10:28.138141Z","shell.execute_reply.started":"2025-10-09T17:10:28.114995Z","shell.execute_reply":"2025-10-09T17:10:28.137433Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# 5. Data Loading and Generation","metadata":{}},{"cell_type":"code","source":"def generate_mouse_data(dataset: pd.DataFrame, traintest: str):\n    \"\"\"\n    A generator that yields processed data for single mice and pairs.\n    Reads tracking and annotation data video by video.\n    \"\"\"\n    assert traintest in ['train', 'test']\n    traintest_directory = Config.TRAIN_TRACKING_DIR if traintest == 'train' else Config.TEST_TRACKING_DIR\n    \n    for _, row in dataset.iterrows():\n        lab_id, video_id = row.lab_id, row.video_id\n        if lab_id.startswith('MABe22'): continue\n        if not isinstance(row.behaviors_labeled, str): continue\n\n        path = os.path.join(traintest_directory, lab_id, f\"{video_id}.parquet\")\n        try:\n            vid_df = pd.read_parquet(path)\n        except FileNotFoundError:\n            continue\n            \n        # Pre-process tracking data\n        if len(vid_df['bodypart'].unique()) > 5:            \n            vid_df = vid_df[~vid_df['bodypart'].isin(Config.DROP_BODY_PARTS)]\n        pvid = vid_df.pivot(columns=['mouse_id', 'bodypart'], index='video_frame', values=['x', 'y'])\n        pvid = pvid.reorder_levels([1, 2, 0], axis=1).sort_index(axis=1)\n        pvid /= row.pix_per_cm_approx\n        del vid_df\n        \n        # Parse labeled behaviors\n        vid_behaviors = pd.DataFrame(\n            [b.split(',') for b in sorted(list(set(json.loads(row.behaviors_labeled))))],\n            columns=['agent', 'target', 'action']\n        )\n        \n        # Load annotations if training\n        annot = None\n        if traintest == 'train':\n            try:\n                annot_path = os.path.join(Config.TRAIN_ANNOTATION_DIR, lab_id, f\"{video_id}.parquet\")\n                annot = pd.read_parquet(annot_path)\n            except FileNotFoundError:\n                continue\n\n        # --- Yield Single Mouse Data ---\n        single_behaviors = vid_behaviors.query(\"target == 'self'\")\n        for mouse_id_str in single_behaviors['agent'].unique():\n            try:\n                mouse_id = int(mouse_id_str[-1])\n                actions = single_behaviors.query(\"agent == @mouse_id_str\")['action'].unique()\n                data = pvid.loc[:, mouse_id]\n                meta = pd.DataFrame({'video_id': video_id, 'agent_id': mouse_id_str, 'target_id': 'self', 'video_frame': data.index})\n                \n                if traintest == 'train':\n                    labels = pd.DataFrame(0.0, columns=actions, index=data.index)\n                    annot_sub = annot.query(\"(agent_id == @mouse_id) & (target_id == @mouse_id)\")\n                    for _, r in annot_sub.iterrows():\n                        labels.loc[r['start_frame']:r['stop_frame'], r.action] = 1.0\n                    yield 'single', data, meta, labels\n                else:\n                    yield 'single', data, meta, actions\n            except (KeyError, IndexError):\n                pass\n\n        # --- Yield Paired Mouse Data ---\n        pair_behaviors = vid_behaviors.query(\"target != 'self'\")\n        mice_ids = pvid.columns.get_level_values('mouse_id').unique()\n        for agent, target in itertools.permutations(mice_ids, 2):\n            agent_str, target_str = f\"mouse{agent}\", f\"mouse{target}\"\n            actions = pair_behaviors.query(\"(agent == @agent_str) & (target == @target_str)\")['action'].unique()\n            if len(actions) == 0: continue\n\n            try:\n                data = pd.concat([pvid[agent], pvid[target]], axis=1, keys=['A', 'B'])\n                meta = pd.DataFrame({'video_id': video_id, 'agent_id': agent_str, 'target_id': target_str, 'video_frame': data.index})\n                \n                if traintest == 'train':\n                    labels = pd.DataFrame(0.0, columns=actions, index=data.index)\n                    annot_sub = annot.query(\"(agent_id == @agent) & (target_id == @target)\")\n                    for _, r in annot_sub.iterrows():\n                        labels.loc[r['start_frame']:r['stop_frame'], r.action] = 1.0\n                    yield 'pair', data, meta, labels\n                else:\n                    yield 'pair', data, meta, actions\n            except (KeyError, IndexError):\n                pass\n        \n        del pvid, annot\n        gc.collect()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-10-09T17:10:28.139555Z","iopub.execute_input":"2025-10-09T17:10:28.139889Z","iopub.status.idle":"2025-10-09T17:10:28.158256Z","shell.execute_reply.started":"2025-10-09T17:10:28.139872Z","shell.execute_reply":"2025-10-09T17:10:28.157686Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# 6. Modeling and Prediction","metadata":{}},{"cell_type":"code","source":"class StratifiedSubsetClassifier(ClassifierMixin, BaseEstimator):\n    \"\"\"A wrapper that fits an estimator on a stratified random subset of the data.\"\"\"\n    def __init__(self, estimator, n_samples: int):\n        self.estimator = estimator\n        self.n_samples = n_samples\n\n    def fit(self, X, y):\n        if len(X) <= self.n_samples:\n            self.estimator.fit(X, y)\n        else:\n            sss = StratifiedShuffleSplit(n_splits=1, train_size=self.n_samples, random_state=Config.RANDOM_SEED)\n            try:\n                train_idx, _ = next(sss.split(X, y))\n                self.estimator.fit(X[train_idx], y[train_idx])\n            except ValueError: # Handle cases with too few samples for stratification\n                indices = np.random.choice(len(X), self.n_samples, replace=False)\n                self.estimator.fit(X[indices], y[indices])\n        \n        self.classes_ = self.estimator.classes_\n        return self\n\n    def predict_proba(self, X):\n        return self.estimator.predict_proba(X)\n        \n    def predict(self, X):\n        return self.estimator.predict(X)\n\ndef get_model_ensemble() -> List[BaseEstimator]:\n    \"\"\"Defines and returns a list of models for the ensemble.\"\"\"\n    models = []\n    # Model 1: LightGBM (Balanced)\n    models.append(make_pipeline(SimpleImputer(), StratifiedSubsetClassifier(\n        lightgbm.LGBMClassifier(n_estimators=225, learning_rate=0.07, random_state=Config.RANDOM_SEED, verbose=-1),\n        n_samples=Config.N_SAMPLES_MAX_SINGLE\n    )))\n    # Model 2: LightGBM (Deeper)\n    models.append(make_pipeline(SimpleImputer(), StratifiedSubsetClassifier(\n        lightgbm.LGBMClassifier(n_estimators=150, num_leaves=63, random_state=Config.RANDOM_SEED, verbose=-1),\n        n_samples=Config.N_SAMPLES_MAX_PAIR\n    )))\n    # Model 3: XGBoost\n    if XGBOOST_AVAILABLE:\n        models.append(make_pipeline(SimpleImputer(), StratifiedSubsetClassifier(\n            XGBClassifier(n_estimators=180, learning_rate=0.08, tree_method='hist', random_state=Config.RANDOM_SEED, verbosity=0),\n            n_samples=Config.N_SAMPLES_MAX_PAIR\n        )))\n    return models\n\ndef predict_to_submission(pred_probas: pd.DataFrame, meta: pd.DataFrame) -> pd.DataFrame:\n    \"\"\"Converts prediction probabilities to a submission-formatted DataFrame.\"\"\"\n    if pred_probas.empty:\n        return pd.DataFrame()\n\n    # Smooth probabilities and find the most likely action per frame\n    pred_smoothed = pred_probas.rolling(window=5, min_periods=1, center=True).mean()\n    ama = np.argmax(pred_smoothed.values, axis=1)\n    max_probs = pred_smoothed.max(axis=1).values\n    \n    # Apply a default threshold\n    threshold_mask = max_probs >= Config.DEFAULT_THRESHOLD\n\n    ama_series = pd.Series(np.where(threshold_mask, ama, -1), index=meta.index)\n    \n    changes = ama_series[ama_series != ama_series.shift(1)]\n\n    starts = changes[changes >= 0]\n    if starts.empty:\n        return pd.DataFrame()\n        \n    start_frames = starts.index\n    stop_frames = []\n    \n    change_indices = changes.index\n    for frame in start_frames:\n        \n        current_pos = change_indices.get_loc(frame)\n        \n        # If it's not the last change, the stop frame is the next change\n        if current_pos + 1 < len(change_indices):\n            stop_frames.append(change_indices[current_pos + 1])\n        # If it is the last change, the stop frame is the end of the video\n        else:\n            stop_frames.append(meta.index.max() + 1)\n            \n    sub_part = pd.DataFrame({\n        'video_id': meta.loc[start_frames, 'video_id'].values,\n        'agent_id': meta.loc[start_frames, 'agent_id'].values,\n        'target_id': meta.loc[start_frames, 'target_id'].values,\n        'action': pred_probas.columns[starts.values],\n        'start_frame': start_frames,\n        'stop_frame': stop_frames\n    })\n    \n    # Filter out short-duration events\n    sub_part = sub_part[sub_part.stop_frame - sub_part.start_frame >= Config.MIN_DURATION_FRAMES]\n    return sub_part.reset_index(drop=True)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-10-09T17:13:53.659472Z","iopub.execute_input":"2025-10-09T17:13:53.659736Z","iopub.status.idle":"2025-10-09T17:13:53.672835Z","shell.execute_reply.started":"2025-10-09T17:13:53.659715Z","shell.execute_reply":"2025-10-09T17:13:53.672178Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# 7. Main Execution Loop","metadata":{}},{"cell_type":"code","source":"# --- Load datasets ---\ntrain_df = pd.read_csv(Config.TRAIN_CSV)\ntest_df = pd.read_csv(Config.TEST_CSV)\nbody_parts_tracked_list = list(train_df.body_parts_tracked.unique())\n\nsubmission_list = []\n\nfor section, body_parts_tracked_str in enumerate(body_parts_tracked_list):\n    if body_parts_tracked_str is np.nan: continue\n    \n    body_parts_tracked = json.loads(body_parts_tracked_str)\n    print(f\"\\n--- Processing group {section+1}/{len(body_parts_tracked_list)}: {len(body_parts_tracked)} body parts ---\")\n    \n    # Prepare training data for this body part configuration\n    train_subset = train_df[train_df.body_parts_tracked == body_parts_tracked_str]\n    \n    all_data = {'single': [], 'pair': []}\n    all_labels = {'single': [], 'pair': []}\n    all_meta = {'single': [], 'pair': []}\n    \n    for switch, data, meta, label in generate_mouse_data(train_subset, 'train'):\n        all_data[switch].append(data)\n        all_labels[switch].append(label)\n        all_meta[switch].append(meta)\n\n    # --- Train and predict for each data type (single/pair) ---\n    for switch in ['single', 'pair']:\n        if not all_data[switch]:\n            continue\n            \n        print(f\"  Processing '{switch}' mouse data...\")\n        # After\n        data_tr = pd.concat(all_data[switch]).reset_index(drop=True)\n        label_tr = pd.concat(all_labels[switch]).reset_index(drop=True)\n        \n        # Feature Engineering\n        transform_func = transform_single if switch == 'single' else transform_pair\n        X_tr = transform_func(data_tr, body_parts_tracked)\n        del data_tr\n        gc.collect()\n\n        print(f\"    Train features shape: {X_tr.shape}\")\n        \n        # Train one model per action\n        action_models = {}\n        for action in tqdm(label_tr.columns, desc=f\"    Training on {switch} actions\"):\n            y_action = label_tr[action].dropna()\n            if y_action.sum() < 10: continue # Skip actions with too few examples\n            \n            ensemble = get_model_ensemble()\n            trained_models = []\n            for model in ensemble:\n                try:\n                    m_clone = clone(model)\n                    m_clone.fit(X_tr.loc[y_action.index].values, y_action.values)\n                    trained_models.append(m_clone)\n                except Exception as e:\n                    print(f\"      Warning: Model training failed for action '{action}'. Error: {e}\")\n            \n            if trained_models:\n                action_models[action] = trained_models\n        \n        del X_tr, label_tr\n        gc.collect()\n        \n        # --- Inference on Test Set ---\n        test_subset = test_df[test_df.body_parts_tracked == body_parts_tracked_str]\n        test_generator = generate_mouse_data(test_subset, 'test')\n        \n        for switch_te, data_te, meta_te, actions_te in test_generator:\n            if switch_te != switch: continue\n            \n            X_te = transform_func(data_te, body_parts_tracked)\n            meta_te.set_index('video_frame', inplace=True)\n            \n            pred_probas = pd.DataFrame(index=X_te.index)\n            for action, models in action_models.items():\n                if action in actions_te:\n                    try:\n                        probs = [m.predict_proba(X_te.values)[:, 1] for m in models]\n                        pred_probas[action] = np.mean(probs, axis=0)\n                    except Exception as e:\n                        print(f\"      Warning: Prediction failed for action '{action}'. Error: {e}\")\n                        \n            sub_part = predict_to_submission(pred_probas, meta_te)\n            if not sub_part.empty:\n                submission_list.append(sub_part)\n                print(f\"    Found {len(sub_part)} predictions for video {meta_te['video_id'].iloc[0]}\")\n\n            del X_te, data_te, meta_te, pred_probas\n            gc.collect()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-10-09T17:13:56.867588Z","iopub.execute_input":"2025-10-09T17:13:56.868321Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# 8. Final Submission","metadata":{}},{"cell_type":"code","source":"def robustify_submission(submission: pd.DataFrame) -> pd.DataFrame:\n    \"\"\"Post-processes the submission to remove overlaps.\"\"\"\n    if submission.empty:\n        return submission\n        \n    submission = submission[submission.start_frame < submission.stop_frame].copy()\n    \n    clean_groups = []\n    # Group by the agent/target pair within each video\n    for _, group in submission.groupby(['video_id', 'agent_id', 'target_id']):\n        group = group.sort_values('start_frame').reset_index(drop=True)\n        \n        # Greedily remove any prediction that overlaps with the previously accepted one\n        if not group.empty:\n            accepted = [group.iloc[0]]\n            for i in range(1, len(group)):\n                if group.iloc[i]['start_frame'] >= accepted[-1]['stop_frame']:\n                    accepted.append(group.iloc[i])\n            clean_groups.append(pd.DataFrame(accepted))\n            \n    return pd.concat(clean_groups).reset_index(drop=True) if clean_groups else pd.DataFrame()\n\n# --- Create final submission file ---\nif not submission_list:\n    # Create a dummy submission if no predictions were made\n    final_submission = pd.DataFrame({\n        'video_id': [438887472], 'agent_id': ['mouse1'], 'target_id': ['self'],\n        'action': ['rear'], 'start_frame': [100], 'stop_frame': [200]\n    })\nelse:\n    submission = pd.concat(submission_list).reset_index(drop=True)\n    final_submission = robustify_submission(submission)\n\nfinal_submission.index.name = 'row_id'\nfinal_submission.to_csv(Config.SUBMISSION_FILE)\n\nprint(f\"\\n✅ Submission created: {Config.SUBMISSION_FILE}\")\nprint(f\"Total predictions: {len(final_submission)}\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-10-09T17:10:54.427494Z","iopub.status.idle":"2025-10-09T17:10:54.427755Z","shell.execute_reply.started":"2025-10-09T17:10:54.427639Z","shell.execute_reply":"2025-10-09T17:10:54.427652Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"","metadata":{"trusted":true},"outputs":[],"execution_count":null}]}