{"metadata":{"kernelspec":{"display_name":"Python 3","language":"python","name":"python3"},"language_info":{"codemirror_mode":{"name":"ipython","version":3},"file_extension":".py","mimetype":"text/x-python","name":"python","nbconvert_exporter":"python","pygments_lexer":"ipython3","version":"3.11.11"},"kaggle":{"accelerator":"gpu","dataSources":[{"sourceId":102335,"sourceType":"competition"},{"sourceId":245653776,"sourceType":"kernelVersion"},{"sourceId":552395,"sourceType":"modelInstanceVersion"},{"sourceId":964433,"sourceType":"modelInstanceVersion"}],"dockerImageVersionId":31041,"isGpuEnabled":true,"isInternetEnabled":false,"language":"python","sourceType":"notebook"},"papermill":{"default_parameters":{},"duration":499.2673,"end_time":"2026-08-14T03:21:56.008388","environment_variables":{},"exception":null,"input_path":"__notebook__.ipynb","output_path":"__notebook__.ipynb","parameters":{},"start_time":"2026-08-14T03:13:36.741088","version":"2.6.0"}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"code","source":"# imu-only model + full model (测试集一半无tof thm, 训练集需mask一半; 测试时无tof thm时就用imu-only); patience, fold, batch size可调\n# 各fold要各自的scaler, 可能数据泄露; no tof-1无效值处理; 每个fold ema/swa (如早停附近的epochs/最好的作权重平均)\n\n# 待提升点：发现好/难区别的类别，类别权重；加handedness特征，预测难度大\n\nDEBUG = False\nTRAIN = False\ntof_thm = True  ###\nUSE_SWA_VARIANTS_AT_INFERENCE = True\nTOF_NEG1_AS_MISSING = False\n\nMAX_SEQ_LENGTH = 128\n\nFEATURE_NAMES = [\n    'acc_x', 'acc_y', 'acc_z',\n    'rot_w', 'rot_x', 'rot_y', 'rot_z',\n    'acc_mag', 'rot_angle', 'acc_mag_jerk', 'rot_angle_vel',\n    'linear_acc_x', 'linear_acc_y', 'linear_acc_z', 'linear_acc_mag', 'linear_acc_mag_jerk',\n    'angular_vel_x', 'angular_vel_y', 'angular_vel_z',\n    'angular_distance',\n]\ntof_features = [f'tof_{i}_v{j}' for i in range(1, 6) for j in range(64)]\nthm_features = [f'thm_{i}' for i in range(1, 6)]\n\nif tof_thm:\n    FEATURE_NAMES.extend(tof_features)\n    FEATURE_NAMES.extend(thm_features)\n\nCATEGORICAL_FEATURES = []\nNUMERICAL_FEATURES = [f for f in FEATURE_NAMES if f not in CATEGORICAL_FEATURES]\n\nLABEL_NAMES = [\n    'Forehead - pull hairline',\n    'Neck - pinch skin',\n    'Forehead - scratch',\n    'Eyelash - pull hair',\n    'Text on phone',\n    'Eyebrow - pull hair',\n    'Neck - scratch',\n    'Above ear - pull hair',\n    'Cheek - pinch skin',\n    'Wave hello',\n    'Write name in air',\n    'Pull air toward your face',\n    'Feel around in tray and pull out an object',\n    'Write name on leg',\n    'Pinch knee/leg skin',\n    'Scratch knee/leg skin',\n    'Drink from bottle/cup',\n    'Glasses on/off'\n]\nIDX2LABEL = {x: i for i, x in enumerate(LABEL_NAMES)}\n\nOUTPUT_DIR = './saved_models'","metadata":{"execution":{"iopub.execute_input":"2026-08-14T03:13:40.902501Z","iopub.status.busy":"2026-08-14T03:13:40.901894Z","iopub.status.idle":"2026-08-14T03:13:40.912726Z","shell.execute_reply":"2026-08-14T03:13:40.911836Z"},"papermill":{"duration":0.023327,"end_time":"2026-08-14T03:13:40.914195","exception":false,"start_time":"2026-08-14T03:13:40.890868","status":"completed"},"tags":[]},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import os\nif not os.path.exists(OUTPUT_DIR):\n    os.mkdir(OUTPUT_DIR)\n\nif TRAIN:\n    # 交互式session里如果之前已经跑过训练/调试，OUTPUT_DIR里可能还留着上次的\n    # fold_*.pth(包括SWA的swarecent/swabest变体)。不清掉的话这次训练的输出会跟\n    # 旧文件混在一起，分不清哪些是这次新训出来的，也可能让EnsemblePredictor之后\n    # glob到本不该在的旧文件。只在TRAIN=True(真的要训练)时清，纯推理不动它。\n    import glob as _glob\n    stale_files = _glob.glob(f'{OUTPUT_DIR}/fold_*.pth')\n    if stale_files:\n        print(f\"🧹 清理 {OUTPUT_DIR} 下 {len(stale_files)} 个旧的fold_*.pth (来自之前的交互式运行)\")\n        for _f in stale_files:\n            os.remove(_f)","metadata":{"execution":{"iopub.execute_input":"2026-08-14T03:13:40.931405Z","iopub.status.busy":"2026-08-14T03:13:40.930513Z","iopub.status.idle":"2026-08-14T03:13:40.936165Z","shell.execute_reply":"2026-08-14T03:13:40.935244Z"},"papermill":{"duration":0.015247,"end_time":"2026-08-14T03:13:40.937421","exception":false,"start_time":"2026-08-14T03:13:40.922174","status":"completed"},"tags":[]},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Load Data","metadata":{"papermill":{"duration":0.007491,"end_time":"2026-08-14T03:13:40.953153","exception":false,"start_time":"2026-08-14T03:13:40.945662","status":"completed"},"tags":[]}},{"cell_type":"code","source":"import pandas as pd\nimport numpy as np\nimport matplotlib.pyplot as plt\n%matplotlib inline\n\nfrom sklearn.preprocessing import StandardScaler, LabelEncoder\nimport joblib","metadata":{"_cell_guid":"b1076dfc-b9ad-4769-8c92-a6c4dae69d19","_uuid":"8f2839f25d086af736a60e9eeb907d3b93b6e0e5","execution":{"iopub.execute_input":"2026-08-14T03:13:40.969502Z","iopub.status.busy":"2026-08-14T03:13:40.969286Z","iopub.status.idle":"2026-08-14T03:13:43.475971Z","shell.execute_reply":"2026-08-14T03:13:43.475361Z"},"papermill":{"duration":2.516539,"end_time":"2026-08-14T03:13:43.477349","exception":false,"start_time":"2026-08-14T03:13:40.96081","status":"completed"},"tags":[]},"outputs":[],"execution_count":null},{"cell_type":"code","source":"DATA_ROOT = '/kaggle/input/cmi-detect-behavior-with-sensor-data/'\n\ntrain_df = pd.read_csv(f'{DATA_ROOT}/train.csv')\ntrain_demo_df = pd.read_csv(f'{DATA_ROOT}/train_demographics.csv')","metadata":{"execution":{"iopub.execute_input":"2026-08-14T03:13:43.492036Z","iopub.status.busy":"2026-08-14T03:13:43.491729Z","iopub.status.idle":"2026-08-14T03:14:12.627867Z","shell.execute_reply":"2026-08-14T03:14:12.626807Z"},"papermill":{"duration":29.145316,"end_time":"2026-08-14T03:14:12.629539","exception":false,"start_time":"2026-08-14T03:13:43.484223","status":"completed"},"tags":[]},"outputs":[],"execution_count":null},{"cell_type":"code","source":"train_df = pd.merge(\n    train_df,\n    train_demo_df,\n    how='left',\n    on='subject'\n)\n\ntrain_df.shape[0]","metadata":{"execution":{"iopub.execute_input":"2026-08-14T03:14:12.645023Z","iopub.status.busy":"2026-08-14T03:14:12.64433Z","iopub.status.idle":"2026-08-14T03:14:13.309086Z","shell.execute_reply":"2026-08-14T03:14:13.308328Z"},"papermill":{"duration":0.673026,"end_time":"2026-08-14T03:14:13.310322","exception":false,"start_time":"2026-08-14T03:14:12.637296","status":"completed"},"tags":[]},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Feature Engineering","metadata":{"papermill":{"duration":0.006714,"end_time":"2026-08-14T03:14:13.323878","exception":false,"start_time":"2026-08-14T03:14:13.317164","status":"completed"},"tags":[]}},{"cell_type":"code","source":"# Remove Gravity\n\nfrom scipy.spatial.transform import Rotation as R\n\ndef remove_gravity_from_acc(acc_data, rot_data):\n\n    if isinstance(acc_data, pd.DataFrame):\n        acc_values = acc_data[['acc_x', 'acc_y', 'acc_z']].values\n    else:\n        acc_values = acc_data\n\n    if isinstance(rot_data, pd.DataFrame):\n        quat_values = rot_data[['rot_x', 'rot_y', 'rot_z', 'rot_w']].values\n    else:\n        quat_values = rot_data\n\n    num_samples = acc_values.shape[0]\n    linear_accel = np.zeros_like(acc_values)\n    \n    gravity_world = np.array([0, 0, 9.81])\n\n    for i in range(num_samples):\n        if np.all(np.isnan(quat_values[i])) or np.all(np.isclose(quat_values[i], 0)):\n            linear_accel[i, :] = acc_values[i, :] \n            continue\n\n        try:\n            rotation = R.from_quat(quat_values[i])\n            gravity_sensor_frame = rotation.apply(gravity_world, inverse=True)\n            linear_accel[i, :] = acc_values[i, :] - gravity_sensor_frame\n        except ValueError:\n             linear_accel[i, :] = acc_values[i, :]\n             \n    return linear_accel\n\ndef calculate_angular_velocity_from_quat(rot_data, time_delta=1/200): # Assuming 200Hz sampling rate\n    if isinstance(rot_data, pd.DataFrame):\n        quat_values = rot_data[['rot_x', 'rot_y', 'rot_z', 'rot_w']].values\n    else:\n        quat_values = rot_data\n\n    num_samples = quat_values.shape[0]\n    angular_vel = np.zeros((num_samples, 3))\n\n    for i in range(num_samples - 1):\n        q_t = quat_values[i]\n        q_t_plus_dt = quat_values[i+1]\n\n        if np.all(np.isnan(q_t)) or np.all(np.isclose(q_t, 0)) or \\\n           np.all(np.isnan(q_t_plus_dt)) or np.all(np.isclose(q_t_plus_dt, 0)):\n            continue\n\n        try:\n            rot_t = R.from_quat(q_t)\n            rot_t_plus_dt = R.from_quat(q_t_plus_dt)\n\n            # Calculate the relative rotation\n            delta_rot = rot_t.inv() * rot_t_plus_dt\n            \n            # Convert delta rotation to angular velocity vector\n            # The rotation vector (Euler axis * angle) scaled by 1/dt\n            # is a good approximation for small delta_rot\n            angular_vel[i, :] = delta_rot.as_rotvec() / time_delta\n        except ValueError:\n            # If quaternion is invalid, angular velocity remains zero\n            pass\n            \n    return angular_vel\n\ndef calculate_angular_distance(rot_data):\n    if isinstance(rot_data, pd.DataFrame):\n        quat_values = rot_data[['rot_x', 'rot_y', 'rot_z', 'rot_w']].values\n    else:\n        quat_values = rot_data\n\n    num_samples = quat_values.shape[0]\n    angular_dist = np.zeros(num_samples)\n\n    for i in range(num_samples - 1):\n        q1 = quat_values[i]\n        q2 = quat_values[i+1]\n\n        if np.all(np.isnan(q1)) or np.all(np.isclose(q1, 0)) or \\\n           np.all(np.isnan(q2)) or np.all(np.isclose(q2, 0)):\n            angular_dist[i] = 0 # Или np.nan, в зависимости от желаемого поведения\n            continue\n        try:\n            # Преобразование кватернионов в объекты Rotation\n            r1 = R.from_quat(q1)\n            r2 = R.from_quat(q2)\n\n            # Вычисление углового расстояния: 2 * arccos(|real(p * q*)|)\n            # где p* - сопряженный кватернион q\n            # В scipy.spatial.transform.Rotation, r1.inv() * r2 дает относительное вращение.\n            # Угол этого относительного вращения - это и есть угловое расстояние.\n            relative_rotation = r1.inv() * r2\n            \n            # Угол rotation vector соответствует угловому расстоянию\n            # Норма rotation vector - это угол в радианах\n            angle = np.linalg.norm(relative_rotation.as_rotvec())\n            angular_dist[i] = angle\n        except ValueError:\n            angular_dist[i] = 0 # В случае недействительных кватернионов\n            pass\n            \n    return angular_dist","metadata":{"execution":{"iopub.execute_input":"2026-08-14T03:14:13.338136Z","iopub.status.busy":"2026-08-14T03:14:13.337502Z","iopub.status.idle":"2026-08-14T03:14:13.35051Z","shell.execute_reply":"2026-08-14T03:14:13.34973Z"},"papermill":{"duration":0.021184,"end_time":"2026-08-14T03:14:13.351657","exception":false,"start_time":"2026-08-14T03:14:13.330473","status":"completed"},"tags":[]},"outputs":[],"execution_count":null},{"cell_type":"code","source":"def feature_engineering(train_df):\n    if TOF_NEG1_AS_MISSING:\n        tof_cols_present = [c for c in tof_features if c in train_df.columns]\n        if tof_cols_present:\n            train_df[tof_cols_present] = train_df[tof_cols_present].replace(-1.0, np.nan)\n\n    # IMU magnitude\n    train_df['acc_mag'] = np.sqrt(train_df['acc_x']**2 + train_df['acc_y']**2 + train_df['acc_z']**2)\n    \n    # IMU angle\n    train_df['rot_angle'] = 2 * np.arccos(train_df['rot_w'].clip(-1, 1))\n    \n    # IMU jerk, angular velocity\n    train_df['acc_mag_jerk'] = train_df.groupby('sequence_id')['acc_mag'].diff().fillna(0)\n    train_df['rot_angle_vel'] = train_df.groupby('sequence_id')['rot_angle'].diff().fillna(0)\n    \n    # Remove gravity\n    def get_linear_accel(df):\n        res = remove_gravity_from_acc(\n            df[['acc_x', 'acc_y', 'acc_z']],\n            df[['rot_x', 'rot_y', 'rot_z', 'rot_w']]\n        )\n        res = pd.DataFrame(res, columns=['linear_acc_x', 'linear_acc_y', 'linear_acc_z'], index=df.index)\n        return res\n    \n    linear_accel_df = train_df.groupby('sequence_id').apply(get_linear_accel, include_groups=False)\n    linear_accel_df = linear_accel_df.droplevel('sequence_id')\n    train_df = train_df.join(linear_accel_df)\n    \n    train_df['linear_acc_mag'] = np.sqrt(train_df['linear_acc_x']**2 + train_df['linear_acc_y']**2 + train_df['linear_acc_z']**2)\n    train_df['linear_acc_mag_jerk'] = train_df.groupby('sequence_id')['linear_acc_mag'].diff().fillna(0)\n\n    # Calc angular velocity\n    def calc_angular_velocity(df):\n        res = calculate_angular_velocity_from_quat( df[['rot_x', 'rot_y', 'rot_z', 'rot_w']] )\n        res = pd.DataFrame(res, columns=['angular_vel_x', 'angular_vel_y', 'angular_vel_z'], index=df.index)\n        return res\n    \n    angular_velocity_df = train_df.groupby('sequence_id').apply(calc_angular_velocity, include_groups=False)\n    angular_velocity_df = angular_velocity_df.droplevel('sequence_id')\n    train_df = train_df.join(angular_velocity_df)\n\n    # Calculating angular distance\n    def calc_angular_distance(df):\n        res = calculate_angular_distance(df[['rot_x', 'rot_y', 'rot_z', 'rot_w']])\n        res = pd.DataFrame(res, columns=['angular_distance'], index=df.index)\n        return res\n    \n    angular_distance_df = train_df.groupby('sequence_id').apply(calc_angular_distance, include_groups=False)\n    angular_distance_df = angular_distance_df.droplevel('sequence_id')\n    train_df = train_df.join(angular_distance_df)\n\n    train_df[NUMERICAL_FEATURES] = train_df.groupby('sequence_id')[FEATURE_NAMES].ffill().bfill().fillna(0).values.astype('float32') ### 更准确\n    \n    return train_df","metadata":{"execution":{"iopub.execute_input":"2026-08-14T03:14:13.366002Z","iopub.status.busy":"2026-08-14T03:14:13.365649Z","iopub.status.idle":"2026-08-14T03:14:13.374453Z","shell.execute_reply":"2026-08-14T03:14:13.373681Z"},"papermill":{"duration":0.017452,"end_time":"2026-08-14T03:14:13.37555","exception":false,"start_time":"2026-08-14T03:14:13.358098","status":"completed"},"tags":[]},"outputs":[],"execution_count":null},{"cell_type":"code","source":"train_df = feature_engineering(train_df)","metadata":{"execution":{"iopub.execute_input":"2026-08-14T03:14:13.390015Z","iopub.status.busy":"2026-08-14T03:14:13.389353Z","iopub.status.idle":"2026-08-14T03:17:42.786061Z","shell.execute_reply":"2026-08-14T03:17:42.785072Z"},"papermill":{"duration":209.405904,"end_time":"2026-08-14T03:17:42.788067","exception":false,"start_time":"2026-08-14T03:14:13.382163","status":"completed"},"tags":[]},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"### Standard","metadata":{"papermill":{"duration":0.006577,"end_time":"2026-08-14T03:17:42.806109","exception":false,"start_time":"2026-08-14T03:17:42.799532","status":"completed"},"tags":[]}},{"cell_type":"code","source":"feature_scaler = StandardScaler()\nfeature_scaler.fit(train_df[NUMERICAL_FEATURES])\njoblib.dump(feature_scaler, f'{OUTPUT_DIR}/feature_scaler.joblib')","metadata":{"execution":{"iopub.execute_input":"2026-08-14T03:17:42.820792Z","iopub.status.busy":"2026-08-14T03:17:42.820505Z","iopub.status.idle":"2026-08-14T03:17:45.221351Z","shell.execute_reply":"2026-08-14T03:17:45.220647Z"},"papermill":{"duration":2.409615,"end_time":"2026-08-14T03:17:45.222479","exception":false,"start_time":"2026-08-14T03:17:42.812864","status":"completed"},"tags":[]},"outputs":[],"execution_count":null},{"cell_type":"code","source":"train_df[NUMERICAL_FEATURES] = feature_scaler.transform(train_df[NUMERICAL_FEATURES])","metadata":{"execution":{"iopub.execute_input":"2026-08-14T03:17:45.236994Z","iopub.status.busy":"2026-08-14T03:17:45.236798Z","iopub.status.idle":"2026-08-14T03:17:47.434274Z","shell.execute_reply":"2026-08-14T03:17:47.433612Z"},"papermill":{"duration":2.206469,"end_time":"2026-08-14T03:17:47.435866","exception":false,"start_time":"2026-08-14T03:17:45.229397","status":"completed"},"tags":[]},"outputs":[],"execution_count":null},{"cell_type":"code","source":"len(NUMERICAL_FEATURES)","metadata":{"execution":{"iopub.execute_input":"2026-08-14T03:17:47.452152Z","iopub.status.busy":"2026-08-14T03:17:47.451446Z","iopub.status.idle":"2026-08-14T03:17:47.456108Z","shell.execute_reply":"2026-08-14T03:17:47.455471Z"},"papermill":{"duration":0.013217,"end_time":"2026-08-14T03:17:47.457165","exception":false,"start_time":"2026-08-14T03:17:47.443948","status":"completed"},"tags":[]},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"### Convert to Sequence","metadata":{"papermill":{"duration":0.0064,"end_time":"2026-08-14T03:17:47.470281","exception":false,"start_time":"2026-08-14T03:17:47.463881","status":"completed"},"tags":[]}},{"cell_type":"code","source":"agg_train_df = train_df.groupby(['sequence_id', 'subject', 'gesture']).apply(\n    lambda df: df[FEATURE_NAMES].to_dict(orient='records'),\n    include_groups=False,\n)\n\nagg_train_df = agg_train_df.reset_index()\nagg_train_df.columns = ['sequence_id', 'subject', 'gesture', 'sequence']","metadata":{"execution":{"iopub.execute_input":"2026-08-14T03:17:47.484243Z","iopub.status.busy":"2026-08-14T03:17:47.483741Z","iopub.status.idle":"2026-08-14T03:21:40.409889Z","shell.execute_reply":"2026-08-14T03:21:40.409142Z"},"papermill":{"duration":232.935078,"end_time":"2026-08-14T03:21:40.411736","exception":false,"start_time":"2026-08-14T03:17:47.476658","status":"completed"},"tags":[]},"outputs":[],"execution_count":null},{"cell_type":"code","source":"agg_train_df['label'] = agg_train_df.gesture.map(IDX2LABEL)","metadata":{"execution":{"iopub.execute_input":"2026-08-14T03:21:40.431189Z","iopub.status.busy":"2026-08-14T03:21:40.430938Z","iopub.status.idle":"2026-08-14T03:21:40.4369Z","shell.execute_reply":"2026-08-14T03:21:40.435994Z"},"papermill":{"duration":0.016824,"end_time":"2026-08-14T03:21:40.438148","exception":false,"start_time":"2026-08-14T03:21:40.421324","status":"completed"},"tags":[]},"outputs":[],"execution_count":null},{"cell_type":"code","source":"agg_train_df.head()","metadata":{"execution":{"iopub.execute_input":"2026-08-14T03:21:40.45471Z","iopub.status.busy":"2026-08-14T03:21:40.454272Z","iopub.status.idle":"2026-08-14T03:21:40.72611Z","shell.execute_reply":"2026-08-14T03:21:40.72541Z"},"papermill":{"duration":0.281405,"end_time":"2026-08-14T03:21:40.727215","exception":false,"start_time":"2026-08-14T03:21:40.44581","status":"completed"},"tags":[]},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Data Augmentation","metadata":{"papermill":{"duration":0.00668,"end_time":"2026-08-14T03:21:40.741633","exception":false,"start_time":"2026-08-14T03:21:40.734953","status":"completed"},"tags":[]}},{"cell_type":"code","source":"import torch\nimport numpy as np\nimport random\nfrom typing import Dict, List, Optional, Tuple\nfrom scipy import signal\nfrom scipy.interpolate import interp1d\n\n\nclass TimeSeriesAugmentation:\n    \"\"\"时序数据增强类，专门用于IMU和ToF传感器数据\"\"\"\n    \n    def __init__(self, \n                 time_stretch_range: Tuple[float, float] = (0.8, 1.2),\n                 time_shift_range: float = 0.1,\n                 noise_std: float = 0.02,\n                 magnitude_scale_range: Tuple[float, float] = (0.9, 1.1),\n                 rotation_angle_range: float = 0.1,\n                 mask_ratio: float = 0.1,\n                 freq_filter_range: Tuple[float, float] = (0.1, 0.9),\n                 aug_prob: float = 0.8):\n        \"\"\"\n        初始化数据增强参数\n        \n        Args:\n            time_stretch_range: 时间拉伸范围 (最小比例, 最大比例)\n            time_shift_range: 时间移位范围 (序列长度的比例)\n            noise_std: 高斯噪声标准差\n            magnitude_scale_range: 幅度缩放范围\n            rotation_angle_range: 旋转角度范围 (弧度)\n            mask_ratio: 时间遮盖比例\n            freq_filter_range: 频率滤波范围\n            aug_prob: 每种增强方法的应用概率\n        \"\"\"\n        self.time_stretch_range = time_stretch_range\n        self.time_shift_range = time_shift_range\n        self.noise_std = noise_std\n        self.magnitude_scale_range = magnitude_scale_range\n        self.rotation_angle_range = rotation_angle_range\n        self.mask_ratio = mask_ratio\n        self.freq_filter_range = freq_filter_range\n        self.aug_prob = aug_prob\n        \n        # IMU特征索引 (前7个特征: acc_x, acc_y, acc_z, rot_w, rot_x, rot_y, rot_z)\n        self.imu_indices = list(range(7))\n        self.acc_indices = [0, 1, 2]  # 加速度\n        self.rot_indices = [3, 4, 5, 6]  # 四元数旋转\n    \n    def __call__(self, sequence: np.ndarray) -> np.ndarray:\n        \"\"\"\n        对序列应用随机增强\n        \n        Args:\n            sequence: 输入序列 (seq_len, num_features)\n        \n        Returns:\n            augmented_sequence: 增强后的序列\n        \"\"\"\n        augmented = sequence.copy()\n        \n        # 随机应用各种增强方法\n        if random.random() < self.aug_prob:\n            augmented = self.time_stretch(augmented)\n        \n        if random.random() < self.aug_prob:\n            augmented = self.time_shift(augmented)\n        \n        if random.random() < self.aug_prob:\n            augmented = self.add_noise(augmented)\n        \n        if random.random() < self.aug_prob:\n            augmented = self.magnitude_scale(augmented)\n        \n        if random.random() < self.aug_prob:\n            augmented = self.rotate_imu(augmented)\n        \n        if random.random() < self.aug_prob:\n            augmented = self.time_mask(augmented)\n        \n        if random.random() < self.aug_prob:\n            augmented = self.frequency_filter(augmented)\n        \n        return augmented\n    \n    def time_stretch(self, sequence: np.ndarray) -> np.ndarray:\n        \"\"\"时间拉伸/压缩\"\"\"\n        seq_len, num_features = sequence.shape\n        stretch_factor = random.uniform(*self.time_stretch_range)\n        \n        # 创建新的时间采样点\n        original_time = np.linspace(0, 1, seq_len)\n        new_length = max(1, int(seq_len / stretch_factor))\n        new_time = np.linspace(0, 1, new_length)\n        \n        # 对每个特征进行插值\n        stretched_sequence = np.zeros((new_length, num_features))\n        for i in range(num_features):\n            interpolator = interp1d(original_time, sequence[:, i], \n                                  kind='linear', bounds_error=False, \n                                  fill_value='extrapolate')\n            stretched_sequence[:, i] = interpolator(new_time)\n        \n        # 如果序列变长，截断到原长度；如果变短，用零填充\n        if new_length > seq_len:\n            return stretched_sequence[:seq_len]\n        else:\n            padded = np.zeros((seq_len, num_features))\n            padded[:new_length] = stretched_sequence\n            return padded\n    \n    def time_shift(self, sequence: np.ndarray) -> np.ndarray:\n        \"\"\"时间移位\"\"\"\n        seq_len, num_features = sequence.shape\n        max_shift = int(seq_len * self.time_shift_range)\n        shift = random.randint(-max_shift, max_shift)\n        \n        if shift == 0:\n            return sequence\n        \n        shifted = np.zeros_like(sequence)\n        if shift > 0:\n            shifted[shift:] = sequence[:-shift]\n        else:\n            shifted[:shift] = sequence[-shift:]\n        \n        return shifted\n    \n    def add_noise(self, sequence: np.ndarray) -> np.ndarray:\n        \"\"\"添加高斯噪声\"\"\"\n        noise = np.random.normal(0, self.noise_std, sequence.shape)\n        return sequence + noise\n    \n    def magnitude_scale(self, sequence: np.ndarray) -> np.ndarray:\n        \"\"\"幅度缩放\"\"\"\n        scale_factor = random.uniform(*self.magnitude_scale_range)\n        scaled = sequence.copy()\n        \n        # 只对IMU数据进行缩放，保持其他特征不变\n        scaled[:, self.imu_indices] *= scale_factor\n        \n        return scaled\n    \n    def rotate_imu(self, sequence: np.ndarray) -> np.ndarray:\n        \"\"\"对IMU数据应用小角度旋转\"\"\"\n        rotated = sequence.copy()\n        \n        # 生成小角度旋转矩阵\n        angle = random.uniform(-self.rotation_angle_range, self.rotation_angle_range)\n        cos_a, sin_a = np.cos(angle), np.sin(angle)\n        \n        # 只旋转加速度数据 (简化版本，只在xy平面旋转)\n        acc_x = rotated[:, 0] * cos_a - rotated[:, 1] * sin_a\n        acc_y = rotated[:, 0] * sin_a + rotated[:, 1] * cos_a\n        \n        rotated[:, 0] = acc_x\n        rotated[:, 1] = acc_y\n        \n        return rotated\n    \n    def time_mask(self, sequence: np.ndarray) -> np.ndarray:\n        \"\"\"时间遮盖 - 随机遮盖一段时间窗口\"\"\"\n        seq_len, num_features = sequence.shape\n        mask_length = int(seq_len * self.mask_ratio)\n        \n        if mask_length == 0:\n            return sequence\n        \n        start_idx = random.randint(0, seq_len - mask_length)\n        masked = sequence.copy()\n        \n        # 用前后平均值填充遮盖区域\n        if start_idx > 0 and start_idx + mask_length < seq_len:\n            before_mean = np.mean(masked[max(0, start_idx-5):start_idx], axis=0)\n            after_mean = np.mean(masked[start_idx+mask_length:min(seq_len, start_idx+mask_length+5)], axis=0)\n            fill_value = (before_mean + after_mean) / 2\n        else:\n            fill_value = np.mean(masked, axis=0)\n        \n        masked[start_idx:start_idx+mask_length] = fill_value\n        \n        return masked\n    \n    def frequency_filter(self, sequence: np.ndarray) -> np.ndarray:\n        \"\"\"频域滤波\"\"\"\n        seq_len, num_features = sequence.shape\n        if seq_len < 10:  # 序列太短，跳过滤波\n            return sequence\n        \n        filtered = sequence.copy()\n        \n        # 只对IMU数据进行滤波\n        for i in self.imu_indices:\n            # 应用低通滤波或高通滤波\n            if random.random() < 0.5:  # 低通滤波\n                cutoff = random.uniform(*self.freq_filter_range)\n                sos = signal.butter(2, cutoff, btype='low', output='sos')\n            else:  # 高通滤波\n                cutoff = random.uniform(0.05, self.freq_filter_range[0])\n                sos = signal.butter(2, cutoff, btype='high', output='sos')\n            \n            try:\n                filtered[:, i] = signal.sosfilt(sos, sequence[:, i])\n            except:\n                # 如果滤波失败，保持原数据\n                pass\n        \n        return filtered\n\n\nclass MixupAugmentation:\n    \"\"\"Mixup数据增强 - 混合两个不同的序列\"\"\"\n    \n    def __init__(self, alpha: float = 0.2, prob: float = 0.5):\n        \"\"\"\n        Args:\n            alpha: Beta分布参数\n            prob: 应用Mixup的概率\n        \"\"\"\n        self.alpha = alpha\n        self.prob = prob\n    \n    def __call__(self, batch_sequences: torch.Tensor, batch_labels: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor, torch.Tensor, float]:\n        \"\"\"\n        对批次数据应用Mixup\n        \n        Args:\n            batch_sequences: (batch_size, seq_len, num_features)\n            batch_labels: (batch_size,)\n        \n        Returns:\n            mixed_sequences: 混合后的序列\n            labels_a: 原始标签\n            labels_b: 混合标签\n            lambda_: 混合比例\n        \"\"\"\n        if random.random() > self.prob:\n            return batch_sequences, batch_labels, batch_labels, 1.0\n        \n        batch_size = batch_sequences.size(0)\n        lambda_ = np.random.beta(self.alpha, self.alpha) if self.alpha > 0 else 1\n        \n        # 随机打乱顺序\n        index = torch.randperm(batch_size)\n        \n        # 混合序列\n        mixed_sequences = lambda_ * batch_sequences + (1 - lambda_) * batch_sequences[index]\n        \n        labels_a = batch_labels\n        labels_b = batch_labels[index]\n        \n        return mixed_sequences, labels_a, labels_b, lambda_\n\n\ndef mixup_criterion(criterion, outputs, labels_a, labels_b, lambda_):\n    \"\"\"Mixup损失函数\"\"\"\n    return lambda_ * criterion(outputs, labels_a) + (1 - lambda_) * criterion(outputs, labels_b)","metadata":{"execution":{"iopub.execute_input":"2026-08-14T03:21:40.756211Z","iopub.status.busy":"2026-08-14T03:21:40.756007Z","iopub.status.idle":"2026-08-14T03:21:44.788736Z","shell.execute_reply":"2026-08-14T03:21:44.78771Z"},"papermill":{"duration":4.041943,"end_time":"2026-08-14T03:21:44.790258","exception":false,"start_time":"2026-08-14T03:21:40.748315","status":"completed"},"tags":[]},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## DataLoader","metadata":{"papermill":{"duration":0.006476,"end_time":"2026-08-14T03:21:44.80385","exception":false,"start_time":"2026-08-14T03:21:44.797374","status":"completed"},"tags":[]}},{"cell_type":"code","source":"import torch\nimport numpy as np\nfrom torch.utils.data import Dataset, DataLoader\nfrom sklearn.model_selection import StratifiedGroupKFold, GroupKFold\nimport pandas as pd\nfrom typing import List, Dict\n\nclass SequenceDataset(Dataset):\n    def __init__(self, sequences: List[List[Dict]], labels: List[int], is_training=True, use_augmentation=True, augmentation_params=None, feature_names=None):\n        \"\"\"\n        Args:\n            sequences: 序列数据列表，每个序列包含多个时间步的特征字典\n            labels: 对应的标签列表\n            feature_names: 提取特征的顺序/列表。默认为全局FEATURE_NAMES，\n                           但推理时(尤其是imu-only分支)必须显式传入和sequence字典里\n                           实际存在的key一致的列表，否则会在tof_thm=True的全局状态下\n                           用345个特征名去索引只有20个key的字典，抛KeyError。\n        \"\"\"\n        self.sequences = sequences\n        self.labels = labels\n        self.is_training = is_training\n        self.use_augmentation = use_augmentation and is_training\n        self.feature_names = feature_names if feature_names is not None else FEATURE_NAMES\n        \n        # 转换为numpy数组格式并截断\n        self.processed_sequences = self._process_sequences()\n\n        # 初始化数据增强器\n        if self.use_augmentation:\n            if augmentation_params is None:\n                augmentation_params = {\n                    'time_stretch_range': (0.8, 1.2),\n                    'time_shift_range': 0.1,\n                    'noise_std': 0.02,\n                    'magnitude_scale_range': (0.9, 1.1),\n                    'rotation_angle_range': 0.1,\n                    'mask_ratio': 0.1,\n                    'freq_filter_range': (0.1, 0.9),\n                    'aug_prob': 0.5  ### 可ablation,训练时降低概率，避免过度增强\n                }\n            self.augmenter = TimeSeriesAugmentation(**augmentation_params)\n            print(f\"✅ 数据增强已启用 (训练模式: {self.is_training})\")\n        else:\n            self.augmenter = None\n            print(f\"❌ 数据增强已禁用 (训练模式: {self.is_training})\")\n        \n        \n    def _process_sequences(self):\n        \"\"\"将字典格式的序列转换为numpy数组并截断到最大长度\"\"\"\n        processed = []\n        \n        for seq in self.sequences:\n            # 截断到最大长度\n            seq = seq[:MAX_SEQ_LENGTH]\n            \n            seq_array = []\n            for timestep in seq:\n                # 按照feature_names的顺序提取特征\n                features = [timestep[feature] for feature in self.feature_names]\n                seq_array.append(features)\n            processed.append(np.array(seq_array, dtype=np.float32))\n        \n        return processed\n    \n    def __len__(self):\n        return len(self.sequences)\n    \n    def __getitem__(self, idx):\n        sequence = self.processed_sequences[idx].copy()\n        label = torch.LongTensor([self.labels[idx]])[0]\n\n        # 在训练时应用数据增强\n        if self.use_augmentation and self.augmenter is not None:\n            sequence = self.augmenter(sequence)\n\n        if self.is_training and tof_thm and random.random() < 0.5: ### 训练集中mask一半\n            tof_indices = [i for i, f in enumerate(FEATURE_NAMES) if 'tof' in f]\n            thm_indices = [i for i, f in enumerate(FEATURE_NAMES) if 'thm' in f]\n            tof_thm_indices = tof_indices + thm_indices\n            sequence[:, tof_thm_indices] = 0\n        \n        sequence = torch.FloatTensor(sequence)\n        return sequence, label\n\n\ndef collate_fn(batch):\n    \"\"\"自定义批处理函数，处理不同长度的序列\"\"\"\n    sequences, labels = zip(*batch)\n    \n    # 获取批次中的最大序列长度\n    max_length = max(seq.shape[0] for seq in sequences)\n    \n    # 填充序列到相同长度\n    padded_sequences = []\n    lengths = []\n    \n    for seq in sequences:\n        seq_len = seq.shape[0]\n        lengths.append(seq_len)\n        \n        if seq_len < max_length:\n            # 使用零填充\n            padding = torch.zeros(max_length - seq_len, seq.shape[1])\n            padded_seq = torch.cat([seq, padding], dim=0)\n        else:\n            padded_seq = seq\n        \n        padded_sequences.append(padded_seq)\n    \n    return torch.stack(padded_sequences), torch.stack(labels), torch.LongTensor(lengths)\n\n\ndef create_kfold_splits(sequences, labels, subjects, n_splits=5, random_state=42):\n    \"\"\"使用StratifiedGroupKFold创建K折分割\"\"\"\n    sequences = np.array(sequences, dtype=object)\n    labels = np.array(labels)\n    subjects = np.array(subjects)\n\n    if sequences.shape[0] > 1000:\n        sgkf = StratifiedGroupKFold(n_splits=n_splits, shuffle=True, random_state=random_state)\n    else:\n        n_splits = 2\n        sgkf = GroupKFold(n_splits=n_splits, shuffle=True, random_state=random_state)\n    \n    splits = []\n    for fold_idx, (train_idx, val_idx) in enumerate(sgkf.split(sequences, labels, subjects)):\n        splits.append((train_idx, val_idx))\n        print(f\"   Fold {fold_idx+1}: 训练={len(train_idx)}, 验证={len(val_idx)}\")\n    \n    print(f\"✅ 成功创建{n_splits}折交叉验证分割\")\n    return splits\n\n\ndef create_fold_loaders(sequences, labels, train_idx, val_idx, batch_size=32, use_augmentation=True, augmentation_params=None, use_mixup=False, mixup_params=None):\n    \"\"\"为特定的折创建数据加载器，使用全局scaler\"\"\"\n    # 根据索引分割数据\n    train_sequences = [sequences[i] for i in train_idx]\n    train_labels = [labels[i] for i in train_idx]\n    val_sequences = [sequences[i] for i in val_idx]\n    val_labels = [labels[i] for i in val_idx]\n    \n    # 创建数据集，使用全局scaler\n    train_dataset = SequenceDataset(train_sequences, train_labels, is_training=True, use_augmentation=use_augmentation, augmentation_params=augmentation_params)\n    val_dataset = SequenceDataset(val_sequences, val_labels, is_training=False, use_augmentation=False)\n    \n    # 创建数据加载器\n    train_loader = DataLoader(train_dataset, batch_size=batch_size, shuffle=True, collate_fn=collate_fn)\n    val_loader = DataLoader(val_dataset, batch_size=batch_size, shuffle=False, collate_fn=collate_fn)\n\n    # 创建Mixup增强器\n    mixup_augmenter = None\n    if use_mixup:\n        if mixup_params is None:\n            mixup_params = {'alpha': 0.2, 'prob': 0.5}\n        mixup_augmenter = MixupAugmentation(**mixup_params)\n        print(f\"✅ Mixup数据增强已启用: alpha={mixup_params['alpha']}, prob={mixup_params['prob']}\")\n    \n    return train_loader, val_loader, mixup_augmenter","metadata":{"execution":{"iopub.execute_input":"2026-08-14T03:21:44.818491Z","iopub.status.busy":"2026-08-14T03:21:44.818148Z","iopub.status.idle":"2026-08-14T03:21:44.913748Z","shell.execute_reply":"2026-08-14T03:21:44.91296Z"},"papermill":{"duration":0.104645,"end_time":"2026-08-14T03:21:44.915052","exception":false,"start_time":"2026-08-14T03:21:44.810407","status":"completed"},"tags":[]},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Model","metadata":{"papermill":{"duration":0.006633,"end_time":"2026-08-14T03:21:44.928627","exception":false,"start_time":"2026-08-14T03:21:44.921994","status":"completed"},"tags":[]}},{"cell_type":"code","source":"import torch\nimport torch.nn as nn\nimport torch.nn.functional as F\n\nclass SEBlock(nn.Module):\n    def __init__(self, channels, reduction=8):\n        super().__init__()\n        self.squeeze = nn.AdaptiveAvgPool1d(1)\n        self.excitation = nn.Sequential(\n            nn.Linear(channels, channels // reduction, bias=False),\n            nn.ReLU(inplace=True),\n            nn.Linear(channels // reduction, channels, bias=False),\n            nn.Sigmoid()\n        )\n    \n    def forward(self, x):\n        b, c, _ = x.size()\n        y = self.squeeze(x).view(b, c)\n        y = self.excitation(y).view(b, c, 1)\n        return x * y.expand_as(x)\n\nclass ResidualSECNNBlock(nn.Module):\n    def __init__(self, in_channels, out_channels, kernel_size, pool_size=2, dropout=0.3, weight_decay=1e-4):\n        super().__init__()\n        \n        # First conv block\n        self.conv1 = nn.Conv1d(in_channels, out_channels, kernel_size, padding=kernel_size//2, bias=False)\n        self.bn1 = nn.BatchNorm1d(out_channels)\n        \n        # Second conv block\n        self.conv2 = nn.Conv1d(out_channels, out_channels, kernel_size, padding=kernel_size//2, bias=False)\n        self.bn2 = nn.BatchNorm1d(out_channels)\n        \n        # SE block\n        self.se = SEBlock(out_channels)\n        \n        # Shortcut connection\n        self.shortcut = nn.Sequential()\n        if in_channels != out_channels:\n            self.shortcut = nn.Sequential(\n                nn.Conv1d(in_channels, out_channels, 1, bias=False),\n                nn.BatchNorm1d(out_channels)\n            )\n        \n        self.pool = nn.MaxPool1d(pool_size)\n        self.dropout = nn.Dropout(dropout)\n        \n    def forward(self, x):\n        shortcut = self.shortcut(x)\n        \n        # First conv\n        out = F.relu(self.bn1(self.conv1(x)))\n        # Second conv\n        out = self.bn2(self.conv2(out))\n        \n        # SE block\n        out = self.se(out)\n        \n        # Add shortcut\n        out += shortcut\n        out = F.relu(out)\n        \n        # Pool and dropout\n        out = self.pool(out)\n        out = self.dropout(out)\n        \n        return out\n\nclass AttentionLayer(nn.Module):\n    def __init__(self, hidden_dim):\n        super().__init__()\n        self.attention = nn.Linear(hidden_dim, 1)\n        \n    def forward(self, x):\n        # x shape: (batch, seq_len, hidden_dim)\n        scores = torch.tanh(self.attention(x))  # (batch, seq_len, 1)\n        weights = F.softmax(scores.squeeze(-1), dim=1)  # (batch, seq_len)\n        context = torch.sum(x * weights.unsqueeze(-1), dim=1)  # (batch, hidden_dim)\n        return context\n\nclass TwoBranchModel(nn.Module):\n    def __init__(self, imu_dim, tof_dim, n_classes, weight_decay=1e-4):\n        super().__init__()\n        self.imu_dim = imu_dim\n        self.tof_dim = tof_dim\n        self.n_classes = n_classes\n        self.weight_decay = weight_decay\n        # 关键修复: 在构造时把全局tof_thm\"快照\"成实例属性self.use_tof。\n        # 原来forward()里直接读全局tof_thm，但EnsemblePredictor初始化时会先把\n        # 全局tof_thm临时置False来构造imu-only模型(不建tof分支层)，再恢复成True。\n        # 恢复之后，imu-only模型在forward()时读到的全局tof_thm已经是True，\n        # 于是尝试访问从未创建过的self.tof_conv1等层，抛AttributeError。\n        # 用实例属性冻住\"构造那一刻\"的取值，就不会再被后续的全局状态变化影响。\n        self.use_tof = tof_thm\n        \n        # IMU deep branch\n        self.imu_block1 = ResidualSECNNBlock(imu_dim, 64, 3, dropout=0.3, weight_decay=weight_decay)\n        self.imu_block2 = ResidualSECNNBlock(64, 128, 5, dropout=0.3, weight_decay=weight_decay)\n        \n        ### TOF/Thermal lighter branch\n        if self.use_tof:\n            self.tof_conv1 = nn.Conv1d(tof_dim, 64, 3, padding=1, bias=False)\n            self.tof_bn1 = nn.BatchNorm1d(64)\n            self.tof_pool1 = nn.MaxPool1d(2)\n            self.tof_drop1 = nn.Dropout(0.3)\n            \n            self.tof_conv2 = nn.Conv1d(64, 128, 3, padding=1, bias=False)\n            self.tof_bn2 = nn.BatchNorm1d(128)\n            self.tof_pool2 = nn.MaxPool1d(2)\n            self.tof_drop2 = nn.Dropout(0.3)\n        \n        # BiLSTM\n        # self.bilstm = nn.LSTM(256, 128, bidirectional=True, batch_first=True)\n        # self.bilstm = nn.LSTM(128, 128, bidirectional=True, batch_first=True)\n        # self.lstm_dropout = nn.Dropout(0.4)\n        # BiGRU - 根据是否使用tof_thm调整输入维度\n        gru_input_dim = 256 if self.use_tof else 128\n        self.bigru = nn.GRU(gru_input_dim, 128, bidirectional=True, batch_first=True)\n        self.gru_dropout = nn.Dropout(0.4)\n        \n        # Attention\n        self.attention = AttentionLayer(256)  # 128*2 for bidirectional\n        \n        # Dense layers\n        self.dense1 = nn.Linear(256, 256, bias=False)\n        self.bn_dense1 = nn.BatchNorm1d(256)\n        self.drop1 = nn.Dropout(0.5)\n        \n        self.dense2 = nn.Linear(256, 128, bias=False)\n        self.bn_dense2 = nn.BatchNorm1d(128)\n        self.drop2 = nn.Dropout(0.3)\n        \n        self.classifier = nn.Linear(128, n_classes)\n        \n    def forward(self, x):\n        # Split input\n\n        imu = x[:, :, :self.imu_dim]  # imu only   batch, seq_len, hidden_dim\n        imu = imu.transpose(1, 2)  # (batch, imu_dim, seq_len)\n        if self.use_tof:\n         tof = x[:, :, self.imu_dim:].transpose(1, 2)\n        \n        # IMU branch\n        x1 = self.imu_block1(imu)\n        x1 = self.imu_block2(x1)\n\n        merged = x1.transpose(1, 2)  # (batch, seq_len, 128)\n        ### TOF branch\n        if self.use_tof:\n            x2 = F.relu(self.tof_bn1(self.tof_conv1(tof)))\n            x2 = self.tof_drop1(self.tof_pool1(x2))\n            x2 = F.relu(self.tof_bn2(self.tof_conv2(x2)))\n            x2 = self.tof_drop2(self.tof_pool2(x2))\n        \n        # Concatenate branches\n            merged = torch.cat([x1, x2], dim=1).transpose(1, 2)  # with tof: (batch, seq_len, 256)\n        \n        # BiLSTM\n        # lstm_out, _ = self.bilstm(merged)\n        # lstm_out = self.lstm_dropout(lstm_out)\n        gru_out, _ = self.bigru(merged)\n        gru_out = self.gru_dropout(gru_out)\n        \n        # Attention\n        attended = self.attention(gru_out)\n        \n        # Dense layers\n        x = F.relu(self.bn_dense1(self.dense1(attended)))\n        x = self.drop1(x)\n        x = F.relu(self.bn_dense2(self.dense2(x)))\n        x = self.drop2(x)\n        \n        # Classification\n        logits = self.classifier(x)\n        return logits","metadata":{"execution":{"iopub.execute_input":"2026-08-14T03:21:44.943501Z","iopub.status.busy":"2026-08-14T03:21:44.94326Z","iopub.status.idle":"2026-08-14T03:21:44.959867Z","shell.execute_reply":"2026-08-14T03:21:44.959053Z"},"papermill":{"duration":0.025533,"end_time":"2026-08-14T03:21:44.96103","exception":false,"start_time":"2026-08-14T03:21:44.935497","status":"completed"},"tags":[]},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Train","metadata":{"papermill":{"duration":0.006832,"end_time":"2026-08-14T03:21:45.020608","exception":false,"start_time":"2026-08-14T03:21:45.013776","status":"completed"},"tags":[]}},{"cell_type":"markdown","source":"### Metric","metadata":{"papermill":{"duration":0.006641,"end_time":"2026-08-14T03:21:45.034096","exception":false,"start_time":"2026-08-14T03:21:45.027455","status":"completed"},"tags":[]}},{"cell_type":"code","source":"import kagglehub\nmetric = kagglehub.package_import('jiazhuang/cmi-2025-metric')\n\ndef get_competition_score(true, pred):\n    assert len(true) == len(pred)\n    N = len(true)\n    true = pd.DataFrame({'id': range(N), 'gesture': true})\n    pred = pd.DataFrame({'id': range(N), 'gesture': pred})\n    return metric.score(true, pred, 'id')","metadata":{"execution":{"iopub.execute_input":"2026-08-14T03:21:45.048943Z","iopub.status.busy":"2026-08-14T03:21:45.048704Z","iopub.status.idle":"2026-08-14T03:21:45.496834Z","shell.execute_reply":"2026-08-14T03:21:45.496202Z"},"papermill":{"duration":0.457125,"end_time":"2026-08-14T03:21:45.498117","exception":false,"start_time":"2026-08-14T03:21:45.040992","status":"completed"},"tags":[]},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import torch\nimport torch.nn as nn\nimport torch.optim as optim\nfrom torch.optim.swa_utils import update_bn\nfrom sklearn.metrics import accuracy_score, classification_report, confusion_matrix\nimport matplotlib.pyplot as plt\nimport seaborn as sns\nfrom tqdm import tqdm\nimport json\nimport copy\nimport heapq\nfrom collections import deque\n\n# from metric import get_competition_score\n\n\nclass LabelSmoothingCrossEntropy(nn.Module):\n    \"\"\"Label Smoothing交叉熵损失函数\"\"\"\n    \n    def __init__(self, smoothing: float = 0.1, num_classes: int = None):\n        \"\"\"\n        Args:\n            smoothing: 平滑参数，范围[0, 1]，0表示不平滑，1表示均匀分布\n            num_classes: 类别数量\n        \"\"\"\n        super().__init__()\n        self.smoothing = smoothing\n        self.num_classes = num_classes\n        \n    def forward(self, pred, target):\n        \"\"\"\n        Args:\n            pred: 模型预测logits，形状[batch_size, num_classes]\n            target: 真实标签，形状[batch_size]\n        \"\"\"\n        batch_size, num_classes = pred.shape\n        if self.num_classes is None:\n            self.num_classes = num_classes\n            \n        # 计算log softmax\n        log_pred = F.log_softmax(pred, dim=1)\n        \n        # 创建平滑标签\n        with torch.no_grad():\n            # 创建one-hot标签\n            true_dist = torch.zeros_like(log_pred)\n            true_dist.scatter_(1, target.unsqueeze(1), 1.0)\n            \n            # 应用label smoothing\n            # 正确类别的概率 = 1 - smoothing + smoothing / num_classes\n            # 其他类别的概率 = smoothing / num_classes\n            true_dist = true_dist * (1 - self.smoothing) + self.smoothing / num_classes\n        \n        # 计算KL散度损失\n        return torch.mean(torch.sum(-true_dist * log_pred, dim=1))\n\n\nclass Trainer:\n    \"\"\"LSTM分类器训练器\"\"\"\n    \n    def __init__(self, model, device, class_names, label_smoothing=0.0):\n        self.model = model.to(device)\n        self.device = device\n        self.class_names = class_names\n        self.label_smoothing = label_smoothing\n        self.train_losses = []\n        self.val_losses = []\n        self.train_accuracies = []\n        self.val_accuracies = []\n        self.val_scores = []\n\n        # 根据label_smoothing参数选择损失函数\n        if label_smoothing > 0:\n            self.criterion = LabelSmoothingCrossEntropy(\n                smoothing=label_smoothing, \n                num_classes=len(class_names)\n            )\n            print(f\"✅ 使用Label Smoothing (α={label_smoothing:.3f})\")\n        else:\n            self.criterion = nn.CrossEntropyLoss()\n            print(\"✅ 使用标准交叉熵损失\")\n        \n    def train_epoch(self, train_loader, optimizer, criterion, mixup_augmenter=None):\n        \"\"\"训练一个epoch\"\"\"\n        self.model.train()\n        total_loss = 0\n        correct_predictions = 0\n        total_predictions = 0\n        \n        progress_bar = tqdm(train_loader, desc=\"训练中\", disable=True)\n        \n        for batch_idx, (sequences, labels, lengths) in enumerate(progress_bar):\n            sequences = sequences.to(self.device)\n            labels = labels.to(self.device)\n\n            # 应用Mixup增强\n            if mixup_augmenter is not None:\n                sequences, labels_a, labels_b, lambda_ = mixup_augmenter(sequences, labels)\n            else:\n                labels_a, labels_b, lambda_ = labels, labels, 1.0\n            \n            # 前向传播\n            optimizer.zero_grad()\n            outputs = self.model(sequences)\n\n            # 计算损失\n            if mixup_augmenter is not None and lambda_ != 1.0:\n                loss = mixup_criterion(criterion, outputs, labels_a, labels_b, lambda_)\n            else:\n                loss = criterion(outputs, labels)\n            \n            # 反向传播\n            loss.backward()\n            \n            # 梯度裁剪\n            torch.nn.utils.clip_grad_norm_(self.model.parameters(), max_norm=1.0)\n            \n            optimizer.step()\n            \n            # 统计\n            total_loss += loss.item()\n            _, predicted = torch.max(outputs.data, 1)\n\n            if mixup_augmenter is not None and lambda_ != 1.0:\n                # Mixup时使用lambda_加权的准确率\n                correct_predictions += (lambda_ * (predicted == labels_a).sum().item() + \n                                     (1 - lambda_) * (predicted == labels_b).sum().item())\n            else:\n                correct_predictions += (predicted == labels).sum().item()\n            \n            total_predictions += labels.size(0)\n            \n            # 更新进度条\n            progress_bar.set_postfix({\n                'Loss': f'{loss.item():.4f}',\n                'Acc': f'{correct_predictions/total_predictions:.4f}',\n                'Mixup': 'ON' if (mixup_augmenter is not None and lambda_ != 1.0) else 'OF'\n            })\n        \n        avg_loss = total_loss / len(train_loader)\n        accuracy = correct_predictions / total_predictions\n        \n        return avg_loss, accuracy\n    \n    def validate(self, val_loader, criterion):\n        \"\"\"验证\"\"\"\n        self.model.eval()\n        total_loss = 0\n        correct_predictions = 0\n        total_predictions = 0\n        all_predictions = []\n        all_labels = []\n        \n        with torch.no_grad():\n            for sequences, labels, lengths in tqdm(val_loader, desc=\"验证中\", disable=True):\n                sequences = sequences.to(self.device)\n                labels = labels.to(self.device)\n                \n                outputs = self.model(sequences)\n                loss = criterion(outputs, labels)\n                \n                total_loss += loss.item()\n                _, predicted = torch.max(outputs.data, 1)\n                correct_predictions += (predicted == labels).sum().item()\n                total_predictions += labels.size(0)\n                \n                all_predictions.extend(predicted.cpu().numpy())\n                all_labels.extend(labels.cpu().numpy())\n        \n        avg_loss = total_loss / len(val_loader)\n        accuracy = correct_predictions / total_predictions\n        \n        return avg_loss, accuracy, all_predictions, all_labels\n    \n    def train(self, train_loader, val_loader, num_epochs=50, learning_rate=0.001,\n              weight_decay=1e-5, patience=10, save_path='best_model.pth', mixup_augmenter=None,\n              use_swa=True, swa_k=5):\n        \"\"\"完整训练流程\n\n        use_swa: 训练结束后，尝试产出两种\"权重平均\"版本的checkpoint：\n                   - \"recent\": 最近swa_k次\"刷新最佳纪录\"时的权重平均(按时间顺序，偏向\n                     训练后期/早停附近的epoch)\n                   - \"best\": 全部epoch里验证分数最高的swa_k个(按分数排名，不要求连续\n                     或是\"刷新纪录\"的那些)\n                 每种都会重新校准BN、在本fold验证集上打分，只有分数不低于单一最佳\n                 checkpoint才会存成独立文件、加入EnsemblePredictor的投票池；比单一\n                 best差就直接不存。\n                 (这里原来是\"不筛选，全都存下来交给投票\"，但实际提交做了A/B: 不筛选\n                 时private分数反而比只用单一best更差，大概率是某个SWA变体本身没有\n                 单一best好、投票时却跟它一样只算一票，把整体拖下去了。所以改回本地\n                 校验，比单一best差的就不让它参与投票。这仍然只是fold内部、样本量\n                 有限的一次校验，不是\"绝对保真\"的保证，但至少不会让明显更差的权重\n                 跟好的权重同权重投票。)\n        \"\"\"\n        \n        # 优化器和损失函数\n        optimizer = optim.Adam(self.model.parameters(), lr=learning_rate, weight_decay=weight_decay)\n        criterion = self.criterion\n        \n        # 学习率调度器\n        scheduler = optim.lr_scheduler.ReduceLROnPlateau(\n            optimizer, mode='max', factor=0.5, patience=patience//2\n        )\n        \n        # 早停\n        best_val_score = -float('inf')\n        epochs_without_improvement = 0\n\n        # SWA相关状态: 两路分开收集，互不覆盖\n        # - recent_best_state_dicts: 按时间顺序，只在\"刷新最佳纪录\"时追加，deque的maxlen\n        #   会自动挤掉最早的，留下最近swa_k次刷新纪录时的权重\n        # - top_k_checkpoints: 小顶堆，不管是不是刷新纪录，只要分数排得进前swa_k就收进来，\n        #   堆顶heap[0]是这几个里分数最低的一个，用来判断新epoch要不要挤进这个集合\n        self.swa_variants = {}\n        recent_best_state_dicts = deque(maxlen=swa_k) if use_swa else None\n        top_k_checkpoints = [] if use_swa else None\n        \n        print(f\"开始训练，设备: {self.device}\")\n        print(f\"模型参数量: {sum(p.numel() for p in self.model.parameters()):,}\")\n        \n        # 记录当前学习率用于检测变化\n        current_lr = learning_rate\n        \n        for epoch in range(num_epochs):\n            # 训练\n            train_loss, train_acc = self.train_epoch(train_loader, optimizer, criterion, mixup_augmenter)\n            \n            # 验证\n            val_loss, val_acc, val_preds, val_labels = self.validate(val_loader, criterion)\n            val_preds = [self.class_names[pred] for pred in val_preds]\n            val_labels = [self.class_names[label] for label in val_labels]\n            val_score = get_competition_score(val_labels, val_preds)\n            \n            # 记录历史\n            self.train_losses.append(train_loss)\n            self.val_losses.append(val_loss)\n            self.train_accuracies.append(train_acc)\n            self.val_accuracies.append(val_acc)\n            self.val_scores.append(val_score)\n            \n            # 更新学习率\n            old_lr = optimizer.param_groups[0]['lr']\n            scheduler.step(val_score)\n            new_lr = optimizer.param_groups[0]['lr']\n            \n            # 手动记录学习率变化\n            # if new_lr != old_lr:\n            #     print(f\"学习率从 {old_lr:.6f} 降低到 {new_lr:.6f}\")\n\n            if epoch % 10 == 0:\n                print(f\"\\nEpoch {epoch+1}/{num_epochs}\")\n                print(\"-\" * 50)\n                print(f\"训练 - Loss: {train_loss:.4f}, Acc: {train_acc:.4f}\")\n                print(f\"验证 - Loss: {val_loss:.4f}, Acc: {val_acc:.4f}, Score: {val_score:.4f}\")\n                print(f\"当前学习率: {new_lr:.6f}\")\n\n            # 是否要把这个epoch的权重收进\"历史验证分数最高的swa_k个\"里：\n            # 堆没满直接收；堆满了就跟堆里最差的(top_k_checkpoints[0][0])比，分数\n            # 更高才把它换出去。只有真的可能入选才做深拷贝，避免每个epoch都白白\n            # 拷贝一次模型权重(deque用maxlen会自动挤掉旧的，但那是按时间顺序挤，\n            # 不是按分数——这里改成heapq按分数排名维护，才是真的\"最好的\")\n            if top_k_checkpoints is not None:\n                if len(top_k_checkpoints) < swa_k or val_score > top_k_checkpoints[0][0]:\n                    snapshot = copy.deepcopy(self.model.state_dict())\n                    if len(top_k_checkpoints) < swa_k:\n                        heapq.heappush(top_k_checkpoints, (val_score, epoch, snapshot))\n                    else:\n                        heapq.heapreplace(top_k_checkpoints, (val_score, epoch, snapshot))\n            \n            # 早停检查\n            if val_score > best_val_score:\n                best_val_score = val_score\n                epochs_without_improvement = 0\n                # 保存最佳模型\n                torch.save({\n                    'model_state_dict': self.model.state_dict(),\n                    'optimizer_state_dict': optimizer.state_dict(),\n                    'epoch': epoch,\n                    'val_loss': val_loss,\n                    'val_acc': val_acc,\n                    'val_score': val_score,\n                    'model_config': {                        \n                        'imu_dim': self.model.imu_dim,\n                        'tof_dim': self.model.tof_dim,\n                        'n_classes': self.model.n_classes,\n                        'weight_decay': self.model.weight_decay\n                    }\n                }, save_path)\n                # print(f\"保存最佳模型到 {save_path}\")\n\n                if recent_best_state_dicts is not None:\n                    recent_best_state_dicts.append(copy.deepcopy(self.model.state_dict()))\n            else:\n                epochs_without_improvement += 1\n                \n            if epochs_without_improvement >= patience:\n                print(f\"早停：{patience} 个epoch无改善\")\n                break\n        \n        print(\"训练完成！\")\n\n        # SWA: 分别对\"最近几次刷新最佳纪录\"(recent)和\"全部epoch里分数最高的几个\"(best)\n        # 做等权重平均，重新校准BN之后都存成独立的checkpoint文件，跟单一最佳checkpoint\n        # 一起交给EnsemblePredictor做多数投票——不在这里用本地验证集分数做二选一淘汰。\n        # 本地fold验证集样本量本来就小、噪声大，本地分数更高不代表public/private分数\n        # 也更高；训练阶段就淘汰掉的话，集成投票也没机会再把它捡回来发挥作用了。\n        def _build_and_save_swa_variant(state_dicts, variant_label, variant_path):\n            avg_state_dict = copy.deepcopy(state_dicts[-1])\n            for key in avg_state_dict:\n                if torch.is_floating_point(avg_state_dict[key]):\n                    stacked = torch.stack([sd[key].float() for sd in state_dicts], dim=0)\n                    avg_state_dict[key] = stacked.mean(dim=0).to(avg_state_dict[key].dtype)\n\n            swa_model = TwoBranchModel(\n                imu_dim=self.model.imu_dim,\n                tof_dim=self.model.tof_dim,\n                n_classes=self.model.n_classes,\n                weight_decay=self.model.weight_decay,\n            ).to(self.device)\n            swa_model.load_state_dict(avg_state_dict)\n\n            # 平均之后BatchNorm的running mean/var需要重新在训练集上跑一遍前向来校准\n            update_bn(train_loader, swa_model, device=self.device)\n\n            original_model = self.model\n            self.model = swa_model\n            swa_loss, swa_acc, swa_preds, swa_labels_idx = self.validate(val_loader, criterion)\n            self.model = original_model\n\n            swa_preds_name = [self.class_names[p] for p in swa_preds]\n            swa_labels_name = [self.class_names[l] for l in swa_labels_idx]\n            swa_score = get_competition_score(swa_labels_name, swa_preds_name)\n\n            # 加回\"比单一best差就舍弃\"的过滤：实际提交做过A/B，不筛选、全部等权重\n            # 投票时private分数反而更差——大概率是某个SWA变体本身没有单一best好，\n            # 投票时却跟它一样只算一票，把整体拖下去了。这里改成:SWA变体验证分数\n            # 不低于单一best才留下来参与投票，比它差就直接不存这个文件(也就不会\n            # 出现在EnsemblePredictor的glob结果里，不参与投票)。\n            if swa_score < best_val_score:\n                print(f\"📊 SWA[{variant_label}](共{len(state_dicts)}个checkpoint等权重平均) \"\n                      f\"val_score={swa_score:.4f} < 单一最佳checkpoint val_score={best_val_score:.4f}，\"\n                      f\"比单一best差，舍弃、不参与投票\")\n                return None\n\n            torch.save({\n                'model_state_dict': swa_model.state_dict(),\n                'epoch': -1,\n                'val_loss': swa_loss,\n                'val_acc': swa_acc,\n                'val_score': swa_score,\n                'model_config': {\n                    'imu_dim': self.model.imu_dim,\n                    'tof_dim': self.model.tof_dim,\n                    'n_classes': self.model.n_classes,\n                    'weight_decay': self.model.weight_decay\n                }\n            }, variant_path)\n\n            print(f\"📊 SWA[{variant_label}](共{len(state_dicts)}个checkpoint等权重平均) \"\n                  f\"val_score={swa_score:.4f} >= 单一最佳checkpoint val_score={best_val_score:.4f}，\"\n                  f\"保留，已存到 {variant_path}\")\n            return swa_score\n\n        # 存的文件名要落在 fold_*_model.pth 这个glob规则里，EnsemblePredictor才会自动捡到一起投票\n        if save_path.endswith('_model.pth'):\n            path_prefix = save_path[:-len('_model.pth')]\n        else:\n            path_prefix = save_path[:-len('.pth')] if save_path.endswith('.pth') else save_path\n\n        if recent_best_state_dicts is not None and len(recent_best_state_dicts) > 1:\n            try:\n                variant_path = f'{path_prefix}_swarecent_model.pth'\n                score = _build_and_save_swa_variant(list(recent_best_state_dicts), 'recent', variant_path)\n                if score is not None:\n                    self.swa_variants['recent'] = {\n                        'path': variant_path, 'val_score': score,\n                        'num_checkpoints': len(recent_best_state_dicts),\n                    }\n            except Exception as e:\n                print(f\"⚠️ SWA[recent]权重平均步骤出错，跳过。错误: {e}\")\n\n        if top_k_checkpoints is not None and len(top_k_checkpoints) > 1:\n            try:\n                best_epochs_sorted = sorted(((s, e) for s, e, _ in top_k_checkpoints), reverse=True)\n                print(f\"📋 参与SWA[best]平均的{len(top_k_checkpoints)}个epoch (epoch号, val_score): \"\n                      f\"{[(e, round(s, 4)) for s, e in best_epochs_sorted]}\")\n                state_dicts = [sd for _, _, sd in top_k_checkpoints]\n                variant_path = f'{path_prefix}_swabest_model.pth'\n                score = _build_and_save_swa_variant(state_dicts, 'best', variant_path)\n                if score is not None:\n                    self.swa_variants['best'] = {\n                        'path': variant_path, 'val_score': score,\n                        'num_checkpoints': len(top_k_checkpoints),\n                    }\n            except Exception as e:\n                print(f\"⚠️ SWA[best]权重平均步骤出错，跳过。错误: {e}\")\n\n        return self.train_losses, self.val_losses, self.train_accuracies, self.val_accuracies, self.val_scores\n    \n    def plot_training_history(self, save_path='training_history.png'):\n        \"\"\"绘制训练历史\"\"\"\n        fig, (ax1, ax2, ax3) = plt.subplots(1, 3, figsize=(15, 5))\n        \n        # 损失曲线\n        ax1.plot(self.train_losses, label='train loss')\n        ax1.plot(self.val_losses, label='val loss')\n        ax1.set_title('train and val loss')\n        ax1.set_xlabel('Epoch')\n        ax1.set_ylabel('Loss')\n        ax1.legend()\n        ax1.grid(True)\n        \n        # 准确率曲线\n        ax2.plot(self.train_accuracies, label='train accuracy')\n        ax2.plot(self.val_accuracies, label='val accuracy')\n        ax2.set_title('train and val accuracy')\n        ax2.set_xlabel('Epoch')\n        ax2.set_ylabel('Accuracy')\n        ax2.legend()\n        ax2.grid(True)\n\n        # 分数曲线\n        ax3.plot(self.val_scores, label='val score')\n        ax3.set_title('val score')\n        ax3.set_xlabel('Epoch')\n        ax3.set_ylabel('Score')\n        ax3.legend()\n        ax3.grid(True)\n        \n        plt.tight_layout()\n        plt.savefig(save_path, dpi=300, bbox_inches='tight')\n        plt.show()\n        print(f\"训练历史图保存到 {save_path}\")\n\n\ndef evaluate_model(model, test_loader, device, class_names=None, save_path=None, title_suffix=''):\n    \"\"\"评估模型\n\n    save_path: 混淆矩阵图保存路径。之前这里是硬编码的固定文件名\n               f'{OUTPUT_DIR}/confusion_matrix.png'，K折训练时每个fold都调一次\n               evaluate_model，5折下来后面的会不断覆盖前面的，最后磁盘上只留得下\n               最后一折的图。现在改成外面按fold(以及是否是SWA变体)传入不同的\n               save_path，互不覆盖；不传的话保留旧的默认路径，向后兼容。\n    title_suffix: 图标题后缀，方便区分是哪个fold/哪个checkpoint变体的混淆矩阵。\n    \"\"\"\n    model.eval()\n    all_predictions = []\n    all_labels = []\n    all_probabilities = []\n    \n    with torch.no_grad():\n        for sequences, labels, lengths in tqdm(test_loader, desc=\"评估中\", disable=True):\n            sequences = sequences.to(device)\n            labels = labels.to(device)\n            \n            outputs = model(sequences)\n            probabilities = torch.softmax(outputs, dim=1)\n            _, predicted = torch.max(outputs, 1)\n            \n            all_predictions.extend(predicted.cpu().numpy())\n            all_labels.extend(labels.cpu().numpy())\n            all_probabilities.extend(probabilities.cpu().numpy())\n    \n    # 计算指标\n    accuracy = accuracy_score(all_labels, all_predictions)\n    score = get_competition_score(\n        [class_names[label] for label in all_labels],\n        [class_names[pred] for pred in all_predictions]\n    )\n\n    # 分类报告\n    if class_names is None:\n        class_names = [f'Class_{i}' for i in range(len(set(all_labels)))]\n    \n    # import pdb; pdb.set_trace()\n    sub_class_names = sorted(set(all_labels + all_predictions))\n    sub_class_names = [class_names[i] for i in sub_class_names]\n    report = classification_report(all_labels, all_predictions, \n                                 target_names=sub_class_names, output_dict=True)\n    \n    print(f\"测试准确率: {accuracy:.4f}\")\n    print(f\"测试分数: {score:.4f}\")\n    print(\"\\n分类报告:\")\n    print(classification_report(all_labels, all_predictions, target_names=sub_class_names))\n    \n    # 混淆矩阵\n    cm = confusion_matrix(all_labels, all_predictions)\n    plt.figure(figsize=(12, 10))\n    sns.heatmap(cm, annot=True, fmt='d', cmap='Blues', \n                xticklabels=sub_class_names, yticklabels=sub_class_names)\n    plt.title('confusion matrix' + (f' - {title_suffix}' if title_suffix else ''))\n    plt.ylabel('true label')\n    plt.xlabel('predicted label')\n    plt.tight_layout()\n    plt.savefig(save_path or f'{OUTPUT_DIR}/confusion_matrix.png', dpi=300, bbox_inches='tight')\n    plt.show()\n\n    # 诊断: 找出最容易混淆的类别对，纯分析用途，不改变训练/预测行为，\n    # 用来判断\"类别权重\"之类的干预到底该往哪个方向做，而不是盲目猜\n    print(\"\\n🔍 最容易混淆的类别对 (真实 → 预测: 次数):\")\n    confused_pairs = [\n        (cm[i, j], sub_class_names[i], sub_class_names[j])\n        for i in range(len(sub_class_names))\n        for j in range(len(sub_class_names))\n        if i != j and cm[i, j] > 0\n    ]\n    confused_pairs.sort(reverse=True)\n    for count, true_name, pred_name in confused_pairs[:10]:\n        print(f\"   {true_name!r} → 被误判为 {pred_name!r}: {count} 次\")\n    \n    return accuracy, score, report, all_predictions, all_probabilities","metadata":{"execution":{"iopub.execute_input":"2026-08-14T03:21:45.515982Z","iopub.status.busy":"2026-08-14T03:21:45.515611Z","iopub.status.idle":"2026-08-14T03:21:45.838295Z","shell.execute_reply":"2026-08-14T03:21:45.837381Z"},"papermill":{"duration":0.333061,"end_time":"2026-08-14T03:21:45.8398","exception":false,"start_time":"2026-08-14T03:21:45.506739","status":"completed"},"tags":[]},"outputs":[],"execution_count":null},{"cell_type":"code","source":"def train_single_fold(sequences, labels, subjects, train_idx, val_idx,\n                     fold_num, total_folds, device, num_epochs=50, \n                     learning_rate=0.001, patience=10, batch_size=16, label_smoothing=0.0,\n                     use_mixup=False, mixup_params=None, use_swa=True, swa_k=5):\n    \"\"\"训练单个折\"\"\"\n    print(f\"\\n{'='*60}\")\n    print(f\"🔄 开始训练第 {fold_num}/{total_folds} 折\")\n    print(f\"{'='*60}\")\n    \n    # 创建数据加载器\n    train_loader, val_loader, mixup_augmenter = create_fold_loaders(\n        sequences, labels, train_idx, val_idx, batch_size=batch_size,\n        use_mixup=use_mixup, mixup_params=mixup_params\n    )\n    \n    print(f\"📊 第{fold_num}折数据信息:\")\n    print(f\"   训练样本: {len(train_loader.dataset)}\")\n    print(f\"   验证样本: {len(val_loader.dataset)}\")\n    \n    # 检查subject分布\n    train_subjects_fold = [subjects[i] for i in train_idx]\n    val_subjects_fold = [subjects[i] for i in val_idx]\n    print(f\"   训练subjects: {len(set(train_subjects_fold))}\")\n    print(f\"   验证subjects: {len(set(val_subjects_fold))}\")\n    \n    # 创建模型\n    num_classes = len(LABEL_NAMES)\n    model = TwoBranchModel(\n        imu_dim=20, # len(FEATURE_NAMES)\n        tof_dim= 64*5+5, ### thm + tof\n        n_classes=num_classes,\n        weight_decay=3e-3\n    )\n    \n    # 初始化权重\n    if hasattr(model, 'init_weights'):\n        model.init_weights()\n    \n    print(f\"✅ 模型参数量: {sum(p.numel() for p in model.parameters()):,}\")\n    \n    # 创建训练器\n    trainer = Trainer(model, device, LABEL_NAMES, label_smoothing=label_smoothing,)\n    \n    # 训练参数\n    model_save_path = f'{OUTPUT_DIR}/fold_{fold_num}_model.pth'\n    \n    # 训练\n    print(f\"🏋️ 开始第{fold_num}折训练...\")\n    train_losses, val_losses, train_accuracies, val_accuracies, val_scores = trainer.train(\n        train_loader, val_loader,\n        num_epochs=num_epochs,\n        learning_rate=learning_rate,\n        patience=patience,\n        save_path=model_save_path,\n        mixup_augmenter=mixup_augmenter,\n        use_swa=use_swa,\n        swa_k=swa_k,\n    )\n    \n    print(f\"✅ 第{fold_num}折训练完成!\")\n    print(f\"📈 最终训练准确率: {train_accuracies[-1]:.4f}\")\n    print(f\"📊 最终验证准确率: {val_accuracies[-1]:.4f}\")\n    print(f\"📊 最终验证分数: {val_scores[-1]:.4f}\")\n\n    # 绘制训练历史\n    history_plot_path = f'{OUTPUT_DIR}/fold_{fold_num}_training_history.png'\n    trainer.plot_training_history(history_plot_path)\n    \n    # 加载最佳模型进行评估\n    checkpoint = torch.load(model_save_path, map_location=device, weights_only=False)\n    model.load_state_dict(checkpoint['model_state_dict'])\n    \n    # 最终评估(单一最佳checkpoint)。混淆矩阵图按fold单独命名，避免5折跑下来\n    # 互相覆盖(之前是固定文件名，只会留下最后一折的图)\n    accuracy, score, report, predictions, probabilities = evaluate_model(\n        model, val_loader, device, LABEL_NAMES,\n        save_path=f'{OUTPUT_DIR}/fold_{fold_num}_confusion_matrix.png',\n        title_suffix=f'fold{fold_num} best',\n    )\n    \n    # 保存该折的结果\n    fold_results = {\n        'fold': fold_num,\n        'model_path': model_save_path,\n        'final_train_acc': train_accuracies[-1],\n        'final_val_acc': val_accuracies[-1],\n        'final_val_score': val_scores[-1],\n        'test_accuracy': accuracy,\n        'test_score': score,\n        'num_epochs': len(train_losses),\n        'train_subjects': len(set(train_subjects_fold)),\n        'val_subjects': len(set(val_subjects_fold)),\n        # SWA(权重平均)额外产出的checkpoint变体(recent/best)，下面会补上完整评估结果，\n        # 跟单一最佳checkpoint同等对待——只是记录方便查看，不代表\"选用了哪个\"，\n        # 它们和单一最佳checkpoint都会被EnsemblePredictor一起投票\n        'swa_variants': {},\n    }\n    \n    print(f\"🎯 第{fold_num}折最终评估(单一最佳checkpoint):\")\n    print(f\"   测试准确率: {accuracy:.4f}\")\n    print(f\"   测试分数: {score:.4f}\")\n\n    # SWA变体也各自完整跑一遍evaluate_model(混淆矩阵图+分类报告)，而不只是\n    # trainer.train()训练时打印的那个轻量验证分数\n    for variant_label, info in trainer.swa_variants.items():\n        variant_checkpoint = torch.load(info['path'], map_location=device, weights_only=False)\n        variant_model = TwoBranchModel(**variant_checkpoint['model_config']).to(device)\n        variant_model.load_state_dict(variant_checkpoint['model_state_dict'])\n\n        v_accuracy, v_score, v_report, _, _ = evaluate_model(\n            variant_model, val_loader, device, LABEL_NAMES,\n            save_path=f'{OUTPUT_DIR}/fold_{fold_num}_confusion_matrix_swa_{variant_label}.png',\n            title_suffix=f'fold{fold_num} swa-{variant_label}',\n        )\n\n        fold_results['swa_variants'][variant_label] = {\n            **info,\n            'test_accuracy': v_accuracy,\n            'test_score': v_score,\n        }\n        print(f\"   📎 SWA[{variant_label}]完整评估: {info['path']} \"\n              f\"(测试准确率={v_accuracy:.4f}, 测试分数={v_score:.4f}, \"\n              f\"训练时验证分数={info['val_score']:.4f}, 用了{info['num_checkpoints']}个checkpoint平均)\")\n    \n    return fold_results\n\n\ndef run_kfold_training(train_df, n_folds=5, **kwargs):\n    \"\"\"运行K折交叉验证训练\"\"\"\n    print(\"🚀 使用K折交叉验证进行LSTM序列分类训练\")\n    print(\"=\" * 60)\n    \n    # 设置设备\n    device = 'cuda' if torch.cuda.is_available() else 'mps' if torch.backends.mps.is_available() else 'cpu'\n    print(f\"🧠 使用设备: {device}\")\n    \n    # 加载数据\n    print(\"📊 加载训练数据...\")\n    # train_df = pd.read_json(train_path, lines=True)\n    sequences = train_df['sequence'].tolist()\n    labels = train_df['label'].tolist()\n    subjects = train_df['subject'].tolist()\n    \n    num_classes = len(LABEL_NAMES)\n    print(f\"\\n📋 数据信息:\")\n    print(f\"   总样本数: {len(sequences)}\")\n    print(f\"   类别数量: {num_classes}\")\n    print(f\"   总subjects数: {len(set(subjects))}\")\n    \n    # 显示类别信息\n    print(f\"\\n🏷️  类别示例:\")\n    for i, class_name in enumerate(LABEL_NAMES[:8]):\n        print(f\"   {i}: {class_name}\")\n    if len(LABEL_NAMES) > 8:\n        print(f\"   ... 还有 {len(LABEL_NAMES) - 8} 个类别\")\n    \n    # 创建K折分割\n    print(f\"\\n📂 创建{n_folds}折交叉验证分割...\")\n    splits = create_kfold_splits(sequences, labels, subjects, n_splits=n_folds, random_state=42)\n    \n    # K折训练\n    fold_results = []\n    for fold_num, (train_idx, val_idx) in enumerate(splits, 1):\n        fold_result = train_single_fold(\n            sequences, labels, subjects, train_idx, val_idx,\n            fold_num, n_folds, device, **kwargs\n        )\n        fold_results.append(fold_result)\n    \n    # 计算总体统计\n    print(f\"\\n{'='*60}\")\n    print(f\"🎉 {n_folds}折交叉验证完成!\")\n    print(f\"{'='*60}\")\n    \n    val_accuracies = [r['final_val_acc'] for r in fold_results]\n    val_scores = [r['final_val_score'] for r in fold_results]\n    test_accuracies = [r['test_accuracy'] for r in fold_results]\n    test_scores = [r['test_score'] for r in fold_results]\n    \n    print(f\"\\n📊 K折交叉验证统计:\")\n    print(f\"   验证准确率: {np.mean(val_accuracies):.4f} ± {np.std(val_accuracies):.4f}\")\n    print(f\"   验证分数: {np.mean(val_scores):.4f} ± {np.std(val_scores):.4f}\")\n    print(f\"   测试准确率: {np.mean(test_accuracies):.4f} ± {np.std(test_accuracies):.4f}\")\n    print(f\"   测试分数: {np.mean(test_scores):.4f} ± {np.std(test_scores):.4f}\")\n    \n    # 显示每折结果\n    print(f\"\\n📋 各折详细结果:\")\n    for i, result in enumerate(fold_results):\n        print(f\"   Fold {i+1}: Val Acc={result['final_val_acc']:.4f}, \"\n              f\"Val Score={result['final_val_score']:.4f}, \"\n              f\"Test Acc={result['test_accuracy']:.4f}, \"\n              f\"Test Score={result['test_score']:.4f}\")\n    \n    # 保存K折结果\n    kfold_results = {\n        'n_folds': n_folds,\n        'fold_results': fold_results,\n        'summary': {\n            'val_acc_mean': np.mean(val_accuracies),\n            'val_acc_std': np.std(val_accuracies),\n            'val_score_mean': np.mean(val_scores),\n            'val_score_std': np.std(val_scores),\n            'test_acc_mean': np.mean(test_accuracies),\n            'test_acc_std': np.std(test_accuracies),\n            'test_score_mean': np.mean(test_scores),\n            'test_score_std': np.std(test_scores)\n        },\n    }\n    \n    with open(f'{OUTPUT_DIR}/kfold_results.json', 'w', encoding='utf-8') as f:\n        json.dump(kfold_results, f, ensure_ascii=False, indent=2)\n\n    print(f\"💾 K折结果已保存: kfold_results.json\")\n    \n    return kfold_results","metadata":{"execution":{"iopub.execute_input":"2026-08-14T03:21:45.855495Z","iopub.status.busy":"2026-08-14T03:21:45.855173Z","iopub.status.idle":"2026-08-14T03:21:45.874321Z","shell.execute_reply":"2026-08-14T03:21:45.87376Z"},"papermill":{"duration":0.028318,"end_time":"2026-08-14T03:21:45.875422","exception":false,"start_time":"2026-08-14T03:21:45.847104","status":"completed"},"tags":[]},"outputs":[],"execution_count":null},{"cell_type":"code","source":"torch.manual_seed(42)\nnp.random.seed(42)\n\n# 训练参数\ntraining_params = {\n    'num_epochs': 1 if DEBUG else 200,\n    'learning_rate': 0.001,\n    'patience': 20, ### 40\n    'batch_size': 12,\n    'label_smoothing': 0.1,\n    'use_mixup': True,                  # 启用Mixup数据增强\n    'mixup_params': {                   # Mixup参数\n        'alpha': 0.2,                   # Beta分布参数，控制混合程度\n        'prob': 0.5                     # 应用Mixup的概率\n    },\n    'use_swa': True,                    # 每fold训练完，额外产出recent/best两个权重平均的checkpoint\n    'swa_k': 5,                         # 各自取多少个epoch的权重做平均\n}\n\nif DEBUG:\n    agg_train_df = agg_train_df.head(2000)\n\n# K折交叉验证训练\nn_folds = 5\nprint(\"🔄 开始K折交叉验证训练\")\nprint(f\"   - Use Mixup: {training_params['use_mixup']}\")\nif training_params['use_mixup']:\n    print(f\"   - Mixup Alpha: {training_params['mixup_params']['alpha']}\")\n    print(f\"   - Mixup Probability: {training_params['mixup_params']['prob']}\")\n\nif TRAIN:\n    _ = run_kfold_training(agg_train_df, n_folds=n_folds, **training_params)","metadata":{"execution":{"iopub.execute_input":"2026-08-14T03:21:45.890411Z","iopub.status.busy":"2026-08-14T03:21:45.889745Z","iopub.status.idle":"2026-08-14T03:21:45.899352Z","shell.execute_reply":"2026-08-14T03:21:45.898619Z"},"papermill":{"duration":0.018183,"end_time":"2026-08-14T03:21:45.900422","exception":false,"start_time":"2026-08-14T03:21:45.882239","status":"completed"},"tags":[]},"outputs":[],"execution_count":null},{"cell_type":"code","source":"!ls saved_models/","metadata":{"execution":{"iopub.execute_input":"2026-08-14T03:21:45.914928Z","iopub.status.busy":"2026-08-14T03:21:45.91437Z","iopub.status.idle":"2026-08-14T03:21:46.397701Z","shell.execute_reply":"2026-08-14T03:21:46.396933Z"},"papermill":{"duration":0.492007,"end_time":"2026-08-14T03:21:46.399149","exception":false,"start_time":"2026-08-14T03:21:45.907142","status":"completed"},"tags":[]},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Inference","metadata":{"papermill":{"duration":0.006795,"end_time":"2026-08-14T03:21:46.413327","exception":false,"start_time":"2026-08-14T03:21:46.406532","status":"completed"},"tags":[]}},{"cell_type":"code","source":"import os\nimport glob\n\ndef get_latest_model_dir(base_dir):\n    \"\"\"在base_dir下找到编号最大的版本子目录，返回它的saved_models/路径。\n\n    关键: 挂载的Model如果没有pin固定版本号，每次运行都会拉取\"当前最新版本\"。\n    如果目录不存在(输入没挂上/被删/权限问题)或者压根没有数字命名的版本子目录，\n    静默回退到某个默认值、拼出一个可能根本不存在的路径的话，下游glob.glob会\n    找到0个模型文件，最终在EnsemblePredictor/predict()里报出一个很隐晦的\n    IndexError(Counter([]).most_common(1)[0])。这里改成尽早、明确地报错，\n    方便本地复现/调试(Kaggle提交时具体异常信息本身还是会被隐藏)。\n    \"\"\"\n    if not os.path.isdir(base_dir):\n        raise FileNotFoundError(\n            f\"找不到模型输入目录: {base_dir}。请检查Notebook右侧Input面板里\"\n            f\"对应的Model是否还挂载着、版本是否可用。\"\n        )\n\n    versions = []\n    for version in os.listdir(base_dir):\n        try:\n            versions.append(int(version))\n        except ValueError:\n            continue\n\n    if not versions:\n        raise FileNotFoundError(\n            f\"{base_dir} 下没有找到任何数字命名的版本子目录: {os.listdir(base_dir)}。\"\n            f\"该Model可能还在处理中，或版本已被删除。\"\n        )\n\n    latest = max(versions)\n    models_dir = f'{base_dir}{latest}/saved_models/'\n\n    if not glob.glob(f'{models_dir}/fold_*_model.pth'):\n        raise FileNotFoundError(\n            f\"{models_dir} 下没有找到任何 fold_*_model.pth 文件，最新版本({latest})可能是空的或结构不对。\"\n        )\n\n    return models_dir\n\n\ndef get_lastest_saved_models():\n    return get_latest_model_dir(\"/kaggle/input/models/willongwang/cmi-lstm/pytorch/swa-imu-only/\") ###","metadata":{"execution":{"iopub.execute_input":"2026-08-14T03:21:46.428671Z","iopub.status.busy":"2026-08-14T03:21:46.428374Z","iopub.status.idle":"2026-08-14T03:21:46.434765Z","shell.execute_reply":"2026-08-14T03:21:46.433918Z"},"papermill":{"duration":0.015774,"end_time":"2026-08-14T03:21:46.436003","exception":false,"start_time":"2026-08-14T03:21:46.420229","status":"completed"},"tags":[]},"outputs":[],"execution_count":null},{"cell_type":"code","source":"SAVED_MODELS = './saved_models/' if TRAIN else get_lastest_saved_models()","metadata":{"execution":{"iopub.execute_input":"2026-08-14T03:21:46.451056Z","iopub.status.busy":"2026-08-14T03:21:46.450415Z","iopub.status.idle":"2026-08-14T03:21:46.456754Z","shell.execute_reply":"2026-08-14T03:21:46.455817Z"},"papermill":{"duration":0.01504,"end_time":"2026-08-14T03:21:46.457926","exception":false,"start_time":"2026-08-14T03:21:46.442886","status":"completed"},"tags":[]},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import torch\nimport numpy as np\nimport pickle\nimport glob\nimport os\nimport re\nfrom collections import Counter\nfrom typing import List, Dict\nimport polars as pl\n\nimport joblib","metadata":{"execution":{"iopub.execute_input":"2026-08-14T03:21:46.474923Z","iopub.status.busy":"2026-08-14T03:21:46.474226Z","iopub.status.idle":"2026-08-14T03:21:46.915693Z","shell.execute_reply":"2026-08-14T03:21:46.914798Z"},"papermill":{"duration":0.451853,"end_time":"2026-08-14T03:21:46.91713","exception":false,"start_time":"2026-08-14T03:21:46.465277","status":"completed"},"tags":[]},"outputs":[],"execution_count":null},{"cell_type":"code","source":"class EnsemblePredictor:\n    \"\"\"集成预测器 - 使用多个fold模型投票预测\"\"\"\n    \n    def __init__(self, models_dir, device='cpu'):\n        \"\"\"\n        初始化集成预测器\n        \n        Args:\n            device: 计算设备\n        \"\"\"\n        self.device = device\n        self.models = []\n        self._load_models(models_dir)\n    \n    def _load_models(self, models_dir):\n        \"\"\"加载所有fold的模型\"\"\"\n        # 查找所有fold模型文件\n        model_files = sorted(glob.glob(f\"{models_dir}/fold_*_model.pth\"))\n\n        # ablation: USE_SWA_VARIANTS_AT_INFERENCE=False时，只保留\"fold_<数字>_model.pth\"\n        if not USE_SWA_VARIANTS_AT_INFERENCE:\n            before = len(model_files)\n            model_files = [f for f in model_files if re.fullmatch(r'fold_\\d+_model\\.pth', os.path.basename(f))]\n            print(f\"🔬 ablation模式: 排除SWA变体，{before} 个文件里保留 {len(model_files)} 个单一best checkpoint\")\n\n        print(f\"📁 找到 {len(model_files)} 个fold模型\")\n\n        # 关键: 如果这里是0个(比如Model目录挂载不对/版本不对)，之前会静默地让\n        # self.models保持为[]，等到真正predict()时才在Counter(...).most_common(1)[0]\n        # 这种很隐晦的地方抛IndexError。这里提前失败并给出models_dir，方便定位。\n        if not model_files:\n            raise FileNotFoundError(f\"在 {models_dir} 下没有找到任何 fold_*_model.pth，无法构建集成预测器。\")\n        \n        # 加载每个fold的模型\n        for model_file in model_files:\n            checkpoint = torch.load(model_file, map_location=self.device, weights_only=False)\n            config = checkpoint['model_config']\n            \n            model = TwoBranchModel(**config)\n            \n            model.load_state_dict(checkpoint['model_state_dict'])\n            model.to(self.device)\n            model.eval()\n            self.models.append(model)\n            \n            print(f\"✅ 加载 {model_file}\")\n        \n    def predict(self, sequence: List[Dict]) -> str:\n        \"\"\"\n        预测单个序列的标签\n        \n        Args:\n            sequence: 输入序列，格式与训练数据相同\n        \n        Returns:\n            predicted_label: 预测的文本标签\n        \"\"\"\n        predictions = []\n        \n        # 关键: 用sequence自身实际拥有的key作为feature_names，\n        # 不能依赖全局FEATURE_NAMES —— 全局tof_thm在最开始为True，\n        # 如果这里还用全局FEATURE_NAMES(345个)去解析imu-only序列(只有20个key)，\n        # 会在测试集中\"无tof/thm\"的那一半样本上必然KeyError，导致提交异常\n        feature_names = list(sequence[0].keys())\n        \n        # 使用每个fold的模型进行预测\n        for model in self.models:\n            # 使用SequenceDataset处理序列\n            dataset = SequenceDataset([sequence], [0], is_training=False, use_augmentation=False, feature_names=feature_names)\n            \n            # 获取处理后的序列\n            processed_sequence = torch.FloatTensor(dataset.processed_sequences[0]).unsqueeze(0).to(self.device)\n            \n            # 预测\n            with torch.no_grad():\n                outputs = model(processed_sequence)\n                predicted_class = torch.argmax(outputs, dim=1).item()\n                predictions.append(predicted_class)\n        \n        # 投票决定最终预测结果\n        most_common_prediction = Counter(predictions).most_common(1)[0][0]\n        \n        # 转换为文本标签\n        predicted_label = LABEL_NAMES[most_common_prediction]\n        \n        return predicted_label","metadata":{"execution":{"iopub.execute_input":"2026-08-14T03:21:46.932735Z","iopub.status.busy":"2026-08-14T03:21:46.932478Z","iopub.status.idle":"2026-08-14T03:21:46.940454Z","shell.execute_reply":"2026-08-14T03:21:46.93994Z"},"papermill":{"duration":0.016854,"end_time":"2026-08-14T03:21:46.94154","exception":false,"start_time":"2026-08-14T03:21:46.924686","status":"completed"},"tags":[]},"outputs":[],"execution_count":null},{"cell_type":"code","source":"feature_scaler = joblib.load(f'{SAVED_MODELS}/feature_scaler.joblib')\nif tof_thm:\n    tof_thm_models = get_latest_model_dir('/kaggle/input/models/willongwang/cmi-lstm/pytorch/default/') ###\n    feature_scaler_tof_thm = joblib.load(f'{tof_thm_models}/feature_scaler.joblib')","metadata":{"execution":{"iopub.execute_input":"2026-08-14T03:21:46.956057Z","iopub.status.busy":"2026-08-14T03:21:46.955868Z","iopub.status.idle":"2026-08-14T03:21:46.968346Z","shell.execute_reply":"2026-08-14T03:21:46.967846Z"},"papermill":{"duration":0.020977,"end_time":"2026-08-14T03:21:46.969282","exception":false,"start_time":"2026-08-14T03:21:46.948305","status":"completed"},"tags":[]},"outputs":[],"execution_count":null},{"cell_type":"code","source":"device = 'cuda' if torch.cuda.is_available() else 'mps' if torch.backends.mps.is_available() else 'cpu'\nprint(f\"🧠 使用设备: {device}\")\noriginal=tof_thm\ntof_thm=False\npredictor = EnsemblePredictor(SAVED_MODELS,device)\ntof_thm=original\nif tof_thm:\n predictor_tof_thm = EnsemblePredictor(tof_thm_models,device)","metadata":{"execution":{"iopub.execute_input":"2026-08-14T03:21:46.983751Z","iopub.status.busy":"2026-08-14T03:21:46.983526Z","iopub.status.idle":"2026-08-14T03:21:49.388351Z","shell.execute_reply":"2026-08-14T03:21:49.387313Z"},"papermill":{"duration":2.413664,"end_time":"2026-08-14T03:21:49.389872","exception":false,"start_time":"2026-08-14T03:21:46.976208","status":"completed"},"tags":[]},"outputs":[],"execution_count":null},{"cell_type":"code","source":"def predict(sequence: pl.DataFrame, demographics: pl.DataFrame) -> str:\n    sequence = sequence.to_pandas()\n    demographics = demographics.to_pandas()\n\n    sequence = pd.merge( \n        sequence,\n        demographics,\n        on='subject',\n        how='left'\n    )\n\n    tof_thm_cols = tof_features + thm_features\n    \n    # 🔧 先检查ToF/Thermal列是否存在，再检查数据是否有效\n    # 官方文档说的是\"所有列一直存在、只是值为null\", 就默认这样不会出错\n    existing_tof_thm_cols = [col for col in tof_thm_cols if col in sequence.columns]\n    if existing_tof_thm_cols:\n        use_tof_thm = sequence[existing_tof_thm_cols].notnull().any().any()\n        if TOF_NEG1_AS_MISSING:\n         use_tof_thm = (sequence[existing_tof_thm_cols].notnull() & (sequence[existing_tof_thm_cols] != -1.0)).any().any()\n    else:\n        use_tof_thm = False\n\n    if tof_thm==False:\n     use_tof_thm = False\n\n    if use_tof_thm:\n        scaler = feature_scaler_tof_thm\n        pred_model = predictor_tof_thm\n    else:\n        scaler = feature_scaler\n        pred_model = predictor\n\n    base_feature_names = [\n        'acc_x', 'acc_y', 'acc_z', 'rot_w', 'rot_x', 'rot_y', 'rot_z',\n        'acc_mag', 'rot_angle', 'acc_mag_jerk', 'rot_angle_vel',\n        'linear_acc_x', 'linear_acc_y', 'linear_acc_z', 'linear_acc_mag', 'linear_acc_mag_jerk',\n        'angular_vel_x', 'angular_vel_y', 'angular_vel_z',\n        'angular_distance',\n    ]\n    if use_tof_thm:\n        feature_names = base_feature_names + tof_thm_cols\n    else:\n        feature_names = base_feature_names\n         \n    numerical_features = [f for f in feature_names if f not in CATEGORICAL_FEATURES]\n\n    sequence = feature_engineering(sequence)\n\n    sequence[numerical_features] = scaler.transform(sequence[numerical_features])\n    \n    sequence = sequence.groupby(['sequence_id', 'subject']).apply(\n        lambda df: df[feature_names].to_dict(orient='records'),\n        include_groups=False,\n    )\n    \n    sequence = sequence.iloc[0]\n\n    predicted_label = pred_model.predict(sequence)\n\n    return predicted_label\n","metadata":{"execution":{"iopub.execute_input":"2026-08-14T03:21:49.406296Z","iopub.status.busy":"2026-08-14T03:21:49.4057Z","iopub.status.idle":"2026-08-14T03:21:49.412743Z","shell.execute_reply":"2026-08-14T03:21:49.41213Z"},"papermill":{"duration":0.016058,"end_time":"2026-08-14T03:21:49.413753","exception":false,"start_time":"2026-08-14T03:21:49.397695","status":"completed"},"tags":[]},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## API","metadata":{"papermill":{"duration":0.006992,"end_time":"2026-08-14T03:21:49.428047","exception":false,"start_time":"2026-08-14T03:21:49.421055","status":"completed"},"tags":[]}},{"cell_type":"code","source":"import kaggle_evaluation.cmi_inference_server\ninference_server = kaggle_evaluation.cmi_inference_server.CMIInferenceServer(predict)\n\nif os.getenv('KAGGLE_IS_COMPETITION_RERUN'):\n    inference_server.serve()\nelse:\n    inference_server.run_local_gateway(\n        data_paths=(\n            '/kaggle/input/cmi-detect-behavior-with-sensor-data/test.csv',\n            '/kaggle/input/cmi-detect-behavior-with-sensor-data/test_demographics.csv',\n        )\n    )","metadata":{"execution":{"iopub.execute_input":"2026-08-14T03:21:49.443625Z","iopub.status.busy":"2026-08-14T03:21:49.443083Z","iopub.status.idle":"2026-08-14T03:21:52.461657Z","shell.execute_reply":"2026-08-14T03:21:52.460798Z"},"papermill":{"duration":3.027646,"end_time":"2026-08-14T03:21:52.462892","exception":false,"start_time":"2026-08-14T03:21:49.435246","status":"completed"},"tags":[]},"outputs":[],"execution_count":null},{"cell_type":"code","source":"","metadata":{"papermill":{"duration":0.007613,"end_time":"2026-08-14T03:21:52.479231","exception":false,"start_time":"2026-08-14T03:21:52.471618","status":"completed"},"tags":[]},"outputs":[],"execution_count":null}]}