{"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.ndimage import gaussian_filter1d\nfrom scipy.signal import medfilt\n\nfrom sklearn.model_selection import train_test_split, cross_val_score, StratifiedShuffleSplit\nfrom sklearn.metrics import roc_auc_score, f1_score\nfrom sklearn.base import ClassifierMixin, BaseEstimator, clone, TransformerMixin\nfrom sklearn.pipeline import make_pipeline\nfrom sklearn.impute import SimpleImputer\nfrom sklearn.feature_selection import VarianceThreshold\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))","metadata":{"_uuid":"8f2839f25d086af736a60e9eeb907d3b93b6e0e5","_cell_guid":"b1076dfc-b9ad-4769-8c92-a6c4dae69d19","trusted":true,"execution":{"iopub.status.busy":"2025-11-22T07:12:36.817230Z","iopub.execute_input":"2025-11-22T07:12:36.817425Z","iopub.status.idle":"2025-11-22T07:12:40.491752Z","shell.execute_reply.started":"2025-11-22T07:12:36.817407Z","shell.execute_reply":"2025-11-22T07:12:40.490971Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"#============= 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","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-11-22T07:12:40.493267Z","iopub.execute_input":"2025-11-22T07:12:40.493598Z","iopub.status.idle":"2025-11-22T07:12:40.506749Z","shell.execute_reply.started":"2025-11-22T07:12:40.493579Z","shell.execute_reply":"2025-11-22T07:12:40.505896Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"#============= 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_facing_features(X, mouse_pair, fps):\n    try:\n        if all(p in mouse_pair['A'].columns.get_level_values(0) for p in ['nose','tail_base']) and \\\n           all(p in mouse_pair['B'].columns.get_level_values(0) for p in ['nose','tail_base']):\n            A_dir = mouse_pair['A']['nose'] - mouse_pair['A']['tail_base']\n            B_dir = mouse_pair['B']['nose'] - mouse_pair['B']['tail_base']\n            A_mag = np.sqrt(A_dir['x']**2 + A_dir['y']**2) + 1e-6\n            B_mag = np.sqrt(B_dir['x']**2 + B_dir['y']**2) + 1e-6\n            A_unit_x = A_dir['x'] / A_mag\n            A_unit_y = A_dir['y'] / A_mag\n            B_unit_x = B_dir['x'] / B_mag\n            B_unit_y = B_dir['y'] / B_mag\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            AB_mag = np.sqrt(ABx**2 + ABy**2) + 1e-6\n\n            X['A_face_B'] = (A_unit_x * (ABx/AB_mag) + A_unit_y * (ABy/AB_mag)).rolling(_scale(30,fps), min_periods=1, center=True).mean()\n            BAx = -ABx; BAy = -ABy; BA_mag = AB_mag\n            X['B_face_A'] = (B_unit_x * (BAx/BA_mag) + B_unit_y * (BAy/BA_mag)).rolling(_scale(30,fps), min_periods=1, center=True).mean()\n\n    except:\n        pass\n\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_micromovement_features(X, center_x, center_y, fps):\n    try:\n        for w in [3, 5, 7]:\n            ws = _scale(w, fps)\n            dx = center_x.diff()\n            dy = center_y.diff()\n\n            x_reversals = ((dx.shift(1) * dx) < 0).astype(float)\n            y_reversals = ((dy.shift(1) * dy) < 0).astype(float)\n            \n            X[f'jitter_x_{w}'] = x_reversals.rolling(ws, min_periods=1).sum()\n            X[f'jitter_y_{w}'] = y_reversals.rolling(ws, min_periods=1).sum()\n            X[f'jitter_total_{w}'] = (x_reversals + y_reversals).rolling(ws, min_periods=1).mean()\n\n        micro_disp = np.sqrt(dx**2 + dy**2)\n        for w in [10, 20]:\n            ws = _scale(w, fps)\n            X[f'micro_disp_{w}'] = (micro_disp < 0.5).astype(float).rolling(ws, min_periods=1).mean()\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","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-11-22T07:12:40.507750Z","iopub.execute_input":"2025-11-22T07:12:40.508030Z","iopub.status.idle":"2025-11-22T07:12:40.604309Z","shell.execute_reply.started":"2025-11-22T07:12:40.508006Z","shell.execute_reply":"2025-11-22T07:12:40.603563Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"#============= TRANSFORM SINGLE =============#\ndef transform_single(single_mouse, body_parts_tracked, 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 [5, 15, 30, 60]:\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_micromovement_features(X, cx, cy, fps)\n        X = add_repetitive_pattern_features(X, cx, cy, fps)\n        X = add_energy_features(X, cx, cy, fps)\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 [5, 15, 30, 60]:\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_facing_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\n    return X.astype(np.float32, copy=False)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-11-22T07:12:40.605213Z","iopub.execute_input":"2025-11-22T07:12:40.605458Z","iopub.status.idle":"2025-11-22T07:12:40.643308Z","shell.execute_reply.started":"2025-11-22T07:12:40.605435Z","shell.execute_reply":"2025-11-22T07:12:40.642602Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"#============= 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)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-11-22T07:12:40.644038Z","iopub.execute_input":"2025-11-22T07:12:40.644388Z","iopub.status.idle":"2025-11-22T07:12:40.663808Z","shell.execute_reply.started":"2025-11-22T07:12:40.644363Z","shell.execute_reply":"2025-11-22T07:12:40.663062Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"#============= PREPROCESS TO SUBMIT =============#\nADAPTIVE_THRESHOLDS = {\n    \"single\": {\n        \"1\": {\"rear\": 0.18},\n        \"2\": {\"huddle\": 0.14},\n        \"3\": {\"rear\": 0.15},\n        \"6\": {\n            \"biteobject\": 0.01, \"climb\": 0.13, \"dig\": 0.18,\n            \"exploreobject\": 0.01, \"rear\": 0.10, \"selfgroom\": 0.16\n        },\n        \"7\": {\n            \"rear\": 0.24, \"rest\": 0.16, \"selfgroom\": 0.12,\n            \"climb\": 0.10, \"dig\": 0.16, \"run\": 0.06\n        },\n        \"8\": {\"rear\": 0.09, \"selfgroom\": 0.08, \"genitalgroom\": 0.22, \"dig\": 0.06},\n        \"9\": {\"freeze\": 0.15, \"rear\": 0.09}\n    },\n    \"pair\": {\n        \"1\": {\n            \"approach\": 0.11, \"attack\": 0.07, \"avoid\": 0.17,\n            \"chase\": 0.03, \"chaseattack\": 0.08, \"submit\": 0.02\n        },\n        \"2\": {\"reciprocalsniff\": 0.22, \"sniffgenital\": 0.24},\n        \"3\": {\n            \"approach\": 0.03, \"attack\": 0.07, \"avoid\": 0.05,\n            \"chase\": 0.01, \"chaseattack\": 0.02, \"submit\": 0.01\n        },\n        \"4\": {\n            \"attack\": 0.08, \"dominance\": 0.12, \"sniff\": 0.07,\n            \"chase\": 0.01, \"escape\": 0.03, \"follow\": 0.33\n        },\n        \"5\": {\n            \"attack\": 0.34, \"sniff\": 0.35, \"defend\": 0.26,\n            \"escape\": 0.19, \"mount\": 0.20\n        },\n        \"6\": {\n            \"shepherd\": 0.22, \"approach\": 0.20, \"attack\": 0.20,\n            \"chase\": 0.09, \"defend\": 0.14, \"escape\": 0.27,\n            \"flinch\": 0.01, \"follow\": 0.06, \"sniff\": 0.22,\n            \"sniffface\": 0.22, \"sniffgenital\": 0.03, \"tussle\": 0.005\n        },\n        \"7\": {\n            \"sniff\": 0.31, \"sniffgenital\": 0.27, \"approach\": 0.19,\n            \"defend\": 0.03, \"escape\": 0.10, \"attemptmount\": 0.02\n        },\n        \"8\": {\n            \"approach\": 0.21, \"attack\": 0.27, \"disengage\": 0.20,\n            \"mount\": 0.28, \"sniff\": 0.30, \"sniffgenital\": 0.23,\n            \"dominancemount\": 0.20, \"sniffbody\": 0.25, \"sniffface\": 0.27,\n            \"attemptmount\": 0.07, \"intromit\": 0.39, \"chase\": 0.03,\n            \"escape\": 0.16, \"reciprocalsniff\": 0.32, \"allogroom\": 0.11,\n            \"ejaculate\": 0.05, \"dominancegroom\": 0.03\n        },\n        \"9\": {\n            \"approach\": 0.06, \"attack\": 0.38, \"defend\": 0.24,\n            \"escape\": 0.41, \"sniff\": 0.12\n        }\n    },\n    \"default\": 0.23,\n    \"single_default\": 0.23,\n    \"pair_default\": 0.23\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, action_thresholds, section_id=None):\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\n        if len(vals) > 5:\n            vals = medfilt(vals, kernel_size=5)\n        \n        rolled = pd.Series(vals).rolling(\n            window=base_window, min_periods=1, center=True\n        ).mean().values\n        \n        gauss = gaussian_filter1d(rolled, sigma=sigma, mode='nearest')\n        pred_smoothed[col] = 0.75 * gauss + 0.25 * vals\n    \n    th_map = _select_threshold_map(action_thresholds, mode, section_id)\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    for i, action in enumerate(pred_smoothed.columns):\n        action_mask = (ama == i)\n        base_th = th_map[action]\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","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-11-22T07:12:40.664762Z","iopub.execute_input":"2025-11-22T07:12:40.665050Z","iopub.status.idle":"2025-11-22T07:12:40.686780Z","shell.execute_reply.started":"2025-11-22T07:12:40.665025Z","shell.execute_reply":"2025-11-22T07:12:40.685966Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"#============= 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","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-11-22T07:12:40.689155Z","iopub.execute_input":"2025-11-22T07:12:40.689774Z","iopub.status.idle":"2025-11-22T07:12:40.711554Z","shell.execute_reply.started":"2025-11-22T07:12:40.689747Z","shell.execute_reply":"2025-11-22T07:12:40.710763Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"#============= 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) >= 5:\n            idx = np.flatnonzero(mask)\n            X_action = X_tr_np[idx]\n            \n            max_cv_samples = 50000\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            else:\n                X_cv = X_action\n                y_cv = y_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                    X_train, X_val, y_train, y_val = train_test_split(\n                        X_cv, y_cv, test_size=0.25, random_state=42, stratify=y_cv\n                    )\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\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(pred, meta_te, ADAPTIVE_THRESHOLDS, section_id)\n                submission_list.append(sub_part)\n            else:\n                if verbose:\n                    print('    Error: no training data')\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()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-11-22T07:12:40.712343Z","iopub.execute_input":"2025-11-22T07:12:40.712601Z","iopub.status.idle":"2025-11-22T07:12:40.735778Z","shell.execute_reply.started":"2025-11-22T07:12:40.712575Z","shell.execute_reply":"2025-11-22T07:12:40.734960Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"#============= 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        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":{"trusted":true,"execution":{"iopub.status.busy":"2025-11-22T07:12:40.736781Z","iopub.execute_input":"2025-11-22T07:12:40.737058Z","execution_failed":"2025-11-22T07:36:04.087Z"}},"outputs":[],"execution_count":null}]}