{"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":"gpu","dataSources":[{"sourceId":59156,"databundleVersionId":13874099,"sourceType":"competition"}],"dockerImageVersionId":31193,"isInternetEnabled":false,"language":"python","sourceType":"notebook","isGpuEnabled":true}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"code","source":"#============= SETTINGS =============#\nverbose = True\nSTATIC_THRESHOLD = 0.27\nRANDOM_STATE = 42\n\n#============= IMPORTS =============#\nimport pandas as pd\nimport numpy as np\nimport os\nimport json\nfrom collections import defaultdict\nimport itertools\nimport gc\nfrom scipy.signal import medfilt\nfrom scipy.ndimage import gaussian_filter1d\n\nfrom sklearn.base import ClassifierMixin, BaseEstimator, clone\nfrom sklearn.pipeline import make_pipeline\nfrom sklearn.model_selection import train_test_split, StratifiedShuffleSplit, GroupShuffleSplit\nfrom sklearn.metrics import roc_auc_score, f1_score\n\ntry:\n    from xgboost import XGBClassifier\n    XGBOOST_AVAILABLE = True\nexcept:\n    XGBOOST_AVAILABLE = False\n\ntry:\n    from catboost import CatBoostClassifier\n    CATBOOST_AVAILABLE = True\nexcept:\n    CATBOOST_AVAILABLE = False\n\n#============= SEEDS =============#\nSEED = 42\nos.environ['PYTHONHASHSEED'] = str(SEED)\nrnd = np.random.RandomState(SEED)\nimport random \nrandom.seed(SEED)\nnp.random.seed(SEED)\n\nimport warnings\nwarnings.filterwarnings('ignore')\nos.environ['PYTHONWARNINGS'] = 'ignore'\nos.environ['OMP_NUM_THREADS'] = '1'\npd.set_option('compute.use_numexpr', False)\n\n#============= KAGGLE PATHS & DATA =============#\nBASE_PATH = '/kaggle/input/MABe-mouse-behavior-detection'\nTRAIN_TRACKING_DIR = os.path.join(BASE_PATH, 'train_tracking')\nTEST_TRACKING_DIR = os.path.join(BASE_PATH, 'test_tracking')\nTRAIN_ANNOTATION_DIR = os.path.join(BASE_PATH, 'train_annotation')\n\nDROP_BODY_PARTS = [\n    'headpiece_bottombackleft','headpiece_bottombackright','headpiece_bottomfrontleft','headpiece_bottomfrontright',\n    'headpiece_topbackleft','headpiece_topbackright','headpiece_topfrontleft','headpiece_topfrontright',\n    'spine_1','spine_2','tail_middle_1','tail_middle_2','tail_midpoint'\n]\n\n#============= LOADING DATA =============#\ntrain = pd.read_csv('/kaggle/input/MABe-mouse-behavior-detection/train.csv')\ntrain['n_mice'] = 4 - train[['mouse1_strain', 'mouse2_strain', 'mouse3_strain', 'mouse4_strain']].isna().sum(axis=1)\ntrain_without_mabe22 = train.query(\"~ lab_id.str.startswith('MABe22_')\")\n\ntest = pd.read_csv('/kaggle/input/MABe-mouse-behavior-detection/test.csv')\nbody_parts_tracked_list = list(np.unique(train.body_parts_tracked))\n#============= DATA GENERATOR =============#\ndef generate_mouse_data(dataset, traintest, traintest_directory=None, generate_single=True, generate_pair=True):\n    assert traintest in ['train', 'test']\n\n    if traintest_directory is None:\n        traintest_directory = f\"/kaggle/input/MABe-mouse-behavior-detection/{traintest}_tracking\"\n\n    for _, row in dataset.iterrows():\n        lab_id = row.lab_id\n        video_id = row.video_id\n\n        if type(row.behaviors_labeled) != str:\n            if verbose:\n                print('No labeled behaviors:', lab_id, video_id)\n            continue\n\n        path = f\"{traintest_directory}/{lab_id}/{video_id}.parquet\"\n        vid = pd.read_parquet(path)\n        if len(np.unique(vid.bodypart)) > 5:\n            vid = vid.query('~ bodypart.isin(@DROP_BODY_PARTS)')\n\n        pvid = vid.pivot(columns=['mouse_id', 'bodypart'], index='video_frame', values=['x', 'y'])\n        if pvid.isna().any().any():\n            if verbose and traintest == 'test':\n                print('    Video with missing values', video_id, traintest, len(vid), 'frames')\n        else:\n            if verbose and traintest == 'test':\n                print('    Video with all values', video_id, traintest, len(vid), 'frames')\n\n        del vid\n        pvid = pvid.reorder_levels([1, 2, 0], axis=1).T.sort_index().T\n        pvid /= row.pix_per_cm_approx\n\n        vid_behaviors = json.loads(row.behaviors_labeled)\n        vid_behaviors = sorted(list({b.replace(\"'\", \"\") for b in vid_behaviors}))\n        vid_behaviors = [b.split(',') for b in vid_behaviors]\n        vid_behaviors = pd.DataFrame(vid_behaviors, columns=['agent', 'target', 'action'])\n\n        if traintest == 'train':\n            try:\n                annot = pd.read_parquet(path.replace('train_tracking', 'train_annotation'))\n            except FileNotFoundError:\n                continue\n\n        if generate_single:\n            vid_behaviors_subset = vid_behaviors.query(\"target == 'self'\")\n            for mouse_id_str in np.unique(vid_behaviors_subset.agent):\n                try:\n                    mouse_id = int(mouse_id_str[-1])\n                    vid_agent_actions = np.unique(vid_behaviors_subset.query('agent == @mouse_id_str').action)\n                    single_mouse = pvid.loc[:, mouse_id]\n                    assert len(single_mouse) == len(pvid)\n                    single_mouse_meta = pd.DataFrame({\n                        'video_id': video_id,\n                        'agent_id': mouse_id_str,\n                        'target_id': 'self',\n                        'video_frame': single_mouse.index,\n                        'frames_per_second': row.frames_per_second\n                    })\n                    if traintest == 'train':\n                        single_mouse_label = pd.DataFrame(0.0, columns=vid_agent_actions, index=single_mouse.index)\n                        annot_subset = annot.query('(agent_id == @mouse_id) & (target_id == @mouse_id)')\n                        for i in range(len(annot_subset)):\n                            annot_row = annot_subset.iloc[i]\n                            single_mouse_label.loc[annot_row['start_frame']:annot_row['stop_frame'], annot_row.action] = 1.0\n                        yield 'single', single_mouse, single_mouse_meta, single_mouse_label\n                    else:\n                        if verbose: print('- Test single', video_id, mouse_id)\n                        yield 'single', single_mouse, single_mouse_meta, vid_agent_actions\n                except KeyError:\n                    pass\n\n        if generate_pair:\n            vid_behaviors_subset = vid_behaviors.query(\"target != 'self'\")\n            if len(vid_behaviors_subset) > 0:\n                for agent, target in itertools.permutations(np.unique(pvid.columns.get_level_values('mouse_id')), 2):\n                    agent_str = f\"mouse{agent}\"\n                    target_str = f\"mouse{target}\"\n                    vid_agent_actions = np.unique(vid_behaviors_subset.query('(agent == @agent_str) & (target == @target_str)').action)\n                    mouse_pair = pd.concat([pvid[agent], pvid[target]], axis=1, keys=['A', 'B'])\n                    assert len(mouse_pair) == len(pvid)\n                    mouse_pair_meta = pd.DataFrame({\n                        'video_id': video_id,\n                        'agent_id': agent_str,\n                        'target_id': target_str,\n                        'video_frame': mouse_pair.index,\n                        'frames_per_second': row.frames_per_second\n                    })\n                    if traintest == 'train':\n                        mouse_pair_label = pd.DataFrame(0.0, columns=vid_agent_actions, index=mouse_pair.index)\n                        annot_subset = annot.query('(agent_id == @agent) & (target_id == @target)')\n                        for i in range(len(annot_subset)):\n                            annot_row = annot_subset.iloc[i]\n                            mouse_pair_label.loc[annot_row['start_frame']:annot_row['stop_frame'], annot_row.action] = 1.0\n                        yield 'pair', mouse_pair, mouse_pair_meta, mouse_pair_label\n                    else:\n                        if verbose: print('- Test pair', video_id, agent, target)\n                        yield 'pair', mouse_pair, mouse_pair_meta, vid_agent_actions\n#============= FEATURE ENGINEERING =============#\ndef safe_rolling(series, window, func, min_periods=None):\n    if min_periods is None:\n        min_periods = max(1, window // 4)\n    return series.rolling(window, min_periods=min_periods, center=True).apply(func, raw=True)\n\ndef _scale(n_frames_at_30fps, fps, ref=30.0):\n    return max(1, int(round(n_frames_at_30fps * float(fps) / ref)))\n\ndef _scale_signed(n_frames_at_30fps, fps, ref=30.0):\n    if n_frames_at_30fps == 0:\n        return 0\n    s = 1 if n_frames_at_30fps > 0 else -1\n    mag = max(1, int(round(abs(n_frames_at_30fps) * float(fps) / ref)))\n    return s * mag\n\ndef _fps_from_meta(meta_df, fallback_lookup, default_fps=30.0):\n    if 'frames_per_second' in meta_df.columns and pd.notnull(meta_df['frames_per_second']).any():\n        return float(meta_df['frames_per_second'].iloc[0])\n    vid = meta_df['video_id'].iloc[0]\n    return float(fallback_lookup.get(vid, default_fps))\n\ndef add_curvature_features(X, center_x, center_y, fps):\n    vel_x = center_x.diff()\n    vel_y = center_y.diff()\n    acc_x = vel_x.diff()\n    acc_y = vel_y.diff()\n\n    cross_prod = vel_x * acc_y - vel_y * acc_x\n    vel_mag = np.sqrt(vel_x**2 + vel_y**2)\n    curvature = np.abs(cross_prod) / (vel_mag**3 + 1e-6)\n\n    for w in [30, 60, 75]:\n        ws = _scale(w, fps)\n        X[f'curv_mean_{w}'] = curvature.rolling(ws, min_periods=max(1, ws // 6)).mean()\n\n    angle = np.arctan2(vel_y, vel_x)\n    angle_change = np.abs(angle.diff())\n    w = 30\n    ws = _scale(w, fps)\n    X[f'turn_rate_{w}'] = angle_change.rolling(ws, min_periods=max(1, ws // 6)).sum()\n\n    return X\n\ndef add_multiscale_features(X, center_x, center_y, fps):\n    speed = np.sqrt(center_x.diff()**2 + center_y.diff()**2) * float(fps)\n\n    scales = [20, 40, 80, 160]\n    for scale in scales:\n        ws = _scale(scale, fps)\n        if len(speed) >= ws:\n            X[f'sp_m{scale}'] = speed.rolling(ws, min_periods=max(1, ws // 4)).mean()\n            X[f'sp_s{scale}'] = speed.rolling(ws, min_periods=max(1, ws // 4)).std()\n\n    if len(scales) >= 2 and f'sp_m{scales[0]}' in X.columns and f'sp_m{scales[-1]}' in X.columns:\n        X['sp_ratio'] = X[f'sp_m{scales[0]}'] / (X[f'sp_m{scales[-1]}'] + 1e-6)\n\n    return X\n\ndef add_state_features(X, center_x, center_y, fps):\n    speed = np.sqrt(center_x.diff()**2 + center_y.diff()**2) * float(fps)\n    w_ma = _scale(15, fps)\n    speed_ma = speed.rolling(w_ma, min_periods=max(1, w_ma // 3)).mean()\n\n    try:\n        bins = [-np.inf, 0.5 * fps, 2.0 * fps, 5.0 * fps, np.inf]\n        speed_states = pd.cut(speed_ma, bins=bins, labels=[0, 1, 2, 3]).astype(float)\n\n        for window in [20, 60, 80, 120]:\n            ws = _scale(window, fps)\n            if len(speed_states) >= ws:\n                for state in [0, 1, 2, 3]:\n                    X[f's{state}_{window}'] = (\n                        (speed_states == state).astype(float)\n                        .rolling(ws, min_periods=max(1, ws // 6)).mean()\n                    )\n                state_changes = (speed_states != speed_states.shift(1)).astype(float)\n                X[f'trans_{window}'] = state_changes.rolling(ws, min_periods=max(1, ws // 6)).sum()\n    except Exception:\n        pass\n\n    return X\n\ndef add_longrange_features(X, center_x, center_y, fps):\n    for window in [120, 240]:\n        ws = _scale(window, fps)\n        if len(center_x) >= ws:\n            X[f'x_ml{window}'] = center_x.rolling(ws, min_periods=max(5, ws // 6)).mean()\n            X[f'y_ml{window}'] = center_y.rolling(ws, min_periods=max(5, ws // 6)).mean()\n\n    for span in [30, 60, 120]:\n        s = _scale(span, fps)\n        X[f'x_e{span}'] = center_x.ewm(span=s, min_periods=1).mean()\n        X[f'y_e{span}'] = center_y.ewm(span=s, min_periods=1).mean()\n\n    speed = np.sqrt(center_x.diff()**2 + center_y.diff()**2) * float(fps)\n\n    for window in [30, 60, 120]:\n        ws = _scale(window, fps)\n        if len(speed) >= ws:\n            X[f'sp_pct{window}'] = speed.rolling(ws, min_periods=max(5, ws // 6)).rank(pct=True)\n\n    return X\n\ndef add_interaction_features(X, mouse_pair, avail_A, avail_B, fps):\n    if 'body_center' not in avail_A or 'body_center' not in avail_B:\n        return X\n\n    rel_x = mouse_pair['A']['body_center']['x'] - mouse_pair['B']['body_center']['x']\n    rel_y = mouse_pair['A']['body_center']['y'] - mouse_pair['B']['body_center']['y']\n    rel_dist = np.sqrt(rel_x**2 + rel_y**2)\n\n    A_vx = mouse_pair['A']['body_center']['x'].diff()\n    A_vy = mouse_pair['A']['body_center']['y'].diff()\n    B_vx = mouse_pair['B']['body_center']['x'].diff()\n    B_vy = mouse_pair['B']['body_center']['y'].diff()\n\n    A_lead = (A_vx * rel_x + A_vy * rel_y) / (np.sqrt(A_vx**2 + A_vy**2) * rel_dist + 1e-6)\n    B_lead = (B_vx * (-rel_x) + B_vy * (-rel_y)) / (np.sqrt(B_vx**2 + B_vy**2) * rel_dist + 1e-6)\n\n    for window in [30, 60]:\n        ws = _scale(window, fps)\n        X[f'A_ld{window}'] = A_lead.rolling(ws, min_periods=max(1, ws // 6)).mean()\n        X[f'B_ld{window}'] = B_lead.rolling(ws, min_periods=max(1, ws // 6)).mean()\n\n    approach = -rel_dist.diff()\n    chase = approach * B_lead\n    w = 30\n    ws = _scale(w, fps)\n    X[f'chase_{w}'] = chase.rolling(ws, min_periods=max(1, ws // 6)).mean()\n\n    for window in [60, 120]:\n        ws = _scale(window, fps)\n        A_sp = np.sqrt(A_vx**2 + A_vy**2)\n        B_sp = np.sqrt(B_vx**2 + B_vy**2)\n        X[f'sp_cor{window}'] = A_sp.rolling(ws, min_periods=max(1, ws // 6)).corr(B_sp)\n\n    return X\n\ndef add_relative_heading_features(X, mouse_pair, fps):\n    try:\n        has_A = all(p in mouse_pair['A'].columns.get_level_values(0) for p in ['nose', 'tail_base'])\n        has_B = all(p in mouse_pair['B'].columns.get_level_values(0) for p in ['nose', 'tail_base'])\n        \n        if has_A and has_B:\n            Ax = mouse_pair['A']['nose']['x'] - mouse_pair['A']['tail_base']['x']\n            Ay = mouse_pair['A']['nose']['y'] - mouse_pair['A']['tail_base']['y']\n            Bx = mouse_pair['B']['nose']['x'] - mouse_pair['B']['tail_base']['x']\n            By = mouse_pair['B']['nose']['y'] - mouse_pair['B']['tail_base']['y']\n            \n            ABx = mouse_pair['B']['body_center']['x'] - mouse_pair['A']['body_center']['x']\n            ABy = mouse_pair['B']['body_center']['y'] - mouse_pair['A']['body_center']['y']\n            \n            norm_A = np.sqrt(Ax**2 + Ay**2) + 1e-6\n            norm_B = np.sqrt(Bx**2 + By**2) + 1e-6\n            norm_AB = np.sqrt(ABx**2 + ABy**2) + 1e-6\n            \n            X['heading_alignment'] = (Ax*Bx + Ay*By) / (norm_A * norm_B)\n            \n            X['A_facing_B'] = (Ax*ABx + Ay*ABy) / (norm_A * norm_AB)\n            \n            X['B_facing_A'] = (Bx*(-ABx) + By*(-ABy)) / (norm_B * norm_AB)\n            \n            w = _scale(15, fps)\n            X['heading_alignment_sm'] = X['heading_alignment'].rolling(w, min_periods=1).mean()\n            \n    except Exception:\n        pass\n    return X\n\ndef add_acceleration_features(X, center_x, center_y, fps):\n    try:\n        vx = center_x.diff() * float(fps)\n        vy = center_y.diff() * float(fps)\n        speed = np.sqrt(vx**2 + vy**2)\n        \n        ax = vx.diff() * float(fps)\n        ay = vy.diff() * float(fps)\n        accel = np.sqrt(ax**2 + ay**2)\n        \n        jx = ax.diff() * float(fps)\n        jy = ay.diff() * float(fps)\n        jerk = np.sqrt(jx**2 + jy**2)\n        \n        for w in [15, 30, 60]:\n            ws = _scale(w, fps)\n            X[f'acc_m{w}'] = accel.rolling(ws, min_periods=max(1, ws//4)).mean()\n            X[f'acc_s{w}'] = accel.rolling(ws, min_periods=max(1, ws//4)).std()\n            X[f'jrk_m{w}'] = jerk.rolling(ws, min_periods=max(1, ws//4)).mean()\n        \n        w = _scale(10, fps)\n        X['sudden_acc'] = (accel > accel.rolling(w, min_periods=1).mean() + 2 * accel.rolling(w, min_periods=1).std()).astype(float)\n        \n    except Exception:\n        pass\n    \n    return X\n\ndef add_spatial_features(X, center_x, center_y, fps):\n    try:\n        dist_origin = np.sqrt(center_x**2 + center_y**2)\n        \n        for w in [60, 120]:\n            ws = _scale(w, fps)\n            X[f'orig_m{w}'] = dist_origin.rolling(ws, min_periods=max(1, ws//4)).mean()\n            X[f'orig_s{w}'] = dist_origin.rolling(ws, min_periods=max(1, ws//4)).std()\n        \n        for w in [30, 60, 120]:\n            ws = _scale(w, fps)\n            x_range = center_x.rolling(ws, min_periods=max(1, ws//4)).max() - center_x.rolling(ws, min_periods=max(1, ws//4)).min()\n            y_range = center_y.rolling(ws, min_periods=max(1, ws//4)).max() - center_y.rolling(ws, min_periods=max(1, ws//4)).min()\n            X[f'area{w}'] = x_range * y_range\n        \n        for w in [30, 60]:\n            ws = _scale(w, fps)\n            straight_dist = np.sqrt((center_x - center_x.shift(ws))**2 + (center_y - center_y.shift(ws))**2)\n            path_length = center_x.diff().abs().rolling(ws, min_periods=1).sum() + center_y.diff().abs().rolling(ws, min_periods=1).sum()\n            X[f'eff{w}'] = straight_dist / (path_length + 1e-6)\n            \n    except Exception:\n        pass\n    \n    return X\n\ndef add_frequency_features(X, center_x, center_y, fps):\n    try:\n        speed = np.sqrt(center_x.diff()**2 + center_y.diff()**2) * float(fps)\n        speed_centered = speed - speed.rolling(_scale(30, fps), min_periods=1).mean()\n        \n        for w in [30, 60]:\n            ws = _scale(w, fps)\n            zero_crossings = ((speed_centered.shift(1) * speed_centered) < 0).astype(float)\n            X[f'zcr{w}'] = zero_crossings.rolling(ws, min_periods=1).sum()\n        \n        for w in [20, 40]:\n            ws = _scale(w, fps)\n            X[f'osc_x{w}'] = (center_x.diff().shift(1) * center_x.diff() < 0).astype(float).rolling(ws, min_periods=1).mean()\n            X[f'osc_y{w}'] = (center_y.diff().shift(1) * center_y.diff() < 0).astype(float).rolling(ws, min_periods=1).mean()\n            \n    except Exception:\n        pass\n    \n    return X\n\ndef add_posture_features(X, single_mouse, available_body_parts, fps):\n    try:\n        if all(p in available_body_parts for p in ['nose', 'tail_base', 'ear_left', 'ear_right']):\n            nose_tail = np.sqrt((single_mouse['nose']['x'] - single_mouse['tail_base']['x'])**2 + \n                               (single_mouse['nose']['y'] - single_mouse['tail_base']['y'])**2)\n            ear_dist = np.sqrt((single_mouse['ear_left']['x'] - single_mouse['ear_right']['x'])**2 + \n                              (single_mouse['ear_left']['y'] - single_mouse['ear_right']['y'])**2)\n            X['compact'] = ear_dist / (nose_tail + 1e-6)\n            \n            w = _scale(30, fps)\n            X['compact_var'] = X['compact'].rolling(w, min_periods=1).std()\n        \n        if all(p in available_body_parts for p in ['nose', 'body_center']):\n            head_angle = np.arctan2(\n                single_mouse['nose']['y'] - single_mouse['body_center']['y'],\n                single_mouse['nose']['x'] - single_mouse['body_center']['x']\n            )\n            head_angle_change = np.abs(head_angle.diff())\n            \n            for w in [15, 30]:\n                ws = _scale(w, fps)\n                X[f'head_stb{w}'] = head_angle_change.rolling(ws, min_periods=1).std()\n                \n    except Exception:\n        pass\n    \n    return X\n\ndef add_interaction_distance_bins(X, mouse_pair, avail_A, avail_B, fps):\n    try:\n        if 'body_center' not in avail_A or 'body_center' not in avail_B:\n            return X\n        \n        dist = np.sqrt((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        \n        X['v_cls2'] = (dist < 3.0).astype(float)\n        X['cls2'] = ((dist >= 3.0) & (dist < 8.0)).astype(float)\n        X['med2'] = ((dist >= 8.0) & (dist < 20.0)).astype(float)\n        \n        for w in [30, 60, 120]:\n            ws = _scale(w, fps)\n            X[f't_vcls{w}'] = X['v_cls2'].rolling(ws, min_periods=1).mean()\n            X[f't_cls{w}'] = X['cls2'].rolling(ws, min_periods=1).mean()\n            \n        dist_change = dist.diff() * float(fps)\n        for w in [15, 30]:\n            ws = _scale(w, fps)\n            X[f'd_chg{w}'] = dist_change.rolling(ws, min_periods=1).mean()\n            \n    except Exception:\n        pass\n    \n    return X\n\ndef add_relative_speed_features(X, mouse_pair, avail_A, avail_B, fps):\n    try:\n        if 'body_center' not in avail_A or 'body_center' not in avail_B:\n            return X\n        \n        A_vx = mouse_pair['A']['body_center']['x'].diff() * float(fps)\n        A_vy = mouse_pair['A']['body_center']['y'].diff() * float(fps)\n        B_vx = mouse_pair['B']['body_center']['x'].diff() * float(fps)\n        B_vy = mouse_pair['B']['body_center']['y'].diff() * float(fps)\n        \n        A_speed = np.sqrt(A_vx**2 + A_vy**2)\n        B_speed = np.sqrt(B_vx**2 + B_vy**2)\n        \n        X['sp_ratio'] = A_speed / (B_speed + 1e-6)\n        \n        speed_diff = np.abs(A_speed - B_speed)\n        for w in [30, 60]:\n            ws = _scale(w, fps)\n            X[f'sp_df{w}'] = speed_diff.rolling(ws, min_periods=1).mean()\n        \n        A_angle = np.arctan2(A_vy, A_vx)\n        B_angle = np.arctan2(B_vy, B_vx)\n        angle_diff = np.abs(np.unwrap(A_angle - B_angle))\n        \n        for w in [20, 40]:\n            ws = _scale(w, fps)\n            X[f'ang_df{w}'] = angle_diff.rolling(ws, min_periods=1).mean()\n            X[f'same_dir{w}'] = (angle_diff < np.pi/4).astype(float).rolling(ws, min_periods=1).mean()\n            X[f'opp_dir{w}'] = (angle_diff > 3*np.pi/4).astype(float).rolling(ws, min_periods=1).mean()\n            \n    except Exception:\n        pass\n    \n    return X\n\ndef add_momentum_features(X, center_x, center_y, fps):\n    try:\n        vx = center_x.diff() * float(fps)\n        vy = center_y.diff() * float(fps)\n        speed = np.sqrt(vx**2 + vy**2)\n        \n        for w in [20, 40, 60]:\n            ws = _scale(w, fps)\n            speed_var = speed.rolling(ws, min_periods=max(1, ws//4)).var()\n            X[f'momentum{w}'] = speed * speed_var\n        \n        direction = np.arctan2(vy, vx)\n        direction_change = np.abs(direction.diff())\n        \n        for w in [30, 60, 90]:\n            ws = _scale(w, fps)\n            X[f'persist{w}'] = (direction_change < np.pi/6).astype(float).rolling(ws, min_periods=1).mean()\n            X[f'erratic{w}'] = (direction_change > np.pi/3).astype(float).rolling(ws, min_periods=1).mean()\n        \n        for w in [30, 60]:\n            ws = _scale(w, fps)\n            speed_mean = speed.rolling(ws, min_periods=1).mean()\n            speed_std = speed.rolling(ws, min_periods=1).std()\n            X[f'sp_stab{w}'] = speed_std / (speed_mean + 1e-6)\n            \n    except Exception:\n        pass\n    \n    return X\n\ndef add_rhythmic_features(X, center_x, center_y, fps):\n    try:\n        speed = np.sqrt(center_x.diff()**2 + center_y.diff()**2) * float(fps)\n        \n        for lag in [5, 10, 15, 20]:\n            lag_frames = _scale(lag, fps)\n            speed_lagged = speed.shift(lag_frames)\n            \n            for w in [30, 60]:\n                ws = _scale(w, fps)\n                corr = speed.rolling(ws, min_periods=max(5, ws//4)).corr(speed_lagged)\n                X[f'rhythm_l{lag}_w{w}'] = corr\n        \n        speed_ma = speed.rolling(_scale(5, fps), min_periods=1).mean()\n        is_peak = ((speed_ma > speed_ma.shift(1)) & (speed_ma > speed_ma.shift(-1))).astype(float)\n        \n        for w in [30, 60, 90]:\n            ws = _scale(w, fps)\n            X[f'peaks{w}'] = is_peak.rolling(ws, min_periods=1).sum()\n            \n    except Exception:\n        pass\n    \n    return X\n\ndef add_posture_dynamics(X, single_mouse, available_body_parts, fps):\n    try:\n        if all(p in available_body_parts for p in ['nose', 'tail_base']):\n            body_length = np.sqrt((single_mouse['nose']['x'] - single_mouse['tail_base']['x'])**2 +\n                                 (single_mouse['nose']['y'] - single_mouse['tail_base']['y'])**2)\n            \n            for w in [15, 30, 60]:\n                ws = _scale(w, fps)\n                X[f'len_m{w}'] = body_length.rolling(ws, min_periods=1).mean()\n                X[f'len_s{w}'] = body_length.rolling(ws, min_periods=1).std()\n                \n            len_change = body_length.diff() * float(fps)\n            for w in [15, 30]:\n                ws = _scale(w, fps)\n                X[f'len_chg{w}'] = len_change.rolling(ws, min_periods=1).mean()\n        \n        if all(p in available_body_parts for p in ['nose', 'body_center', 'tail_base']):\n            if 'body_ang' in X.columns:\n                for w in [20, 40]:\n                    ws = _scale(w, fps)\n                    X[f'ang_m{w}'] = X['body_ang'].rolling(ws, min_periods=1).mean()\n                    X[f'ang_s{w}'] = X['body_ang'].rolling(ws, min_periods=1).std()\n\n        if all(p in available_body_parts for p in ['ear_left', 'ear_right', 'nose']):\n            left_nose = np.sqrt((single_mouse['ear_left']['x'] - single_mouse['nose']['x'])**2 +\n                               (single_mouse['ear_left']['y'] - single_mouse['nose']['y'])**2)\n            right_nose = np.sqrt((single_mouse['ear_right']['x'] - single_mouse['nose']['x'])**2 +\n                                (single_mouse['ear_right']['y'] - single_mouse['nose']['y'])**2)\n            \n            ear_asym = np.abs(left_nose - right_nose)\n            for w in [15, 30]:\n                ws = _scale(w, fps)\n                X[f'ear_asy{w}'] = ear_asym.rolling(ws, min_periods=1).mean()\n                \n    except Exception:\n        pass\n    \n    return X\n\ndef add_territorial_features(X, center_x, center_y, fps):\n    try:\n        x_median = center_x.rolling(_scale(300, fps), min_periods=30).median()\n        y_median = center_y.rolling(_scale(300, fps), min_periods=30).median()\n        \n        for w in [60, 120, 240]:\n            ws = _scale(w, fps)\n            in_right = (center_x > x_median).astype(float)\n            in_top = (center_y > y_median).astype(float)\n            \n            X[f'quad_x{w}'] = in_right.rolling(ws, min_periods=1).mean()\n            X[f'quad_y{w}'] = in_top.rolling(ws, min_periods=1).mean()\n            \n            quad_change_x = (in_right != in_right.shift(1)).astype(float)\n            quad_change_y = (in_top != in_top.shift(1)).astype(float)\n            X[f'explr_x{w}'] = quad_change_x.rolling(ws, min_periods=1).sum()\n            X[f'explr_y{w}'] = quad_change_y.rolling(ws, min_periods=1).sum()\n        \n        for w in [120, 240]:\n            ws = _scale(w, fps)\n            cx_min = center_x.rolling(ws, min_periods=1).min()\n            cx_max = center_x.rolling(ws, min_periods=1).max()\n            cy_min = center_y.rolling(ws, min_periods=1).min()\n            cy_max = center_y.rolling(ws, min_periods=1).max()\n\n            X[f'coverage{w}'] = (cx_max - cx_min) * (cy_max - cy_min)\n            \n    except Exception:\n        pass\n    \n    return X\n\ndef add_interaction_patterns(X, mouse_pair, avail_A, avail_B, fps):\n    try:\n        if 'body_center' not in avail_A or 'body_center' not in avail_B:\n            return X\n        \n        dist = np.sqrt((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\n        dist_change = dist.diff()\n        for w in [20, 40]:\n            ws = _scale(w, fps)\n            osc = ((dist_change.shift(1) * dist_change) < 0).astype(float)\n            X[f'dist_osc{w}'] = osc.rolling(ws, min_periods=1).mean()\n        \n        A_vx = mouse_pair['A']['body_center']['x'].diff() * float(fps)\n        A_vy = mouse_pair['A']['body_center']['y'].diff() * float(fps)\n        B_vx = mouse_pair['B']['body_center']['x'].diff() * float(fps)\n        B_vy = mouse_pair['B']['body_center']['y'].diff() * float(fps)\n        \n        A_speed = np.sqrt(A_vx**2 + A_vy**2)\n        B_speed = np.sqrt(B_vx**2 + B_vy**2)\n        \n        to_B_x = mouse_pair['B']['body_center']['x'] - mouse_pair['A']['body_center']['x']\n        to_B_y = mouse_pair['B']['body_center']['y'] - mouse_pair['A']['body_center']['y']\n        to_B_dist = np.sqrt(to_B_x**2 + to_B_y**2) + 1e-6\n        \n        A_toward_B = (A_vx * to_B_x + A_vy * to_B_y) / (A_speed * to_B_dist + 1e-6)\n        B_toward_A = (-B_vx * to_B_x - B_vy * to_B_y) / (B_speed * to_B_dist + 1e-6)\n        \n        for w in [20, 40, 60]:\n            ws = _scale(w, fps)\n            X[f'A_to_B{w}'] = A_toward_B.rolling(ws, min_periods=1).mean()\n            X[f'B_to_A{w}'] = B_toward_A.rolling(ws, min_periods=1).mean()\n\n            both_approach = ((A_toward_B > 0.5) & (B_toward_A > 0.5)).astype(float)\n            X[f'mutual{w}'] = both_approach.rolling(ws, min_periods=1).mean()\n            \n            A_chase = ((A_toward_B > 0.5) & (B_toward_A < -0.5) & (A_speed > B_speed)).astype(float)\n            X[f'A_chase{w}'] = A_chase.rolling(ws, min_periods=1).mean()\n        \n        if all(p in avail_A for p in ['nose', 'tail_base']) and all(p in avail_B for p in ['nose', 'tail_base']):\n            A_orient = np.arctan2(\n                mouse_pair['A']['nose']['y'] - mouse_pair['A']['tail_base']['y'],\n                mouse_pair['A']['nose']['x'] - mouse_pair['A']['tail_base']['x']\n            )\n            B_orient = np.arctan2(\n                mouse_pair['B']['nose']['y'] - mouse_pair['B']['tail_base']['y'],\n                mouse_pair['B']['nose']['x'] - mouse_pair['B']['tail_base']['x']\n            )\n            \n            orient_diff = np.abs(np.unwrap(A_orient - B_orient))\n            for w in [30, 60]:\n                ws = _scale(w, fps)\n                X[f'parallel{w}'] = (orient_diff < np.pi/4).astype(float).rolling(ws, min_periods=1).mean()\n                X[f'opposite{w}'] = (np.abs(orient_diff - np.pi) < np.pi/4).astype(float).rolling(ws, min_periods=1).mean()\n        \n        is_contact = (dist < 5.0).astype(float)\n        contact_duration = pd.Series(0, index=is_contact.index, dtype=float)\n        current_duration = 0\n        for i in range(len(is_contact)):\n            if is_contact.iloc[i] > 0:\n                current_duration += 1\n                contact_duration.iloc[i] = current_duration\n            else:\n                current_duration = 0\n        X['contact_dur'] = contact_duration\n        \n    except Exception:\n        pass\n    \n    return X\n\ndef add_body_part_velocities(X, single_mouse, available_body_parts, fps):\n    try:\n        parts_of_interest = ['nose', 'ear_left', 'ear_right', 'body_center', 'tail_base']\n        \n        for part in parts_of_interest:\n            if part in available_body_parts:\n                vx = single_mouse[part]['x'].diff() * float(fps)\n                vy = single_mouse[part]['y'].diff() * float(fps)\n                v = np.sqrt(vx**2 + vy**2)\n                \n                for w in [10, 20, 40]:\n                    ws = _scale(w, fps)\n                    X[f'{part}_v{w}'] = v.rolling(ws, min_periods=1).mean()\n        \n        if all(p in available_body_parts for p in ['nose', 'tail_base']):\n            nose_v = np.sqrt(single_mouse['nose']['x'].diff()**2 + single_mouse['nose']['y'].diff()**2) * float(fps)\n            tail_v = np.sqrt(single_mouse['tail_base']['x'].diff()**2 + single_mouse['tail_base']['y'].diff()**2) * float(fps)\n            \n            for w in [15, 30]:\n                ws = _scale(w, fps)\n                X[f'nose_tail_vr{w}'] = (nose_v - tail_v).rolling(ws, min_periods=1).mean()\n                \n    except Exception:\n        pass\n    \n    return X\n\ndef add_statistical_moments(X, center_x, center_y, fps):\n    try:\n        speed = np.sqrt(center_x.diff()**2 + center_y.diff()**2) * float(fps)\n        \n        for w in [60, 120]:\n            ws = _scale(w, fps)\n            if len(speed) >= ws:\n                speed_mean = speed.rolling(ws, min_periods=max(5, ws//4)).mean()\n                speed_std = speed.rolling(ws, min_periods=max(5, ws//4)).std()\n                speed_centered = speed - speed_mean\n                X[f'skew{w}'] = (speed_centered**3).rolling(ws, min_periods=max(5, ws//4)).mean() / (speed_std**3 + 1e-6)\n                X[f'kurt{w}'] = (speed_centered**4).rolling(ws, min_periods=max(5, ws//4)).mean() / (speed_std**4 + 1e-6)\n        \n        for w in [60, 120]:\n            ws = _scale(w, fps)\n            speed_binned = pd.cut(speed, bins=5, labels=False)\n            X[f'complex{w}'] = speed_binned.rolling(ws, min_periods=1).apply(lambda x: len(np.unique(x[~np.isnan(x)])), raw=False)\n            \n    except Exception:\n        pass\n    \n    return X\n\ndef add_temporal_context_features(X, center_x, center_y, fps):\n    try:\n        speed = np.sqrt(center_x.diff()**2 + center_y.diff()**2) * float(fps)\n        \n        for w in [15, 30, 60]:\n            ws = _scale(w, fps)\n            speed_past = speed.shift(ws).rolling(ws, min_periods=1).mean()\n            speed_future = speed.shift(-ws).rolling(ws, min_periods=1).mean()\n            X[f'sp_fut_past_{w}'] = speed_future / (speed_past + 1e-6)\n            X[f'sp_trend_{w}'] = (speed_future - speed_past) / (speed_past + 1e-6)\n        \n        for w in [45, 90]:\n            ws = _scale(w, fps)\n            speed_ma = speed.rolling(ws, min_periods=1, center=True).mean()\n            X[f'phase_accel_{w}'] = (speed > speed_ma).astype(float)\n            X[f'phase_stable_{w}'] = (np.abs(speed - speed_ma) < 0.5 * speed_ma).astype(float)\n            \n    except Exception:\n        pass\n        \n    return X\n\ndef add_body_part_triangulation(X, single_mouse, available_body_parts, fps):\n    try:\n        if all(p in available_body_parts for p in ['nose', 'ear_left', 'ear_right']):\n            x1, y1 = single_mouse['nose']['x'], single_mouse['nose']['y']\n            x2, y2 = single_mouse['ear_left']['x'], single_mouse['ear_left']['y']\n            x3, y3 = single_mouse['ear_right']['x'], single_mouse['ear_right']['y']\n            \n            head_area = 0.5 * np.abs(x1*(y2-y3) + x2*(y3-y1) + x3*(y1-y2))\n            \n            for w in [20, 40]:\n                ws = _scale(w, fps)\n                X[f'head_area_m{w}'] = head_area.rolling(ws, min_periods=1).mean()\n                X[f'head_area_s{w}'] = head_area.rolling(ws, min_periods=1).std()\n        \n        if all(p in available_body_parts for p in ['nose', 'body_center', 'tail_base']):\n            front_half = np.sqrt((single_mouse['nose']['x'] - single_mouse['body_center']['x'])**2 +\n                                (single_mouse['nose']['y'] - single_mouse['body_center']['y'])**2)\n            back_half = np.sqrt((single_mouse['body_center']['x'] - single_mouse['tail_base']['x'])**2 +\n                               (single_mouse['body_center']['y'] - single_mouse['tail_base']['y'])**2)\n            \n            X['body_balance'] = front_half / (back_half + 1e-6)\n            \n            for w in [20, 40]:\n                ws = _scale(w, fps)\n                X[f'balance_var_{w}'] = X['body_balance'].rolling(ws, min_periods=1).std()\n                \n    except Exception:\n        pass\n        \n    return X\n\ndef add_spatial_dominance_features(X, mouse_pair, avail_A, avail_B, fps):\n    try:\n        if 'body_center' not in avail_A or 'body_center' not in avail_B:\n            return X\n        \n        mid_x = (mouse_pair['A']['body_center']['x'] + mouse_pair['B']['body_center']['x']) / 2\n        mid_y = (mouse_pair['A']['body_center']['y'] + mouse_pair['B']['body_center']['y']) / 2\n        \n        A_to_mid = np.sqrt((mouse_pair['A']['body_center']['x'] - mid_x)**2 +\n                          (mouse_pair['A']['body_center']['y'] - mid_y)**2)\n        B_to_mid = np.sqrt((mouse_pair['B']['body_center']['x'] - mid_x)**2 +\n                          (mouse_pair['B']['body_center']['y'] - mid_y)**2)\n        \n        for w in [30, 60]:\n            ws = _scale(w, fps)\n            X[f'space_dom_{w}'] = (A_to_mid < B_to_mid).astype(float).rolling(ws, min_periods=1).mean()\n\n        A_vx = mouse_pair['A']['body_center']['x'].diff()\n        A_vy = mouse_pair['A']['body_center']['y'].diff()\n        B_vx = mouse_pair['B']['body_center']['x'].diff()\n        B_vy = mouse_pair['B']['body_center']['y'].diff()\n        \n        vel_cross = A_vx * B_vy - A_vy * B_vx\n        vel_dot = A_vx * B_vx + A_vy * B_vy\n        \n        for w in [20, 40]:\n            ws = _scale(w, fps)\n            X[f'perpendicular_{w}'] = np.abs(vel_cross).rolling(ws, min_periods=1).mean()\n            X[f'parallel_{w}'] = np.abs(vel_dot).rolling(ws, min_periods=1).mean()\n            X[f'block_ratio_{w}'] = (np.abs(vel_cross) / (np.abs(vel_dot) + 1e-6)).rolling(ws, min_periods=1).mean()\n            \n    except Exception:\n        pass\n        \n    return X\n\ndef add_repetitive_pattern_features(X, center_x, center_y, fps):\n    try:\n        speed = np.sqrt(center_x.diff()**2 + center_y.diff()**2) * float(fps)\n        \n        for period in [10, 20, 30, 40]:\n            period_frames = _scale(period, fps)\n            speed_lagged = speed.shift(period_frames)\n            \n            for w in [60, 120]:\n                ws = _scale(w, fps)\n                corr = speed.rolling(ws, min_periods=max(5, ws//4)).corr(speed_lagged)\n                X[f'cycle_{period}_{w}'] = corr\n        \n        for w in [60, 120, 180]:\n            ws = _scale(w, fps)\n            start_x = center_x.shift(ws)\n            start_y = center_y.shift(ws)\n            \n            return_dist = np.sqrt((center_x - start_x)**2 + (center_y - start_y)**2)\n            path_length = np.sqrt(center_x.diff()**2 + center_y.diff()**2).rolling(ws, min_periods=1).sum()\n            \n            X[f'circularity_{w}'] = return_dist / (path_length + 1e-6)\n            \n    except Exception:\n        pass\n        \n    return X\n\ndef add_energy_features(X, center_x, center_y, fps):\n    try:\n        vx = center_x.diff() * float(fps)\n        vy = center_y.diff() * float(fps)\n        kinetic = vx**2 + vy**2\n        \n        for w in [30, 60, 90]:\n            ws = _scale(w, fps)\n            energy_change = kinetic.diff()\n            X[f'energy_loss_{w}'] = (energy_change < 0).astype(float).rolling(ws, min_periods=1).mean()\n            X[f'energy_gain_{w}'] = (energy_change > 0).astype(float).rolling(ws, min_periods=1).mean()\n\n            X[f'energy_smooth_{w}'] = kinetic.rolling(ws, min_periods=1).std() / (kinetic.rolling(ws, min_periods=1).mean() + 1e-6)\n        \n    except Exception:\n        pass\n        \n    return X\n\ndef add_attention_features(X, mouse_pair, avail_A, avail_B, fps):\n    try:\n        if not all(p in avail_A for p in ['nose', 'tail_base']) or \\\n           not all(p in avail_B for p in ['nose', 'tail_base']):\n            return X\n        \n        A_dir_x = mouse_pair['A']['nose']['x'] - mouse_pair['A']['tail_base']['x']\n        A_dir_y = mouse_pair['A']['nose']['y'] - mouse_pair['A']['tail_base']['y']\n        \n        to_B_x = mouse_pair['B']['body_center']['x'] - mouse_pair['A']['body_center']['x']\n        to_B_y = mouse_pair['B']['body_center']['y'] - mouse_pair['A']['body_center']['y']\n        \n        facing_score = (A_dir_x * to_B_x + A_dir_y * to_B_y) / \\\n                      (np.sqrt(A_dir_x**2 + A_dir_y**2) * np.sqrt(to_B_x**2 + to_B_y**2) + 1e-6)\n        \n        is_facing = (facing_score > 0.7).astype(float)\n        \n        facing_duration = pd.Series(0, index=is_facing.index, dtype=float)\n        current_dur = 0\n        for i in range(len(is_facing)):\n            if is_facing.iloc[i] > 0:\n                current_dur += 1\n                facing_duration.iloc[i] = current_dur\n            else:\n                current_dur = 0\n        \n        X['attention_dur'] = facing_duration\n        \n        for w in [30, 60]:\n            ws = _scale(w, fps)\n            X[f'attention_pct_{w}'] = is_facing.rolling(ws, min_periods=1).mean()\n            X[f'attention_max_{w}'] = facing_duration.rolling(ws, min_periods=1).max()\n        \n    except Exception:\n        pass\n        \n    return X\n\ndef add_behavioral_grammar_features(X, center_x, center_y, fps):\n    try:\n        speed = np.sqrt(center_x.diff()**2 + center_y.diff()**2) * float(fps)\n        speed_tokens = pd.cut(speed, bins=[-np.inf, 1, 3, 7, np.inf], labels=[0, 1, 2, 3])\n\n        for window in [45, 90]:\n            ws = _scale(window, fps)\n            if len(speed_tokens) >= ws:\n                transitions = (speed_tokens.diff() != 0).astype(float)\n                X[f'transition_rate_{window}'] = transitions.rolling(ws, min_periods=ws//4).mean()\n                \n                def pattern_score(tokens):\n                    if len(tokens) < 10:\n                        return 0\n                    unique_ratio = len(set(tokens)) / len(tokens)\n                    return 1 - unique_ratio\n                \n                X[f'pattern_reg_{window}'] = speed_tokens.rolling(ws, min_periods=ws//3).apply(\n                    lambda x: pattern_score(x.values), raw=False\n                )\n                \n                if window == 45:\n                    rest_to_fast = 0\n                    fast_to_rest = 0\n                    for i in range(len(speed_tokens)-1):\n                        if speed_tokens.iloc[i] == 0 and speed_tokens.iloc[i+1] == 3:\n                            rest_to_fast += 1\n                        elif speed_tokens.iloc[i] == 3 and speed_tokens.iloc[i+1] == 0:\n                            fast_to_rest += 1\n                    X['sudden_start'] = rest_to_fast / len(speed_tokens)\n                    X['sudden_stop'] = fast_to_rest / len(speed_tokens)\n                \n    except Exception:\n        pass\n        \n    return X\n\ndef add_social_contagion_features(X, mouse_pair, avail_A, avail_B, fps):\n    try:\n        if 'body_center' not in avail_A or 'body_center' not in avail_B:\n            return X\n        \n        A_vx = mouse_pair['A']['body_center']['x'].diff() * float(fps)\n        A_vy = mouse_pair['A']['body_center']['y'].diff() * float(fps)\n        B_vx = mouse_pair['B']['body_center']['x'].diff() * float(fps)\n        B_vy = mouse_pair['B']['body_center']['y'].diff() * float(fps)\n        A_speed = np.sqrt(A_vx**2 + A_vy**2)\n        B_speed = np.sqrt(B_vx**2 + B_vy**2)\n        \n        for lag in [5, 10, 20]:\n            lag_frames = _scale(lag, fps)\n            ws = _scale(45, fps)\n            \n            if len(A_speed) >= ws + lag_frames:\n                A_lead_corr = A_speed.rolling(ws, min_periods=ws//3).corr(\n                    B_speed.shift(-lag_frames)\n                )\n                X[f'A_leads_B_{lag}'] = A_lead_corr\n                \n                B_lead_corr = B_speed.rolling(ws, min_periods=ws//3).corr(\n                    A_speed.shift(-lag_frames)\n                )\n                X[f'B_leads_A_{lag}'] = B_lead_corr\n                \n                X[f'mutual_inf_{lag}'] = A_lead_corr * B_lead_corr\n        \n        A_active = (A_speed > A_speed.quantile(0.7)).astype(float)\n        B_active = (B_speed > B_speed.quantile(0.7)).astype(float)\n        ws = _scale(40, fps)\n        \n        A_then_B = pd.Series(0.0, index=A_active.index)\n        for i in range(5, len(A_active)):\n            if A_active.iloc[i-5:i].sum() > 0 and B_active.iloc[i] > 0:\n                A_then_B.iloc[i] = 1.0\n        X['contagion_A_to_B'] = A_then_B.rolling(ws, min_periods=ws//3).mean()\n        \n        B_then_A = pd.Series(0.0, index=B_active.index)\n        for i in range(5, len(B_active)):\n            if B_active.iloc[i-5:i].sum() > 0 and A_active.iloc[i] > 0:\n                B_then_A.iloc[i] = 1.0\n        X['contagion_B_to_A'] = B_then_A.rolling(ws, min_periods=ws//3).mean()\n            \n    except Exception:\n        pass\n        \n    return X\n\ndef add_decision_point_features(X, center_x, center_y, fps):\n    try:\n        speed = np.sqrt(center_x.diff()**2 + center_y.diff()**2) * float(fps)\n        direction = np.arctan2(center_y.diff(), center_x.diff())\n        \n        speed_change = np.abs(speed.diff())\n        direction_change = np.abs(direction.diff())\n        \n        speed_change_norm = (speed_change - speed_change.rolling(60, min_periods=1).mean()) / (speed_change.rolling(60, min_periods=1).std() + 1e-6)\n        direction_change_norm = (direction_change - direction_change.rolling(60, min_periods=1).mean()) / (direction_change.rolling(60, min_periods=1).std() + 1e-6)\n        \n        decision_score = speed_change_norm * direction_change_norm\n        is_decision = (decision_score > 1.5).astype(float)\n        \n        for window in [15, 30, 60, 120]:\n            ws = _scale(window, fps)\n            X[f'decision_freq_{window}'] = is_decision.rolling(ws, min_periods=1).sum()\n            time_since_decision = pd.Series(0, index=is_decision.index)\n            counter = 0\n            \n            for i in range(len(is_decision)):\n                if is_decision.iloc[i] > 0:\n                    counter = 0\n                else:\n                    counter += 1\n                time_since_decision.iloc[i] = counter\n                \n            X[f'time_since_decision_{window}'] = time_since_decision\n            X[f'predictability_{window}'] = 1.0 / (X[f'decision_freq_{window}'] + 1)\n        \n        for window in [20, 40]:\n            ws = _scale(window, fps)\n            decision_density = is_decision.rolling(ws, min_periods=1).sum()\n            X[f'decision_burst_{window}'] = (decision_density > ws * 0.3).astype(float)\n            \n    except Exception:\n        pass\n        \n    return X\n\ndef add_attention_field_features(X, mouse_pair, avail_A, avail_B, fps):\n    try:\n        if not all(p in avail_A for p in ['nose', 'ear_left', 'ear_right', 'body_center']):\n            return X\n        if not all(p in avail_B for p in ['nose', 'body_center']):\n            return X\n\n        A_ear_center_x = (mouse_pair['A']['ear_left']['x'] + mouse_pair['A']['ear_right']['x']) / 2\n        A_ear_center_y = (mouse_pair['A']['ear_left']['y'] + mouse_pair['A']['ear_right']['y']) / 2\n        \n        gaze_dir_x = mouse_pair['A']['nose']['x'] - A_ear_center_x\n        gaze_dir_y = mouse_pair['A']['nose']['y'] - A_ear_center_y\n        gaze_length = np.sqrt(gaze_dir_x**2 + gaze_dir_y**2)\n        \n        attention_field_length = 15.0\n        attention_x = mouse_pair['A']['nose']['x'] + (gaze_dir_x / gaze_length) * attention_field_length\n        attention_y = mouse_pair['A']['nose']['y'] + (gaze_dir_y / gaze_length) * attention_field_length\n\n        dist_to_attention = np.sqrt(\n            (mouse_pair['B']['body_center']['x'] - attention_x)**2 +\n            (mouse_pair['B']['body_center']['y'] - attention_y)**2\n        )\n        \n        attention_radius = 10.0\n        B_in_attention = (dist_to_attention < attention_radius).astype(float)\n        \n        for window in [15, 30, 60]:\n            ws = _scale(window, fps)\n            X[f'B_in_attention_field_{window}'] = B_in_attention.rolling(ws, min_periods=1).mean()\n            \n            attention_runs = []\n            current_run = 0\n            for val in B_in_attention:\n                if val > 0:\n                    current_run += 1\n                else:\n                    attention_runs.append(current_run)\n                    current_run = 0\n            \n            X[f'max_attention_duration_{window}'] = pd.Series(\n                B_in_attention.rolling(ws, min_periods=1).apply(\n                    lambda x: np.max(np.diff(np.concatenate(([0], np.where(x == 0)[0], [len(x)]))))\n                    if np.any(x > 0) else 0,\n                    raw=True\n                ),\n                index=X.index\n            )\n        \n        if all(p in avail_B for p in ['nose', 'ear_left', 'ear_right']):\n            B_ear_center_x = (mouse_pair['B']['ear_left']['x'] + mouse_pair['B']['ear_right']['x']) / 2\n            B_ear_center_y = (mouse_pair['B']['ear_left']['y'] + mouse_pair['B']['ear_right']['y']) / 2\n            \n            B_gaze_dir_x = mouse_pair['B']['nose']['x'] - B_ear_center_x\n            B_gaze_dir_y = mouse_pair['B']['nose']['y'] - B_ear_center_y\n            \n            A_to_B_x = mouse_pair['B']['body_center']['x'] - mouse_pair['A']['nose']['x']\n            A_to_B_y = mouse_pair['B']['body_center']['y'] - mouse_pair['A']['nose']['y']\n            \n            A_gaze_align = (gaze_dir_x * A_to_B_x + gaze_dir_y * A_to_B_y) / (gaze_length * np.sqrt(A_to_B_x**2 + A_to_B_y**2) + 1e-6)\n            \n            B_to_A_x = -A_to_B_x\n            B_to_A_y = -A_to_B_y\n            B_gaze_length = np.sqrt(B_gaze_dir_x**2 + B_gaze_dir_y**2)\n            B_gaze_align = (B_gaze_dir_x * B_to_A_x + B_gaze_dir_y * B_to_A_y) / (B_gaze_length * np.sqrt(B_to_A_x**2 + B_to_A_y**2) + 1e-6)\n            \n            mutual_gaze = ((A_gaze_align > 0.7) & (B_gaze_align > 0.7)).astype(float)\n            \n            for window in [20, 40]:\n                ws = _scale(window, fps)\n                X[f'mutual_gaze_{window}'] = mutual_gaze.rolling(ws, min_periods=1).mean()\n                \n    except Exception:\n        pass\n        \n    return X\n\ndef add_bout_structure_features(X, center_x, center_y, fps):\n    try:\n        speed = np.sqrt(center_x.diff()**2 + center_y.diff()**2) * float(fps)\n        activity_threshold = speed.quantile(0.6)\n        is_active = (speed > activity_threshold).astype(float)\n\n        bout_starts = []\n        bout_ends = []\n        in_bout = False\n        \n        for i in range(len(is_active)):\n            if is_active.iloc[i] > 0 and not in_bout:\n                bout_starts.append(i)\n                in_bout = True\n            elif is_active.iloc[i] == 0 and in_bout:\n                bout_ends.append(i)\n                in_bout = False\n        \n        if in_bout:\n            bout_ends.append(len(is_active))\n        \n        bout_durations = pd.Series(0, index=speed.index)\n        for start, end in zip(bout_starts, bout_ends):\n            duration = end - start\n            for k in range(start, min(end, len(bout_durations))):\n                bout_durations.iloc[k] = duration\n        \n        for window in [90, 180]:\n            ws = _scale(window, fps)\n            X[f'bout_duration_{window}'] = bout_durations.rolling(ws, min_periods=ws//4).mean()\n            \n            bout_transitions = (is_active.diff().abs() > 0).astype(float)\n            X[f'bout_freq_{window}'] = bout_transitions.rolling(ws, min_periods=ws//4).sum()\n            X[f'bout_density_{window}'] = is_active.rolling(ws, min_periods=ws//4).mean()\n            \n    except Exception:\n        pass\n        \n    return X\n\ndef add_graph_topology_features(X, mouse_pair, avail_A, avail_B, fps):\n    try:\n        if 'body_center' not in avail_A or 'body_center' not in avail_B:\n            return X\n        \n        dist = 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        )\n        \n        edge_strength = 1.0 / (dist + 1)\n\n        for window in [40, 90]:\n            ws = _scale(window, fps)\n            connection_threshold = 15.0\n            is_connected = (dist < connection_threshold).astype(float)\n            \n            X[f'graph_density_{window}'] = is_connected.rolling(ws, min_periods=ws//4).mean()\n            X[f'edge_weight_{window}'] = edge_strength.rolling(ws, min_periods=ws//4).mean()\n            \n            edge_changes = (is_connected.diff() != 0).astype(float)\n            X[f'edge_stability_{window}'] = 1.0 - edge_changes.rolling(ws, min_periods=ws//4).mean()\n        \n    except Exception:\n        pass\n        \n    return X\n\ndef add_speed_asymmetry_future_past_single(\n    X: pd.DataFrame, cx: pd.Series, cy: pd.Series, fps: float,\n    horizon_base: int = 30, agg: str = \"mean\"\n) -> pd.DataFrame:\n    w = max(3, _scale(horizon_base, fps))\n    v = np.sqrt(cx.diff()**2 + cy.diff()**2) * float(fps)\n    \n    if agg == \"median\":\n        v_past = v.rolling(w, min_periods=max(3, w//4), center=False).median()\n        v_fut = v.iloc[::-1].rolling(w, min_periods=max(3, w//4)).median().iloc[::-1]\n    else:\n        v_past = v.rolling(w, min_periods=max(3, w//4), center=False).mean()\n        v_fut = v.iloc[::-1].rolling(w, min_periods=max(3, w//4)).mean().iloc[::-1]\n    \n    X[\"spd_asym_1s\"] = (v_fut - v_past).fillna(0.0)\n    \n    return X\n\ndef add_gauss_shift_speed_future_past_single(\n    X: pd.DataFrame, cx: pd.Series, cy: pd.Series, fps: float,\n    window_base: int = 30, eps: float = 1e-6\n) -> pd.DataFrame:\n    w = max(5, _scale(window_base, fps))\n    v = np.sqrt(cx.diff()**2 + cy.diff()**2) * float(fps)\n\n    mu_p = v.rolling(w, min_periods=max(3, w//4)).mean()\n    va_p = v.rolling(w, min_periods=max(3, w//4)).var().clip(lower=eps)\n\n    mu_f = v.iloc[::-1].rolling(w, min_periods=max(3, w//4)).mean().iloc[::-1]\n    va_f = v.iloc[::-1].rolling(w, min_periods=max(3, w//4)).var().iloc[::-1].clip(lower=eps)\n\n    kl_pf = 0.5 * ((va_p/va_f) + ((mu_f - mu_p)**2)/va_f - 1.0 + np.log(va_f/va_p))\n    kl_fp = 0.5 * ((va_f/va_p) + ((mu_p - mu_f)**2)/va_p - 1.0 + np.log(va_p/va_f))\n    X[\"spd_symkl_1s\"] = (kl_pf + kl_fp).replace([np.inf, -np.inf], np.nan).fillna(0.0)\n    \n    return X\n\ndef add_cumulative_distance_single(X, cx, cy, fps, horizon_frames_base: int = 180, colname: str = \"path_cum180\"):\n    L = max(1, _scale(horizon_frames_base, fps))\n    step = np.hypot(cx.diff(), cy.diff())\n    path = step.rolling(2*L + 1, min_periods=max(5, L//6), center=True).sum()\n    X[colname] = path.fillna(0.0).astype(np.float32)\n    \n    return X\n\ndef smooth_coordinates(df, fps, sigma_sec=0.05):\n    sigma = sigma_sec * fps\n    df_smooth = df.copy()\n    for col in df.columns:\n        if col in ['x', 'y'] or (isinstance(col, tuple) and col[1] in ['x', 'y']):\n            df_smooth[col] = gaussian_filter1d(df[col].fillna(method='ffill').fillna(0), sigma=sigma)\n    return df_smooth\n\n#============= TRANSFORM SINGLE =============#\ndef transform_single(single_mouse, body_parts_tracked, fps):\n    single_mouse = smooth_coordinates(single_mouse, fps)\n    available_body_parts = single_mouse.columns.get_level_values(0)\n\n    X = pd.DataFrame({\n        f\"{p1}+{p2}\": np.square(single_mouse[p1] - single_mouse[p2]).sum(axis=1, skipna=False)\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 = X.reindex(columns=[f\"{p1}+{p2}\" for p1, p2 in itertools.combinations(body_parts_tracked, 2)], copy=False)\n\n    if all(p in single_mouse.columns for p in ['ear_left', 'ear_right', 'tail_base']):\n        lag = _scale(10, fps)\n        shifted = single_mouse[['ear_left', 'ear_right', 'tail_base']].shift(lag)\n        speeds = pd.DataFrame({\n            'sp_lf': np.square(single_mouse['ear_left'] - shifted['ear_left']).sum(axis=1, skipna=False),\n            'sp_rt': np.square(single_mouse['ear_right'] - shifted['ear_right']).sum(axis=1, skipna=False),\n            'sp_lf2': np.square(single_mouse['ear_left'] - shifted['tail_base']).sum(axis=1, skipna=False),\n            'sp_rt2': np.square(single_mouse['ear_right'] - shifted['tail_base']).sum(axis=1, skipna=False),\n        })\n        X = pd.concat([X, speeds], axis=1)\n\n    if 'nose+tail_base' in X.columns and 'ear_left+ear_right' in X.columns:\n        X['elong'] = X['nose+tail_base'] / (X['ear_left+ear_right'] + 1e-6)\n\n    if all(p in available_body_parts for p in ['nose', 'body_center', 'tail_base']):\n        v1 = single_mouse['nose'] - single_mouse['body_center']\n        v2 = single_mouse['tail_base'] - single_mouse['body_center']\n        X['body_ang'] = (v1['x'] * v2['x'] + v1['y'] * v2['y']) / (\n            np.sqrt(v1['x']**2 + v1['y']**2) * np.sqrt(v2['x']**2 + v2['y']**2) + 1e-6)\n\n        angle = np.arctan2(v1['y'], v1['x'])\n        body_ang = np.arctan2(v2['y'], v2['x'])\n        X['body_ang_diff'] = np.unwrap(angle - body_ang)\n\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 [10, 30, 90]:\n            ws = _scale(w, fps)\n            roll = dict(min_periods=1, center=True)\n            X[f'cx_m{w}'] = cx.rolling(ws, **roll).mean()\n            X[f'cy_m{w}'] = cy.rolling(ws, **roll).mean()\n            X[f'cx_s{w}'] = cx.rolling(ws, **roll).std()\n            X[f'cy_s{w}'] = cy.rolling(ws, **roll).std()\n            X[f'x_rng{w}'] = cx.rolling(ws, **roll).max() - cx.rolling(ws, **roll).min()\n            X[f'y_rng{w}'] = cy.rolling(ws, **roll).max() - cy.rolling(ws, **roll).min()\n            X[f'disp{w}'] = np.sqrt(cx.diff().rolling(ws, min_periods=1).sum()**2 +\n                                     cy.diff().rolling(ws, min_periods=1).sum()**2)\n            X[f'act{w}'] = np.sqrt(cx.diff().rolling(ws, min_periods=1).var() +\n                                   cy.diff().rolling(ws, min_periods=1).var())\n\n        X = add_curvature_features(X, cx, cy, fps)\n        X = add_multiscale_features(X, cx, cy, fps)\n        X = add_state_features(X, cx, cy, fps)\n        X = add_longrange_features(X, cx, cy, fps)\n\n        lag_180 = _scale(180, fps)\n        if len(cx) >= lag_180:\n            long_disp = np.sqrt((cx - cx.shift(lag_180))**2 + (cy - cy.shift(lag_180))**2)\n            X['longdist_bin1'] = (long_disp > 20.0).astype(float)\n            \n            speed_180 = np.sqrt(cx.diff()**2 + cy.diff()**2) * float(fps)\n            X['longdist_bin2'] = (speed_180.rolling(lag_180, min_periods=max(5, lag_180 // 6)).mean() > 5.0).astype(float)\n\n        X = add_acceleration_features(X, cx, cy, fps)\n        X = add_spatial_features(X, cx, cy, fps)\n        X = add_frequency_features(X, cx, cy, fps)\n        X = add_momentum_features(X, cx, cy, fps)\n        X = add_rhythmic_features(X, cx, cy, fps)\n        X = add_territorial_features(X, cx, cy, fps)\n        X = add_statistical_moments(X, cx, cy, fps)\n        X = add_temporal_context_features(X, cx, cy, fps)\n        X = add_body_part_triangulation(X, single_mouse, available_body_parts, fps)\n        X = add_repetitive_pattern_features(X, cx, cy, fps)\n        X = add_energy_features(X, cx, cy, fps)\n        X = add_behavioral_grammar_features(X, cx, cy, fps)\n        X = add_decision_point_features(X, cx, cy, fps)\n        X = add_bout_structure_features(X, cx, cy, fps)\n        X = add_speed_asymmetry_future_past_single(X, cx, cy, fps, horizon_base=30)\n        X = add_gauss_shift_speed_future_past_single(X, cx, cy, fps, window_base=30)\n        X = add_cumulative_distance_single(X, cx, cy, fps, horizon_frames_base=180)\n\n    X = add_posture_features(X, single_mouse, available_body_parts, fps)\n    X = add_posture_dynamics(X, single_mouse, available_body_parts, fps)\n    X = add_body_part_velocities(X, single_mouse, available_body_parts, fps)\n\n    if all(p in available_body_parts for p in ['nose', 'tail_base']):\n        nt_dist = np.sqrt((single_mouse['nose']['x'] - single_mouse['tail_base']['x'])**2 +\n                          (single_mouse['nose']['y'] - single_mouse['tail_base']['y'])**2)\n        for lag in [10, 20, 40]:\n            l = _scale(lag, fps)\n            X[f'nt_lg{lag}'] = nt_dist.shift(l)\n            X[f'nt_df{lag}'] = nt_dist - nt_dist.shift(l)\n\n    if all(p in available_body_parts for p in ['ear_left', 'ear_right']):\n        ear_d = np.sqrt((single_mouse['ear_left']['x'] - single_mouse['ear_right']['x'])**2 +\n                        (single_mouse['ear_left']['y'] - single_mouse['ear_right']['y'])**2)\n        for off in [-30, -20, -10, 10, 20, 30]:\n            o = _scale_signed(off, fps)\n            X[f'ear_o{off}'] = ear_d.shift(-o)\n        w = _scale(30, fps)\n        X['ear_con'] = ear_d.rolling(w, min_periods=1, center=True).std() / \\\n                       (ear_d.rolling(w, min_periods=1, center=True).mean() + 1e-6)\n\n    return X.astype(np.float32, copy=False)\n\n#============= TRANSFORM PAIR =============#\ndef transform_pair(mouse_pair, body_parts_tracked, fps):\n    avail_A = mouse_pair['A'].columns.get_level_values(0)\n    avail_B = mouse_pair['B'].columns.get_level_values(0)\n\n    X = pd.DataFrame({\n        f\"12+{p1}+{p2}\": np.square(mouse_pair['A'][p1] - mouse_pair['B'][p2]).sum(axis=1, skipna=False)\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 = X.reindex(columns=[f\"12+{p1}+{p2}\" for p1, p2 in itertools.product(body_parts_tracked, repeat=2)], copy=False)\n\n    if ('A', 'ear_left') in mouse_pair.columns and ('B', 'ear_left') in mouse_pair.columns:\n        lag = _scale(10, fps)\n        shA = mouse_pair['A']['ear_left'].shift(lag)\n        shB = mouse_pair['B']['ear_left'].shift(lag)\n        speeds = pd.DataFrame({\n            'sp_A': np.square(mouse_pair['A']['ear_left'] - shA).sum(axis=1, skipna=False),\n            'sp_AB': np.square(mouse_pair['A']['ear_left'] - shB).sum(axis=1, skipna=False),\n            'sp_B': np.square(mouse_pair['B']['ear_left'] - shB).sum(axis=1, skipna=False),\n        })\n        X = pd.concat([X, speeds], axis=1)\n\n    if 'nose+tail_base' in X.columns and 'ear_left+ear_right' in X.columns:\n        X['elong'] = X['nose+tail_base'] / (X['ear_left+ear_right'] + 1e-6)\n\n    if all(p in avail_A for p in ['nose', 'tail_base']) and all(p in avail_B for p in ['nose', 'tail_base']):\n        dir_A = mouse_pair['A']['nose'] - mouse_pair['A']['tail_base']\n        dir_B = mouse_pair['B']['nose'] - mouse_pair['B']['tail_base']\n        X['rel_ori'] = (dir_A['x'] * dir_B['x'] + dir_A['y'] * dir_B['y']) / (\n            np.sqrt(dir_A['x']**2 + dir_A['y']**2) * np.sqrt(dir_B['x']**2 + dir_B['y']**2) + 1e-6)\n\n    if all(p in avail_A for p in ['nose']) and all(p in avail_B for p in ['nose']):\n        cur = np.square(mouse_pair['A']['nose'] - mouse_pair['B']['nose']).sum(axis=1, skipna=False)\n        lag = _scale(10, fps)\n        shA_n = mouse_pair['A']['nose'].shift(lag)\n        shB_n = mouse_pair['B']['nose'].shift(lag)\n        past = np.square(shA_n - shB_n).sum(axis=1, skipna=False)\n        X['appr'] = cur - past\n\n    if 'body_center' in avail_A and 'body_center' in avail_B:\n        cd = np.sqrt((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        X['v_cls'] = (cd < 5.0).astype(float)\n        X['cls']   = ((cd >= 5.0) & (cd < 15.0)).astype(float)\n        X['med']   = ((cd >= 15.0) & (cd < 30.0)).astype(float)\n        X['far']   = (cd >= 30.0).astype(float)\n\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, skipna=False)\n\n        for w in [10, 30, 90]:\n            ws = _scale(w, fps)\n            roll = dict(min_periods=1, center=True)\n            X[f'd_m{w}']  = cd_full.rolling(ws, **roll).mean()\n            X[f'd_s{w}']  = cd_full.rolling(ws, **roll).std()\n            X[f'd_mn{w}'] = cd_full.rolling(ws, **roll).min()\n            X[f'd_mx{w}'] = cd_full.rolling(ws, **roll).max()\n\n            d_var = cd_full.rolling(ws, **roll).var()\n            X[f'int{w}'] = 1 / (1 + d_var)\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_m{w}'] = coord.rolling(ws, **roll).mean()\n            X[f'co_s{w}'] = coord.rolling(ws, **roll).std()\n\n        lag_180 = _scale(180, fps)\n        if len(cd_full) >= lag_180:\n            cd_dist = np.sqrt(cd_full)\n            X['longdist_pair_bin1'] = (cd_dist.rolling(lag_180, min_periods=max(5, lag_180 // 6)).mean() > 30.0).astype(float)\n            X['longdist_pair_bin2'] = (cd_dist.rolling(lag_180, min_periods=max(5, lag_180 // 6)).mean() < 10.0).astype(float)\n    \n    if 'nose' in avail_A and 'nose' in avail_B:\n        nn = np.sqrt((mouse_pair['A']['nose']['x'] - mouse_pair['B']['nose']['x'])**2 +\n                     (mouse_pair['A']['nose']['y'] - mouse_pair['B']['nose']['y'])**2)\n        for lag in [10, 20, 40]:\n            l = _scale(lag, fps)\n            X[f'nn_lg{lag}']  = nn.shift(l)\n            X[f'nn_ch{lag}']  = nn - nn.shift(l)\n            is_cl = (nn < 10.0).astype(float)\n            X[f'cl_ps{lag}']  = is_cl.rolling(l, min_periods=1).mean()\n\n    if 'body_center' in avail_A and 'body_center' in avail_B:\n        Avx = mouse_pair['A']['body_center']['x'].diff()\n        Avy = mouse_pair['A']['body_center']['y'].diff()\n        Bvx = mouse_pair['B']['body_center']['x'].diff()\n        Bvy = mouse_pair['B']['body_center']['y'].diff()\n        val = (Avx * Bvx + Avy * Bvy) / (np.sqrt(Avx**2 + Avy**2) * np.sqrt(Bvx**2 + Bvy**2) + 1e-6)\n\n        for off in [-30, -20, -10, 0, 10, 20, 30]:\n            o = _scale_signed(off, fps)\n            X[f'va_{off}'] = val.shift(-o)\n\n        w = _scale(30, fps)\n        X['int_con'] = cd_full.rolling(w, min_periods=1, center=True).std() / \\\n                       (cd_full.rolling(w, min_periods=1, center=True).mean() + 1e-6)\n\n        X = add_interaction_features(X, mouse_pair, avail_A, avail_B, fps)\n        X = add_relative_heading_features(X, mouse_pair, fps)\n        X = add_interaction_distance_bins(X, mouse_pair, avail_A, avail_B, fps)\n        X = add_relative_speed_features(X, mouse_pair, avail_A, avail_B, fps)   \n        X = add_interaction_patterns(X, mouse_pair, avail_A, avail_B, fps)\n        X = add_spatial_dominance_features(X, mouse_pair, avail_A, avail_B, fps)\n        X = add_attention_features(X, mouse_pair, avail_A, avail_B, fps)\n        X = add_social_contagion_features(X, mouse_pair, avail_A, avail_B, fps)\n        X = add_attention_field_features(X, mouse_pair, avail_A, avail_B, fps)\n        X = add_graph_topology_features(X, mouse_pair, avail_A, avail_B, fps)\n\n    return X.astype(np.float32, copy=False)\n\n#============= CLASSIFIER =============#\nclass StratifiedSubsetClassifier(ClassifierMixin, BaseEstimator):\n    def __init__(self, estimator, n_samples=None):\n        self.estimator = estimator\n        self.n_samples = n_samples\n\n    def _to_numpy(self, X):\n        try:\n            return X.to_numpy(np.float32, copy=False)\n        except AttributeError:\n            return np.asarray(X, dtype=np.float32)\n\n    def fit(self, X, y):\n        Xn = self._to_numpy(X)\n        y = np.asarray(y).ravel()\n\n        uniq = np.unique(y[~pd.isna(y)])\n        if set(uniq.tolist()) == {0, 2}:\n            y = (y > 0).astype(np.int8)\n\n        if self.n_samples is None or len(Xn) <= int(self.n_samples):\n            self.estimator.fit(Xn, y)\n        else:\n            sss = StratifiedShuffleSplit(n_splits=1, train_size=int(self.n_samples), random_state=42)\n            try:\n                idx, _ = next(sss.split(np.zeros_like(y), y))\n                self.estimator.fit(Xn[idx], y[idx])\n            except Exception as e:\n                if 'best_split_info.left_count' in str(e):\n                    self.estimator.set_params(device_type='cpu')\n                    self.estimator.fit(Xn[idx], y[idx])\n                else:\n                    step = max(len(Xn) // int(self.n_samples), 1)\n                    self.estimator.fit(Xn[::step], y[::step])\n\n        try:\n            self.classes_ = np.asarray(self.estimator.classes_)\n        except Exception:\n            self.classes_ = np.unique(y)\n        return self\n\n    def predict_proba(self, X):\n        Xn = self._to_numpy(X)\n        try:\n            P = self.estimator.predict_proba(Xn)\n        except Exception:\n            if len(self.classes_) == 1:\n                n = len(Xn)\n                c = int(self.classes_[0])\n                if c == 1:\n                    return np.column_stack([np.zeros(n, dtype=np.float32), np.ones(n, dtype=np.float32)])\n                else:\n                    return np.column_stack([np.ones(n, dtype=np.float32), np.zeros(n, dtype=np.float32)])\n            return np.full((len(Xn), 2), 0.5, dtype=np.float32)\n\n        P = np.asarray(P)\n        if P.ndim == 1:\n            P1 = P.astype(np.float32)\n            return np.column_stack([1.0 - P1, P1])\n        if P.shape[1] == 1 and len(self.classes_) == 2:\n            P1 = P[:, 0].astype(np.float32)\n            return np.column_stack([1.0 - P1, P1])\n        return P\n\n    def predict(self, X):\n        Xn = self._to_numpy(X)\n        try:\n            return self.estimator.predict(Xn)\n        except Exception:\n            return np.argmax(self.predict_proba(Xn), axis=1)\n#============= PREPROCESS TO SUBMIT =============#\nADAPTIVE_THRESHOLDS_v1 = {\n    'single': {\n        '1': {\n            'rear': 0.21525800228118896\n        },\n        '2': {\n            'huddle': 0.18310001492500305,\n            'rear': 0.5,\n            'selfgroom': 0.5\n        },\n        '3': {\n            'rear': 0.1394311934709549\n        },\n        '6': {\n            'biteobject': 0.0005995196762709933,\n            'climb': 0.1665017008781433,\n            'dig': 0.20503206551074982,\n            'exploreobject': 0.002617048792027599,\n            'rear': 0.06062667946700533,\n            'selfgroom': 0.16229913172159882\n        },\n        '7': {\n            'rear': 0.296084463596344,\n            'rest': 0.19009769796937753,\n            'selfgroom': 0.1503967693952951,\n            'climb': 0.04347278145137728,\n            'dig': 0.19580557942390442,\n            'run': 0.08074358850717545\n        },\n        '8': {\n            'rear': 0.12643475830554962,\n            'selfgroom': 0.09562112390995026,\n            'genitalgroom': 0.1659549388765812,\n            'dig': 0.08209896832704544\n        },\n        '9': {\n            'freeze': 0.19502536751134242,\n            'rear': 0.10221560573017868\n        }\n    },\n    'pair': {\n        '1': {\n            'approach': 0.11553158611059189,\n            'attack': 0.14749008417129517,\n            'avoid': 0.12643153965473175,\n            'chase': 0.06645490974187851,\n            'chaseattack': 0.18669064342975616,\n            'submit': 0.01984693482518196\n        },\n        '2': {\n            'reciprocalsniff': 0.21752649545669556,\n            'sniff': 0.5,\n            'sniffgenital': 0.27659541368484497,\n            'intromit': 0.5,\n            'mount': 0.5\n        },\n        '3': {\n            'approach': 0.02425196021795273,\n            'attack': 0.06918201595544815,\n            'avoid': 0.05090678855776787,\n            'chase': 0.010798096656799316,\n            'chaseattack': 0.022090625017881393,\n            'submit': 0.016084443777799606\n        },\n        '4': {\n            'attack': 0.10026851296424866,\n            'dominance': 0.11296072602272034,\n            'sniff': 0.07157517224550247,\n            'chase': 0.01268178690224886,\n            'escape': 0.041602469980716705,\n            'follow': 0.24068273603916168\n        },\n        '5': {\n            'attack': 0.3286178410053253,\n            'sniff': 0.3949454724788666,\n            'defend': 0.26223069429397583,\n            'escape': 0.22683562338352203,\n            'mount': 0.13026556372642517,\n            'sniffgenital': 0.5\n        },\n        '6': {\n            'shepherd': 0.16575618088245392,\n            'approach': 0.19152656197547913,\n            'attack': 0.23048235476016998,\n            'chase': 0.09831040352582932,\n            'defend': 0.15771698951721191,\n            'escape': 0.304587721824646,\n            'flinch': 0.008557775989174843,\n            'follow': 0.06308241188526154,\n            'sniff': 0.21881681300167954,\n            'sniffface': 0.21378189325332642,\n            'sniffgenital': 0.06065356358885765,\n            'tussle': 0.006215625908225775\n        },\n        '7': {\n            'intromit': 0.5,\n            'mount': 0.5,\n            'sniff': 0.33832971822497443,\n            'sniffgenital': 0.25398045778274536,\n            'approach': 0.2360716611146927,\n            'defend': 0.033372338861227036,\n            'escape': 0.11022237351367613,\n            'attemptmount': 0.030093489214777946\n        },\n        '8': {\n            'approach': 0.25436079502105713,\n            'attack': 0.304213285446167,\n            'disengage': 0.23995341360569,\n            'mount': 0.2929087281227112,\n            'sniff': 0.3190191388130188,\n            'sniffgenital': 0.27274999022483826,\n            'dominancemount': 0.236189067363739,\n            'sniffbody': 0.2834545855777041,\n            'sniffface': 0.31102895736694336,\n            'attemptmount': 0.060965705662965775,\n            'intromit': 0.4145866930484772,\n            'chase': 0.06262975931167603,\n            'escape': 0.25139206647872925,\n            'reciprocalsniff': 0.35057151317596436,\n            'allogroom': 0.019169863313436508,\n            'ejaculate': 0.04548828676342964,\n            'dominancegroom': 0.041647788137197495\n        },\n        '9': {\n            'approach': 0.06814590096473694,\n            'attack': 0.45281786720697165,\n            'defend': 0.2370478814378824,\n            'escape': 0.43424616074872296,\n            'sniff': 0.10713639110326767\n        }\n    }\n}\n\nADAPTIVE_THRESHOLDS_v2 = {\n    'single': {\n        '1': {'rear': 0.21007365021308194},\n        '2': {'huddle': 0.19735296523909912},\n        '3': {'rear': 0.15701597511237975},\n        '6': {\n            'biteobject': 0.019280318405461752,\n            'climb': 0.17041432069803572,\n            'dig': 0.18322978307096568,\n            'exploreobject': 0.03616068681012833,\n            'rear': 0.14167958812060324,\n            'selfgroom': 0.16369590503761555\n        },\n        '7': {\n            'rear': 0.2833280211562102,\n            'rest': 0.2335984627108944,\n            'selfgroom': 0.16591909358979792,\n            'climb': 0.12406076880998487,\n            'dig': 0.18660886803797655,\n            'run': 0.045829534706167005\n        },\n        '8': {\n            'rear': 0.12779833881797478,\n            'selfgroom': 0.10192123986447743,\n            'genitalgroom': 0.2622886999648151,\n            'dig': 0.08035279006049402\n        },\n        '9': {\n            'freeze': 0.1534336287860117,\n            'rear': 0.11200474950637435\n        }\n    },\n    'pair': {\n        '1': {\n            'approach': 0.12387827146667493,\n            'attack': 0.061858616667710684,\n            'avoid': 0.19742925700695999,\n            'chase': 0.10244937475279906,\n            'chaseattack': 0.12397792072234151,\n            'submit': 0.026304502835523096\n        },\n        '2': {\n            'reciprocalsniff': 0.2507984189461601,\n            'sniffgenital': 0.27431326505807785\n        },\n        '3': {\n            'approach': 0.06031101706132876,\n            'attack': 0.052739387804301124,\n            'avoid': 0.05702548759221109,\n            'chase': 0.020763902784874902,\n            'chaseattack': 0.01764858005019092,\n            'submit': 0.03319505167948223\n        },\n        '4': {\n            'attack': 0.11977287991962667,\n            'dominance': 0.12347981291961246,\n            'sniff': 0.15427837209513517,\n            'chase': 0.020285560347685753,\n            'escape': 0.04395574874861563,\n            'follow': 0.3634684520362762\n        },\n        '5': {\n            'attack': 0.3795698675025961,\n            'sniff': 0.4030891118910214,\n            'defend': 0.3212448068921057,\n            'escape': 0.32324332344141365,\n            'mount': 0.2662508768385557\n        },\n        '6': {\n            'shepherd': 0.2094737727212095,\n            'approach': 0.2117071055838438,\n            'attack': 0.25760099456407204,\n            'chase': 0.1840792820129987,\n            'defend': 0.20886460457788664,\n            'escape': 0.2994354072207405,\n            'flinch': 0.040431458312782054,\n            'follow': 0.09344493854577517,\n            'sniff': 0.2732194822024317,\n            'sniffface': 0.24886296332576852,\n            'sniffgenital': 0.05561604743836648,\n            'tussle': 0.011138234224494668\n        },\n        '7': {\n            'sniff': 0.3415880870848557,\n            'sniffgenital': 0.2862918040171262,\n            'approach': 0.21883488170298246,\n            'defend': 0.06010772077562987,\n            'escape': 0.1263435325085349,\n            'attemptmount': 0.07515830439133601\n        },\n        '8': {\n            'approach': 0.23995662261566278,\n            'attack': 0.33376404322207925,\n            'disengage': 0.24522130141859103,\n            'mount': 0.3211466468884248,\n            'sniff': 0.3360021167613508,\n            'sniffgenital': 0.2615004578401469,\n            'dominancemount': 0.21957091537858664,\n            'sniffbody': 0.28360908033353077,\n            'sniffface': 0.30462080424710697,\n            'attemptmount': 0.07619999899436361,\n            'intromit': 0.4302671785513684,\n            'chase': 0.04856646280375858,\n            'escape': 0.18002569235275614,\n            'reciprocalsniff': 0.3961874780776454,\n            'allogroom': 0.10826494978890203,\n            'ejaculate': 0.6021891238089415,\n            'dominancegroom': 0.077824002762503\n        },\n        '9': {\n            'approach': 0.08412952885539599,\n            'attack': 0.4264444115237341,\n            'defend': 0.2390009497061875,\n            'escape': 0.43846319371420767,\n            'sniff': 0.18137406640085454\n        }\n    }\n}\n\ndef _select_threshold_map(thresholds, mode: str, section_id: str = None):\n    base_default = float(thresholds.get('default', 0.27))\n    mode_default = float(thresholds.get(f\"{mode}_default\", base_default))\n    \n    if section_id and mode in thresholds:\n        mode_dict = thresholds[mode]\n        if isinstance(mode_dict, dict) and section_id in mode_dict:\n            section_overrides = mode_dict[section_id]\n            out = defaultdict(lambda: mode_default)\n            out.update({str(k): float(v) for k, v in section_overrides.items()})\n            return out\n    \n    return defaultdict(lambda: mode_default)\n\ndef predict_multiclass_adaptive(pred, meta, thresholds_v1, thresholds_v2, section_id=None, weight_v1=0.5, weight_v2=0.5):\n    fps = 30.0\n    if 'frames_per_second' in meta.columns:\n        fps_val = meta['frames_per_second'].iloc[0]\n        if pd.notnull(fps_val):\n            fps = float(fps_val)\n    \n    mode = 'pair'\n    try:\n        if 'target_id' in meta.columns and meta['target_id'].eq('self').all():\n            mode = 'single'\n    except:\n        pass\n\n    pred_smoothed = pred.copy()\n    base_window = max(5, int(round(9 * (fps / 30.0))))\n    sigma = fps / 18.0\n    \n    for col in pred_smoothed.columns:\n        vals = pred_smoothed[col].fillna(0).values\n        if len(vals) > 5:\n            vals = medfilt(vals, kernel_size=5)\n        rolled = pd.Series(vals).rolling(\n            window=base_window, min_periods=1, center=True\n        ).mean().values\n        gauss = gaussian_filter1d(rolled, sigma=sigma, mode='nearest')\n        pred_smoothed[col] = 0.75 * gauss + 0.25 * vals\n    \n    th_map_v1 = _select_threshold_map(thresholds_v1, mode, section_id)\n    th_map_v2 = _select_threshold_map(thresholds_v2, mode, section_id)\n    \n    ama = np.argmax(pred_smoothed, axis=1)\n    max_probs = pred_smoothed.max(axis=1)\n    \n    threshold_mask = np.zeros(len(pred_smoothed), dtype=bool)\n    \n    for i, action in enumerate(pred_smoothed.columns):\n        action_mask = (ama == i)\n        \n        base_th_v1 = th_map_v1[action]\n        base_th_v2 = th_map_v2[action]\n        \n        has_v1 = False\n        has_v2 = False\n        \n        if mode in thresholds_v1 and isinstance(thresholds_v1[mode], dict):\n            if section_id in thresholds_v1[mode]:\n                has_v1 = action in thresholds_v1[mode][section_id]\n        \n        if mode in thresholds_v2 and isinstance(thresholds_v2[mode], dict):\n            if section_id in thresholds_v2[mode]:\n                has_v2 = action in thresholds_v2[mode][section_id]\n        \n        if has_v1 and has_v2:\n            base_th = weight_v1 * base_th_v1 + weight_v2 * base_th_v2\n        elif has_v1 and not has_v2:\n            base_th = base_th_v1\n        elif has_v2 and not has_v1:\n            base_th = base_th_v2\n        \n        action_lower = action.lower()\n        \n        if action_lower in ['attack', 'mount', 'chase', 'chaseattack', 'jump']:\n            adjusted_th = base_th * 0.88\n        elif action_lower in ['rear', 'climb', 'dig', 'selfgroom']:\n            adjusted_th = base_th * 0.90\n        elif action_lower in ['sniff', 'sniffgenital', 'sniffface', 'approach']:\n            adjusted_th = base_th * 0.93\n        elif action_lower in ['rest', 'huddle', 'freeze']:\n            adjusted_th = base_th * 1.05\n        else:\n            adjusted_th = base_th * 0.95\n        \n        threshold_mask |= (action_mask & (max_probs >= adjusted_th))\n    \n    ama = np.where(threshold_mask, ama, -1)\n    ama = pd.Series(ama, index=meta.video_frame)\n    changes_mask = (ama != ama.shift(1)).values\n    ama_changes = ama[changes_mask]\n    meta_changes = meta[changes_mask]\n    mask = ama_changes.values >= 0\n    mask[-1] = False\n    \n    if np.sum(mask) == 0:\n        return pd.DataFrame(columns=['video_id', 'agent_id', 'target_id', 'action', 'start_frame', 'stop_frame'])\n    \n    submission_part = pd.DataFrame({\n        'video_id': meta_changes['video_id'][mask].values,\n        'agent_id': meta_changes['agent_id'][mask].values,\n        'target_id': meta_changes['target_id'][mask].values,\n        'action': pred.columns[ama_changes[mask].values],\n        'start_frame': ama_changes.index[mask],\n        'stop_frame': ama_changes.index[1:][mask[:-1]]\n    })\n    \n    stop_video_id = meta_changes['video_id'][1:][mask[:-1]].values\n    stop_agent_id = meta_changes['agent_id'][1:][mask[:-1]].values\n    stop_target_id = meta_changes['target_id'][1:][mask[:-1]].values\n    \n    for i in range(len(submission_part)):\n        video_id = submission_part.video_id.iloc[i]\n        agent_id = submission_part.agent_id.iloc[i]\n        target_id = submission_part.target_id.iloc[i]\n        if i < len(stop_video_id):\n            if (stop_video_id[i] != video_id or \n                stop_agent_id[i] != agent_id or \n                stop_target_id[i] != target_id):\n                new_stop = meta.query('video_id == @video_id').video_frame.max() + 1\n                submission_part.iat[i, submission_part.columns.get_loc('stop_frame')] = new_stop\n        else:\n            new_stop = meta.query('video_id == @video_id').video_frame.max() + 1\n            submission_part.iat[i, submission_part.columns.get_loc('stop_frame')] = new_stop\n    \n    duration = submission_part.stop_frame - submission_part.start_frame\n    min_duration = max(2, int(fps / 12))\n    submission_part = submission_part[duration >= min_duration].reset_index(drop=True)\n    \n    if len(submission_part) > 1:\n        merged_list = []\n        current = submission_part.iloc[0].to_dict()\n        \n        for i in range(1, len(submission_part)):\n            next_seg = submission_part.iloc[i]\n            gap = next_seg['start_frame'] - current['stop_frame']\n            if (gap <= 8 and \n                next_seg['action'] == current['action'] and\n                next_seg['agent_id'] == current['agent_id'] and\n                next_seg['target_id'] == current['target_id']):\n                current['stop_frame'] = next_seg['stop_frame']\n            else:\n                merged_list.append(current)\n                current = next_seg.to_dict()\n        \n        merged_list.append(current)\n        submission_part = pd.DataFrame(merged_list)\n    \n    if len(submission_part) > 0:\n        assert (submission_part.stop_frame > submission_part.start_frame).all()\n    \n    if verbose:\n        print(f\"    Actions found: {len(submission_part)}\")\n    \n    return submission_part\n#============= ROBUSTIFY & FALLBACK =============#\ndef robustify(submission, dataset, traintest, traintest_directory=None):\n    if traintest_directory is None:\n        traintest_directory = f\"/kaggle/input/MABe-mouse-behavior-detection/{traintest}_tracking\"\n\n    submission = submission[submission.start_frame < submission.stop_frame].copy()\n    submission = submission.dropna(subset=['video_id', 'agent_id', 'target_id', 'action', 'start_frame', 'stop_frame'])\n    submission['start_frame'] = submission['start_frame'].astype(int)\n    submission['stop_frame'] = submission['stop_frame'].astype(int)\n    submission['video_id'] = submission['video_id'].astype(int)\n\n    group_list = []\n    for _, group in submission.groupby(['video_id', 'agent_id', 'target_id']):\n        group = group.sort_values('start_frame')\n        mask = np.ones(len(group), dtype=bool)\n        last_stop = 0\n        for i, (_, row) in enumerate(group.iterrows()):\n            if row['start_frame'] < last_stop:\n                mask[i] = False\n            else:\n                last_stop = row['stop_frame']\n        group_list.append(group[mask])\n\n    if len(group_list) > 0:\n        submission = pd.concat(group_list, ignore_index=True)\n    else:\n        submission = pd.DataFrame(columns=['video_id', 'agent_id', 'target_id', 'action', 'start_frame', 'stop_frame'])\n\n    batch_len = 200\n    s_list = []\n\n    for video_id in dataset.video_id.unique():\n        video_id_int = int(video_id)\n        vid_sub = submission[submission.video_id == video_id_int]\n        lab_id = dataset[dataset.video_id == video_id_int].lab_id.iloc[0]\n        path = f\"{traintest_directory}/{lab_id}/{video_id_int}.parquet\"\n        \n        try:\n            vid = pd.read_parquet(path)\n        except Exception:\n            continue\n            \n        start_frame = vid.video_frame.min()\n        stop_frame = vid.video_frame.max() + 1\n\n        all_agents_targets = set()\n        for agent in vid.mouse_id.unique():\n            all_agents_targets.add((f\"mouse{agent}\", 'self'))\n            for target in vid.mouse_id.unique():\n                if agent != target:\n                    all_agents_targets.add((f\"mouse{agent}\", f\"mouse{target}\"))\n\n        for agent, target in all_agents_targets:\n            actions = vid_sub[(vid_sub.agent_id == agent) & (vid_sub.target_id == target)]\n            if len(actions) == 0:\n                for i in range((stop_frame - start_frame + batch_len - 1) // batch_len):\n                    batch_start = start_frame + i * batch_len\n                    batch_stop = min(batch_start + batch_len, stop_frame)\n                    s_list.append((video_id_int, agent, target, 'other', batch_start, batch_stop))\n            else:\n                covered = set()\n                for _, action_row in actions.iterrows():\n                    covered.update(range(int(action_row['start_frame']), int(action_row['stop_frame'])))\n\n                uncovered = set(range(start_frame, stop_frame)) - covered\n                if len(uncovered) > 0:\n                    uncovered_sorted = sorted(uncovered)\n                    gap_start = uncovered_sorted[0]\n                    for j in range(1, len(uncovered_sorted)):\n                        if uncovered_sorted[j] != uncovered_sorted[j-1] + 1:\n                            gap_stop = uncovered_sorted[j-1] + 1\n                            for k in range((gap_stop - gap_start + batch_len - 1) // batch_len):\n                                batch_start = gap_start + k * batch_len\n                                batch_stop = min(batch_start + batch_len, gap_stop)\n                                s_list.append((video_id_int, agent, target, 'other', batch_start, batch_stop))\n                            gap_start = uncovered_sorted[j]\n                    gap_stop = uncovered_sorted[-1] + 1\n                    for k in range((gap_stop - gap_start + batch_len - 1) // batch_len):\n                        batch_start = gap_start + k * batch_len\n                        batch_stop = min(batch_start + batch_len, gap_stop)\n                        s_list.append((video_id_int, agent, target, 'other', batch_start, batch_stop))\n                else:\n                    for i, (_, action_row) in enumerate(actions.iterrows()):\n                        batch_start = start_frame + i * batch_len\n                        batch_stop = min(batch_start + batch_len, stop_frame)\n                        s_list.append((video_id_int, agent, target, action_row['action'], batch_start, batch_stop))\n\n    if len(s_list) > 0:\n        submission = pd.concat([\n            submission,\n            pd.DataFrame(s_list, columns=['video_id', 'agent_id', 'target_id', 'action', 'start_frame', 'stop_frame'])\n        ])\n\n    submission = submission.reset_index(drop=True)\n    submission['video_id'] = submission['video_id'].astype(int)\n    submission['start_frame'] = submission['start_frame'].astype(int)\n    submission['stop_frame'] = submission['stop_frame'].astype(int)\n    \n    return submission\n#============= SUBMIT ENSEMBLE =============#\ndef submit_ensemble(body_parts_tracked_str, switch_tr, X_tr, label, meta, n_samples, section_id):\n    models = []\n    gpu_device = 'gpu' if GPU_AVAILABLE else 'cpu'\n    \n    if XGBOOST_AVAILABLE:\n        xgb_device = 'gpu_hist' if GPU_AVAILABLE else 'hist'\n        \n        models.append(('XGB_v1', make_pipeline(\n            StratifiedSubsetClassifier(\n                XGBClassifier(\n                    n_estimators=300,\n                    learning_rate=0.08,\n                    max_depth=8,\n                    min_child_weight=3,\n                    subsample=0.85,\n                    colsample_bytree=0.85,\n                    gamma=0.1,\n                    reg_alpha=0.05,\n                    reg_lambda=1.0,\n                    tree_method=xgb_device, \n                    verbosity=0,\n                    random_state=SEED\n                ), int(n_samples/1.5),\n            )\n        )))\n        \n        models.append(('XGB_v2', make_pipeline(\n            StratifiedSubsetClassifier(\n                XGBClassifier(\n                    n_estimators=320,\n                    learning_rate=0.07,\n                    max_depth=9,\n                    min_child_weight=2,\n                    subsample=0.8,\n                    colsample_bytree=0.8,\n                    gamma=0.15,\n                    reg_alpha=0.1,\n                    reg_lambda=1.2,\n                    tree_method=xgb_device, \n                    verbosity=0,\n                    random_state=SEED + 1\n                ), int(n_samples/1.5),\n            )\n        )))\n        \n        models.append(('XGB_v3', make_pipeline(\n            StratifiedSubsetClassifier(\n                XGBClassifier(\n                    n_estimators=280,\n                    learning_rate=0.065,\n                    max_depth=7,\n                    min_child_weight=4,\n                    subsample=0.82,\n                    colsample_bytree=0.82,\n                    gamma=0.12,\n                    reg_alpha=0.08,\n                    reg_lambda=1.1,\n                    tree_method=xgb_device, \n                    verbosity=0,\n                    random_state=SEED + 2\n                ), int(n_samples/1.5),\n            )\n        )))\n        \n    if CATBOOST_AVAILABLE:\n        cat_device = 'GPU' if GPU_AVAILABLE else 'CPU'\n\n        models.append(('CatBoost_v1', make_pipeline(\n            StratifiedSubsetClassifier(\n                CatBoostClassifier(\n                    iterations=280,\n                    learning_rate=0.09,\n                    depth=7,\n                    l2_leaf_reg=3.0,\n                    bootstrap_type='Bayesian',\n                    bagging_temperature=0.8,\n                    random_strength=1.0,\n                    task_type=cat_device,\n                    verbose=False, \n                    allow_writing_files=False,\n                    random_seed=SEED\n                ), n_samples,\n            )\n        )))\n        \n        models.append(('CatBoost_v2', make_pipeline(\n            StratifiedSubsetClassifier(\n                CatBoostClassifier(\n                    iterations=300,\n                    learning_rate=0.08,\n                    depth=6,  \n                    l2_leaf_reg=4.0,\n                    bootstrap_type='MVS',\n                    random_strength=0.8,\n                    subsample=0.8,\n                    task_type=cat_device,\n                    verbose=False, \n                    allow_writing_files=False,\n                    random_seed=SEED\n                ), n_samples,\n            )\n        )))\n\n    X_tr_np = X_tr.to_numpy(np.float32, copy=False)\n    del X_tr; gc.collect()\n\n    model_list = []\n    for action in label.columns:\n        y_raw = label[action].to_numpy()\n        mask = ~pd.isna(y_raw)\n        y_action = y_raw[mask].astype(int)\n        \n        if not (y_action == 0).all() and np.sum(y_action) >= 3:\n            idx = np.flatnonzero(mask)\n            X_action = X_tr_np[idx]\n            \n            groups_action = meta['video_id'].values[idx]\n            \n            max_cv_samples = 500000\n            if len(X_action) > max_cv_samples:\n                sss = StratifiedShuffleSplit(n_splits=1, train_size=max_cv_samples, random_state=42)\n                cv_idx, _ = next(sss.split(X_action, y_action))\n                X_cv = X_action[cv_idx]\n                y_cv = y_action[cv_idx]\n                groups_cv = groups_action[cv_idx]\n            else:\n                X_cv = X_action\n                y_cv = y_action\n                groups_cv = groups_action\n            \n            model_scores = []\n            if verbose:\n                print(f\"    Evaluating models for action: {action} (samples: {len(y_cv)})\")\n            \n            for model_name, model in models:\n                try:\n                    split_successful = False\n                    try:\n                        gss = GroupShuffleSplit(n_splits=1, test_size=0.2, random_state=42)\n                        train_idx, val_idx = next(gss.split(X_cv, y_cv, groups=groups_cv))\n                        \n                        if np.sum(y_cv[train_idx]) > 0 and np.sum(y_cv[val_idx]) > 0:\n                            split_successful = True\n                    except Exception:\n                        split_successful = False\n\n                    if not split_successful:\n                        sss_fallback = StratifiedShuffleSplit(n_splits=1, test_size=0.2, random_state=42)\n                        train_idx, val_idx = next(sss_fallback.split(X_cv, y_cv))\n                    \n                    X_train, X_val = X_cv[train_idx], X_cv[val_idx]\n                    y_train, y_val = y_cv[train_idx], y_cv[val_idx]\n                    \n                    m_temp = clone(model)\n                    m_temp.fit(X_train, y_train)\n                    \n                    y_pred = m_temp.predict_proba(X_val)[:, 1]\n                    \n                    if len(np.unique(y_val)) == 2:\n                        score = roc_auc_score(y_val, y_pred)\n                    else:\n                        y_pred_class = (y_pred > 0.5).astype(int)\n                        score = f1_score(y_val, y_pred_class, average='weighted')\n                    \n                    model_scores.append((model_name, model, score))\n                    \n                    if verbose:\n                        print(f\"      {model_name}: {score:.4f}\")\n                    \n                    del m_temp, X_train, X_val, y_train, y_val, y_pred\n                    gc.collect()\n                        \n                except Exception as e:\n                    if verbose:\n                        print(f\"      {model_name}: Failed - {str(e)[:50]}\")\n                    gc.collect()\n                    continue\n            \n            del X_cv, y_cv, groups_cv\n            gc.collect()\n            \n            model_scores.sort(key=lambda x: x[2], reverse=True)\n            top_models = model_scores[:3]\n            \n            if len(top_models) > 0:\n                if verbose:\n                    print(f\"    Selected top {len(top_models)} models: {[m[0] for m in top_models]}\")\n                \n                trained = []\n                for model_name, model, score in top_models:\n                    m_clone = clone(model)\n                    m_clone.fit(X_action, y_action)\n                    trained.append(m_clone)\n                    gc.collect()\n                \n                model_list.append((action, trained, [m[2] for m in top_models]))\n            \n            del X_action\n            gc.collect()\n\n    del X_tr_np; gc.collect()\n\n    body_parts_tracked = json.loads(body_parts_tracked_str)\n    if len(body_parts_tracked) > 5:\n        body_parts_tracked = [b for b in body_parts_tracked if b not in DROP_BODY_PARTS]\n\n    test_subset = test[test.body_parts_tracked == body_parts_tracked_str]\n    generator = generate_mouse_data(\n        test_subset, 'test',\n        generate_single=(switch_tr == 'single'),\n        generate_pair=(switch_tr == 'pair')\n    )\n\n    fps_lookup = (\n        test_subset[['video_id', 'frames_per_second']]\n        .drop_duplicates('video_id')\n        .set_index('video_id')['frames_per_second']\n        .to_dict()\n    )\n\n    if verbose:\n        print(f\"    n_videos: {len(test_subset)}, n_candidate_models: {len(models)}\")\n\n    for switch_te, data_te, meta_te, actions_te in generator:\n        assert switch_te == switch_tr\n        try:\n            fps_i = _fps_from_meta(meta_te, fps_lookup, default_fps=30.0)\n\n            if switch_te == 'single':\n                X_te = transform_single(data_te, body_parts_tracked, fps_i).astype(np.float32)\n            else:\n                X_te = transform_pair(data_te, body_parts_tracked, fps_i).astype(np.float32)\n\n            X_te_np = X_te.to_numpy(np.float32, copy=False)\n            del X_te, data_te; gc.collect()\n\n            pred = pd.DataFrame(index=meta_te.video_frame)\n            for action, trained, scores in model_list:\n                if action in actions_te:\n                    probs = [m.predict_proba(X_te_np)[:, 1] for m in trained]\n                    \n                    weights = np.array(scores)\n                    weights = weights / weights.sum()\n\n                    ensemble_prob = np.average(probs, axis=0, weights=weights)\n                    ensemble_prob = np.clip(ensemble_prob, 0.01, 0.99)\n                    ensemble_prob = np.power(ensemble_prob, 0.95)\n                    pred[action] = ensemble_prob\n\n            del X_te_np; gc.collect()\n\n            if pred.shape[1] != 0:\n                sub_part = predict_multiclass_adaptive(\n                    pred, meta_te, \n                    ADAPTIVE_THRESHOLDS_v1, \n                    ADAPTIVE_THRESHOLDS_v2, \n                    section_id,\n                    weight_v1=0.5,\n                    weight_v2=0.5\n                )\n                submission_list.append(sub_part)\n            else:   \n                if verbose:\n                    print(f'    Warning: No trained models for video {meta_te.video_id.iloc[0]}. Using safe defaults.')\n                \n                if len(actions_te) > 0:\n                    for act in actions_te:\n                        pred[act] = 0.001 \n                    \n                    sub_part = predict_multiclass_adaptive(\n                        pred, meta_te, \n                        ADAPTIVE_THRESHOLDS_v1, \n                        ADAPTIVE_THRESHOLDS_v2, \n                        section_id\n                    )\n                    submission_list.append(sub_part)\n\n        except Exception as e:\n            if verbose:\n                print(f\"    Error: {str(e)[:50]}\")\n            try:\n                del data_te\n            except Exception:\n                pass\n            gc.collect()\n#============= MAIN LOOP =============#\ntry:\n    import torch\n    if torch.cuda.is_available():\n        GPU_AVAILABLE = True\n        print(f\"GPU Available: {torch.cuda.get_device_name(0)}\")\n        print(f\"Number of GPUs: {torch.cuda.device_count()}\")\nexcept:\n    GPU_AVAILABLE = False\n\nsubmission_list = []\nprint(f\"XGBoost: {XGBOOST_AVAILABLE}, CatBoost: {CATBOOST_AVAILABLE}\\n\")\n\nfor section in range(1, len(body_parts_tracked_list)):\n    body_parts_tracked_str = body_parts_tracked_list[section]\n    try:\n        body_parts_tracked = json.loads(body_parts_tracked_str)\n        print(f\"#============= {section}. Processing: {len(body_parts_tracked)} body parts =============#\")\n        print(f\"Body parts list: {body_parts_tracked}\")\n        \n        if len(body_parts_tracked) > 5:\n            body_parts_tracked = [b for b in body_parts_tracked if b not in DROP_BODY_PARTS]\n\n        train_subset = train[train.body_parts_tracked == body_parts_tracked_str]\n\n        _fps_lookup = (\n            train_subset[['video_id', 'frames_per_second']]\n            .drop_duplicates('video_id')\n            .set_index('video_id')['frames_per_second']\n            .to_dict()\n        )\n\n        single_list, single_label_list, single_meta_list = [], [], []\n        pair_list, pair_label_list, pair_meta_list = [], [], []\n\n        for switch, data, meta, label in generate_mouse_data(train_subset, 'train'):\n            if switch == 'single':\n                single_list.append(data)\n                single_meta_list.append(meta)\n                single_label_list.append(label)\n            else:\n                pair_list.append(data)\n                pair_meta_list.append(meta)\n                pair_label_list.append(label)\n\n        if len(single_list) > 0:\n            single_feats_parts = []\n            for data_i, meta_i in zip(single_list, single_meta_list):\n                fps_i = _fps_from_meta(meta_i, _fps_lookup, default_fps=30.0)\n                Xi = transform_single(data_i, body_parts_tracked, fps_i).astype(np.float32)\n                single_feats_parts.append(Xi)\n\n            X_tr = pd.concat(single_feats_parts, axis=0, ignore_index=True)\n \n            single_label = pd.concat(single_label_list, axis=0, ignore_index=True)\n            single_meta  = pd.concat(single_meta_list,  axis=0, ignore_index=True)\n\n            del single_list, single_label_list, single_meta_list, single_feats_parts\n            gc.collect()\n\n            print(f\"● Single: {X_tr.shape}\")\n            submit_ensemble(body_parts_tracked_str, 'single', X_tr, single_label, single_meta, 2_000_000, str(section)) \n\n            del X_tr, single_label, single_meta\n            gc.collect()\n\n        if len(pair_list) > 0:\n            pair_feats_parts = []\n            for data_i, meta_i in zip(pair_list, pair_meta_list):\n                fps_i = _fps_from_meta(meta_i, _fps_lookup, default_fps=30.0)\n                Xi = transform_pair(data_i, body_parts_tracked, fps_i).astype(np.float32)\n                pair_feats_parts.append(Xi)\n\n            X_tr = pd.concat(pair_feats_parts, axis=0, ignore_index=True)\n            \n            pair_label = pd.concat(pair_label_list, axis=0, ignore_index=True)\n            pair_meta  = pd.concat(pair_meta_list,  axis=0, ignore_index=True)\n\n            del pair_list, pair_label_list, pair_meta_list, pair_feats_parts\n            gc.collect()\n\n            print(f\"● Pair: {X_tr.shape}\")\n            submit_ensemble(body_parts_tracked_str, 'pair', X_tr, pair_label, pair_meta, 900_000, str(section))\n\n            del X_tr, pair_label, pair_meta\n            gc.collect()\n\n    except Exception as e:\n        print(f\"    Exception {str(e)[:100]}\")\n\n    gc.collect()\n    print()\n\nif len(submission_list) > 0:\n    submission = pd.concat(submission_list, ignore_index=True)\nelse:\n    submission = pd.DataFrame({\n        'video_id': [438887472],\n        'agent_id': ['mouse1'],\n        'target_id': ['self'],\n        'action': ['rear'],\n        'start_frame': [278],\n        'stop_frame': [500]\n    })\n\nsubmission_robust = robustify(submission, test, 'test')\nsubmission_robust.index.name = 'row_id'\nsubmission_robust.to_csv('submission.csv')\nprint(f\"Submission created: {len(submission_robust)} predictions\")","metadata":{"_uuid":"8f2839f25d086af736a60e9eeb907d3b93b6e0e5","_cell_guid":"b1076dfc-b9ad-4769-8c92-a6c4dae69d19","trusted":true,"execution":{"iopub.status.busy":"2025-12-13T01:49:28.787372Z","iopub.execute_input":"2025-12-13T01:49:28.787669Z","iopub.status.idle":"2025-12-13T01:56:29.017630Z","shell.execute_reply.started":"2025-12-13T01:49:28.787648Z","shell.execute_reply":"2025-12-13T01:56:29.016140Z"}},"outputs":[],"execution_count":null}]}