{"metadata":{"kernelspec":{"name":"python3","display_name":"Python 3","language":"python"},"language_info":{"name":"python","version":"3.11.13","mimetype":"text/x-python","codemirror_mode":{"name":"ipython","version":3},"pygments_lexer":"ipython3","nbconvert_exporter":"python","file_extension":".py"},"kaggle":{"accelerator":"none","dataSources":[{"sourceId":102335,"databundleVersionId":12518947,"isSourceIdPinned":false,"sourceType":"competition"},{"sourceId":12935257,"sourceType":"datasetVersion","datasetId":8185393}],"dockerImageVersionId":31089,"isInternetEnabled":true,"language":"python","sourceType":"notebook","isGpuEnabled":false}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"markdown","source":"# Baseline Feature Engineering and Model Training (LightGBM) for BFRB Detection with Helios Sensor Data\n\nIn this notebook, we train a baseline model for classifying body-focused repetitive behaviors (BFRBs) in the\n[CMI - Detect Behavior with Sensor Data](https://www.kaggle.com/competitions/cmi-detect-behavior-with-sensor-data)\nKaggle competition. In the [EDA notebook](eda-cmi-bfrb.ipynb), we analyzed the time series dataset collected from IMUs\n(acceleration and orientation), thermopiles (temperature), and time-of-flight sensors (distance). We engineer basic\nfeatures and aggregates for our inputs and train a LightGBM gradient boosting model to classify the gestures the\nsubjects are performing in the test data. This notebook will provide a comparison for evaluating more complex features\nand ensembles of classification models.\n\n## Table of Contents\n- [1. Feature Engineering](#1-feature-engineering)\n- [2. Model Training](#2-model-training)","metadata":{}},{"cell_type":"code","source":"import json\nimport os\nfrom pathlib import Path\n\nimport joblib\nimport lightgbm as lgb\nimport numpy as np\nimport optuna\nimport pandas as pd\nfrom joblib import Parallel, delayed\nfrom scipy.fft import rfft, rfftfreq\nfrom scipy.stats import entropy, kurtosis, skew\nfrom sklearn.metrics import f1_score\nfrom sklearn.model_selection import StratifiedKFold\nfrom sklearn.preprocessing import LabelEncoder\nfrom tqdm import tqdm\n\n# Whether to generate a new model.\nGEN_NEW_MODEL = False\n\nRANDOM_SEED = 42\nDATA_DIR = Path(\"../kaggle/input/cmi-detect-behavior-with-sensor-data\").resolve()\nMODELS_DIR = Path(\"../models\").resolve()\n\nif os.environ.get(\"KAGGLE_KERNEL_RUN_TYPE\"):\n    DATA_DIR = Path(\"/kaggle/input/cmi-detect-behavior-with-sensor-data\").resolve()\n    if GEN_NEW_MODEL:\n        MODELS_DIR = Path(\"/kaggle/working/models\").resolve()\n    else:\n        MODELS_DIR = Path(\"/kaggle/input/cmi-lightgbm-baseline\").resolve()\n\nMODELS_DIR.mkdir(exist_ok=True, parents=True)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-09-02T00:12:31.635283Z","iopub.execute_input":"2025-09-02T00:12:31.635587Z","iopub.status.idle":"2025-09-02T00:12:45.568786Z","shell.execute_reply.started":"2025-09-02T00:12:31.635561Z","shell.execute_reply":"2025-09-02T00:12:45.567353Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 1. Feature Engineering","metadata":{}},{"cell_type":"markdown","source":"Let's begin by reading in the training data and left joining the demographics data to it.","metadata":{}},{"cell_type":"code","source":"train = pd.read_csv(DATA_DIR.joinpath(\"train.csv\"))\ntrain_demo = pd.read_csv(DATA_DIR.joinpath(\"train_demographics.csv\"))\ntrain = train.merge(train_demo, on=\"subject\", how=\"left\")\n\ntrain.head()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-09-02T00:12:45.570838Z","iopub.execute_input":"2025-09-02T00:12:45.571602Z","iopub.status.idle":"2025-09-02T00:13:26.521720Z","shell.execute_reply.started":"2025-09-02T00:12:45.571569Z","shell.execute_reply":"2025-09-02T00:13:26.520658Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"Let's begin by processing our features. First, we need to choose a baseline model for which we will optimize our feature engineering. For time-series analysis, popular choices are recurrent neural networks (RNNs), Transformers, and tree models. Deep learning architectures such as RNNs, like long short-term memory (LSTM), and transformers can perform very well at learning patterns in time-series data [[Xiao _et al_., 2024](https://doi.org/10.48550/arXiv.2411.05790)]. However, they require large amounts of input data to be effective, whereas in our case we only have about 8,000 sequences. Two popular decision tree algorithms are extreme gradient boosting (XGBoost) and light gradient boosting machine ([LightGBM](https://lightgbm.readthedocs.io/en/stable/)). Decision trees learn patterns by forming bifurcations at questions they learn to ask about the data. These two algorithms employ a technique called gradient boosting, which is serially feeding the outputs of decision trees into others to iteratively correct mistakes. Compared to XGBoost, LightGBM prioritizes vertical growth of trees and buckets values into histograms, which allows it to train faster while optimizing for memory usage [[Saha, 2025](https://neptune.ai/blog/xgboost-vs-lightgbm)]. For these reasons, I believe LightGBM will make a strong choice for our baseline model. \n\nNow that we have chosen our baseline model, we must correspondingly engineer its input features. Instead of learning patterns directly from the time-series like an RNN or transformer, LightGBM will take in one row of input features. This means we must choose aggregates of our data that will effectively capture the patterns needed to classify the gestures based on the sensor data in the wrist-worn device. Since each sequence goes through four phases (rest, transition, rest, gesture), we can break our sequence up into segments to aggregate with the hope that this will capture how the sequence's aggregates will change over time. We can capture common statistical measures like mean, variance, etc. as well as spectral features from a discrete Fourier transform, which we will only perform on the latter segments as that is when the gestures will be taking place and therefore the oscillations will be more meaningful. Let's start by writing a function to gather the time-domain statistical measures from our sensor data.","metadata":{}},{"cell_type":"code","source":"def get_agg_stats_features(df: pd.DataFrame, segment_idx: int) -> dict[str, np.float32]:\n    \"\"\"Returns the time-domain statistical aggregates for each time-series feature in the data frame.\n\n    Args:\n        df (pd.DataFrame): Input data frame.\n        segment_idx (int): Index of the segment of the data frame.\n\n    Returns:\n        dict[str, np.float32]: Mapping from all aggregate feature names to their values.\n    \"\"\"\n    epsilon = 1e-8\n    features: dict[str, np.float32] = {}\n\n    for col in df.columns:\n        data = df[col].to_numpy()\n        features[f\"{col}_{segment_idx}_mean\"] = np.mean(data)\n        features[f\"{col}_{segment_idx}_std\"] = np.std(data)\n        features[f\"{col}_{segment_idx}_min\"] = np.min(data)\n        features[f\"{col}_{segment_idx}_max\"] = np.max(data)\n        features[f\"{col}_{segment_idx}_median\"] = np.median(data)\n        features[f\"{col}_{segment_idx}_range\"] = (\n            features[f\"{col}_{segment_idx}_max\"] - features[f\"{col}_{segment_idx}_min\"]\n        )\n\n        if np.std(data) > epsilon:\n            features[f\"{col}_{segment_idx}_skew\"] = skew(data)\n            features[f\"{col}_{segment_idx}_kurtosis\"] = kurtosis(data)\n        else:\n            features[f\"{col}_{segment_idx}_skew\"] = np.float32(0.0)\n            features[f\"{col}_{segment_idx}_kurtosis\"] = np.float32(0.0)\n\n    return features","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-09-02T00:13:26.522890Z","iopub.execute_input":"2025-09-02T00:13:26.523577Z","iopub.status.idle":"2025-09-02T00:13:26.532852Z","shell.execute_reply.started":"2025-09-02T00:13:26.523549Z","shell.execute_reply":"2025-09-02T00:13:26.531644Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"Now let's write a function to get the spectral features from our data. Some important features of interest are the dominant frequency of oscillation, centroid, bandwidth, and entropy. The centroid is the center of gravity of the spectrum, bandwidth (or spectral spread) is the standard deviation around the centroid, and entropy measures the \"peakiness\" of the spectrum [[Pilanci, 2021](https://web.stanford.edu/class/ee269/Lecture3_spectral_features.pdf)].","metadata":{}},{"cell_type":"code","source":"def get_spectral_features(\n    name: str, segment_idx: int, signal: np.ndarray, epsilon: float = 1e-12, min_freq: float = 0.1, num_bands: int = 4\n) -> dict[str, np.float32]:\n    \"\"\"Gets the spectral features from a 1-D time-series signal of sensor data.\n\n    Args:\n        name (str): Name of the column.\n        segment_idx (int): Index of the segment of the data frame.\n        signal (np.ndarray): 1-D time-series signal of sensor data.\n        epsilon (float, optional): Prevents division by zero. Defaults to 1e-12.\n        min_freq (float, optional): Lower limit for the high-pass filter. Defaults to 0.1.\n        num_bands (int, optional): Number of bands for which to split the frequencies when analyzing the energy per\n            band. Defaults to 4.\n\n    Returns:\n        dict[str, np.float32]: Spectral features from a 1-D time-series signal of sensor data.\n    \"\"\"\n    nyquist_frequency = 0.5\n    full_name = f\"{name}_{segment_idx}\"\n\n    if min_freq <= 0 or min_freq > nyquist_frequency:\n        raise ValueError(\n            f\"Lower limit for the high-pass frequency filter {min_freq} is not greater than 0 or is above the Nyquist \"\n            f\"frequency {nyquist_frequency}.\"\n        )\n\n    band_len = (nyquist_frequency - min_freq) / num_bands\n    band_edges = [(min_freq + i * band_len, min_freq + (i + 1) * band_len) for i in range(num_bands)]\n    bands = {f\"{full_name}_energy_part{i}\": edges for i, edges in enumerate(band_edges)}\n\n    n = len(signal)\n    if n < 2:\n        return {\n            f\"{full_name}_spec_centroid\": np.float32(0.0),\n            f\"{full_name}_spec_bandwidth\": np.float32(0.0),\n            f\"{full_name}_spec_entropy\": np.float32(0.0),\n            f\"{full_name}_dom_freq\": np.float32(0.0),\n            f\"{full_name}_dom_freq_mag\": np.float32(0.0),\n            **{key: np.float32(0.0) for key in bands},\n        }\n\n    freqs = rfftfreq(n, d=1)\n    fft_magnitudes = np.abs(np.array(rfft(signal)))\n    power_spectrum = fft_magnitudes**2\n\n    # Filter out frequencies below the lower limit of the high-pass filter.\n    valid = freqs >= min_freq\n    freqs = freqs[valid]\n    power_spectrum = power_spectrum[valid]\n    fft_magnitudes = fft_magnitudes[valid]\n\n    total_power = np.sum(power_spectrum) + epsilon\n    norm_power = power_spectrum / total_power\n    centroid = np.sum(freqs * norm_power)\n    bandwidth = np.sqrt(np.dot(norm_power, (freqs - centroid) ** 2))\n    spec_entropy = np.float32(entropy(norm_power))\n    dom_idx = np.argmax(power_spectrum)\n    dom_freq = freqs[dom_idx]\n    dom_mag = fft_magnitudes[dom_idx]\n\n    band_energies = {}\n    for key, (f_low, f_high) in bands.items():\n        idxs = np.where((freqs >= f_low) & (freqs < f_high))[0]\n        band_energy = np.sum(norm_power[idxs])\n        band_energies[key] = band_energy\n\n    return {\n        f\"{full_name}_spec_centroid\": centroid,\n        f\"{full_name}_spec_bandwidth\": bandwidth,\n        f\"{full_name}_spec_entropy\": spec_entropy,\n        f\"{full_name}_dom_freq\": dom_freq,\n        f\"{full_name}_dom_freq_mag\": dom_mag,\n        **band_energies,\n    }","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-09-02T00:13:26.533909Z","iopub.execute_input":"2025-09-02T00:13:26.534442Z","iopub.status.idle":"2025-09-02T00:13:26.574971Z","shell.execute_reply.started":"2025-09-02T00:13:26.534235Z","shell.execute_reply":"2025-09-02T00:13:26.573739Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"Now we can write our functions to interpolate, normalize, and segment our sensor data. For now, we'll start by doing this with only our IMU data as a baseline since half the test data will be IMU-only.","metadata":{}},{"cell_type":"code","source":"def preprocess_imu_cols(seq_df: pd.DataFrame, epsilon: float = 1e-8) -> pd.DataFrame:\n    \"\"\"Preprocess the IMU columns with interpolation and normalization.\n\n    Args:\n        seq_df (pd.DataFrame): Raw sequence data frame.\n        epsilon (float, optional): Prevents division by zero. Defaults to 1e-8.\n\n    Returns:\n        pd.DataFrame: Preprocessed data fram.\n    \"\"\"\n    imu_cols = [col for col in seq_df.columns if col.startswith((\"acc_\", \"rot_\"))]\n    df = seq_df.copy().sort_values(\"sequence_counter\").reset_index(drop=True)[imu_cols]\n    df = df.interpolate(method=\"linear\", limit_direction=\"both\")\n    return (df - df.mean()) / (df.std() + epsilon)\n\n\ndef split_df(df: pd.DataFrame, num_segments: int = 6) -> list[pd.DataFrame]:\n    \"\"\"Splits the data frame row-wise into segments of (approximately) equal length.\n\n    Args:\n        df (pd.DataFrame): Preprocessed data frame.\n        num_segments (int, optional): Number of segments to split the data frame into. Defaults to 6.\n\n    Returns:\n        list[pd.DataFrame]: Data frame split into the specified number of segments.\n    \"\"\"\n    segment_length = len(df) // num_segments\n    segments = [\n        df.iloc[i * segment_length : (i + 1) * segment_length].reset_index(drop=True) for i in range(num_segments - 1)\n    ]\n    segments.append(df.iloc[(num_segments - 1) * segment_length :].reset_index(drop=True))\n    return segments\n\n\ndef get_processed_imu_features(seq_df: pd.DataFrame, epsilon: float = 1e-8, num_segments: int = 6) -> pd.DataFrame:\n    \"\"\"Returns the processed features for our baseline model given our raw sequence data frame.\n\n    Args:\n        seq_df (pd.DataFrame): Raw sequence data frame.\n        epsilon (float, optional): Prevents division by zero. Defaults to 1e-8.\n        num_segments (int, optional): Number of segments to split the data frame into. Defaults to 6.\n\n    Returns:\n        pd.DataFrame: Processed features.\n    \"\"\"\n    df = preprocess_imu_cols(seq_df, epsilon)\n    segments = split_df(df, num_segments)\n    features: dict[str, np.float32] = {}\n\n    for i, segment in enumerate(segments):\n        features.update(get_agg_stats_features(segment, i))\n        if i > num_segments // 2:\n            for col in segment.columns:\n                features.update(get_spectral_features(col, i, segment[col].to_numpy()))\n\n    return pd.DataFrame([features])","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-09-02T00:13:26.578300Z","iopub.execute_input":"2025-09-02T00:13:26.578662Z","iopub.status.idle":"2025-09-02T00:13:26.607696Z","shell.execute_reply.started":"2025-09-02T00:13:26.578635Z","shell.execute_reply":"2025-09-02T00:13:26.606332Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"Now let's use the functions we just wrote to get all the features we want to use as the input for our model.","metadata":{}},{"cell_type":"code","source":"if GEN_NEW_MODEL:\n    # Adds a progress bar.\n    tqdm.pandas()\n\n    seq_groups = list(train.groupby(\"sequence_id\"))\n\n    # Parallelize processing the training data frame across all available cores.\n    results = Parallel(n_jobs=-1)(\n        delayed(lambda group: (get_processed_imu_features(group[1]), group[1].iloc[0][\"gesture\"]))(group)\n        for group in tqdm(seq_groups)\n    )\n\n    all_features, all_labels = zip(*results, strict=False)\n    X: pd.DataFrame = pd.concat(all_features, ignore_index=True)\n    y = np.array(all_labels)\n\n    X.to_csv(MODELS_DIR.joinpath(\"X_features.csv\"), index=False)\n    pd.DataFrame({\"gesture\": y}).to_csv(MODELS_DIR.joinpath(\"y_labels.csv\"), index=False)\nelse:\n    X = pd.read_csv(MODELS_DIR.joinpath(\"X_features.csv\"))\n    y = pd.read_csv(MODELS_DIR.joinpath(\"y_labels.csv\"))[\"gesture\"].to_numpy()\n\nX.head()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-09-02T00:13:26.609326Z","iopub.execute_input":"2025-09-02T00:13:26.609815Z","iopub.status.idle":"2025-09-02T00:13:28.560933Z","shell.execute_reply.started":"2025-09-02T00:13:26.609786Z","shell.execute_reply":"2025-09-02T00:13:28.559379Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 2. Train Model\n\nNow that we have engineered our features, let's start training our model. We can take our classification targets (our gestures, e.g. `Above ear - pull hair`) and encode them into integers our LightGBM classifier model can understand.","metadata":{}},{"cell_type":"code","source":"le = LabelEncoder()\ny_encoded = le.fit_transform(y)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-09-02T00:13:28.561898Z","iopub.execute_input":"2025-09-02T00:13:28.562339Z","iopub.status.idle":"2025-09-02T00:13:28.569791Z","shell.execute_reply.started":"2025-09-02T00:13:28.562301Z","shell.execute_reply":"2025-09-02T00:13:28.568612Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"We can define an objective function we can use to turn the hyperparameters of our model. We will use k-fold cross validation to split the data into train and test sets we can average to get a more accurate view of our performance, while also dealing with class imbalance by preserving the percentage of samples in our target feature for our multiclass classification [[docs](https://scikit-learn.org/stable/modules/generated/sklearn.model_selection.StratifiedKFold.html)]. We will optimize the mean F1 score since that's the metric the competition will be using to judge the gestures predicted by our classifier model.","metadata":{}},{"cell_type":"code","source":"def objective(trial: optuna.Trial) -> float:\n    \"\"\"Returns the mean F1 score from cross validation of the training data for tuning hyperparameters.\n\n    Args:\n        trial (optuna.Trial): Hyperparameter tuning trial.\n\n    Returns:\n        float: Mean F1 score from cross validation of the training data.\n    \"\"\"\n    param = {\n        \"objective\": \"multiclass\",\n        \"num_class\": len(np.unique(y)),\n        \"metric\": \"None\",\n        \"verbosity\": -1,\n        \"boosting_type\": \"gbdt\",\n        \"n_estimators\": trial.suggest_int(\"n_estimators\", 50, 300),\n        \"learning_rate\": trial.suggest_float(\"learning_rate\", 0.01, 0.2, log=True),\n        \"num_leaves\": trial.suggest_int(\"num_leaves\", 20, 100),\n        \"max_depth\": trial.suggest_int(\"max_depth\", 3, 12),\n        \"min_child_samples\": trial.suggest_int(\"min_child_samples\", 10, 100),\n        \"subsample\": trial.suggest_float(\"subsample\", 0.5, 1.0),\n        \"colsample_bytree\": trial.suggest_float(\"colsample_bytree\", 0.5, 1.0),\n        \"reg_alpha\": trial.suggest_float(\"reg_alpha\", 1e-8, 1.0, log=True),\n        \"reg_lambda\": trial.suggest_float(\"reg_lambda\", 1e-8, 1.0, log=True),\n        \"random_state\": RANDOM_SEED,\n        \"n_jobs\": -1,\n        \"early_stopping_round\": 30,\n    }\n\n    skf = StratifiedKFold(n_splits=3, shuffle=True, random_state=RANDOM_SEED)\n    f1_scores = []\n    y_arr = np.array(y_encoded)\n\n    for train_idx, val_idx in skf.split(X, y_arr):\n        X_train, X_val = X.iloc[train_idx], X.iloc[val_idx]\n        y_train, y_val = y_arr[train_idx], y_arr[val_idx]\n\n        model = lgb.LGBMClassifier(**param)\n        model.fit(X_train, y_train, eval_set=[(X_val, y_val)], eval_metric=\"multi_logloss\")\n\n        preds = np.array(model.predict(X_val))\n        f1 = f1_score(y_val, preds, average=\"macro\")\n        f1_scores.append(f1)\n\n    return float(np.mean(f1_scores))","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-09-02T00:13:28.571257Z","iopub.execute_input":"2025-09-02T00:13:28.571705Z","iopub.status.idle":"2025-09-02T00:13:28.597575Z","shell.execute_reply.started":"2025-09-02T00:13:28.571669Z","shell.execute_reply":"2025-09-02T00:13:28.596309Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"We can now calculate the optimal hyperparameters for our LightGBM classifier model.","metadata":{}},{"cell_type":"code","source":"if GEN_NEW_MODEL:\n    study = optuna.create_study(direction=\"maximize\", pruner=optuna.pruners.MedianPruner(n_warmup_steps=5))\n    study.optimize(objective, n_trials=5, show_progress_bar=True)\n    MODELS_DIR.joinpath(\"best_lgbm_params.json\").write_text(json.dumps(study.best_params, indent=2), encoding=\"utf-8\")\n    best_params = study.best_params\nelse:\n    best_params = json.loads(MODELS_DIR.joinpath(\"best_lgbm_params.json\").read_text(encoding=\"utf-8\"))","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-09-02T00:13:28.598666Z","iopub.execute_input":"2025-09-02T00:13:28.599028Z","iopub.status.idle":"2025-09-02T00:13:28.639770Z","shell.execute_reply.started":"2025-09-02T00:13:28.598989Z","shell.execute_reply":"2025-09-02T00:13:28.638315Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"Now let's instantiate our model with the tuned hyperparameters and generate the pickle file containing the learned trees, weights, etc. from our model that we will need for inference.","metadata":{}},{"cell_type":"code","source":"best_params.update(\n    {\n        \"objective\": \"multiclass\",\n        \"num_class\": len(np.unique(y)),\n        \"random_state\": RANDOM_SEED,\n        \"n_jobs\": -1,\n    }\n)\n\nfinal_model = lgb.LGBMClassifier(**best_params)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-09-02T00:14:15.377311Z","iopub.execute_input":"2025-09-02T00:14:15.377852Z","iopub.status.idle":"2025-09-02T00:14:15.391812Z","shell.execute_reply.started":"2025-09-02T00:14:15.377815Z","shell.execute_reply":"2025-09-02T00:14:15.390170Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"if GEN_NEW_MODEL:\n    feature_names = X.columns.tolist()\n    final_model.fit(X, y_encoded)\n    joblib.dump(final_model, MODELS_DIR.joinpath(\"baseline_model.pkl\"))\n    joblib.dump(le, MODELS_DIR.joinpath(\"label_encoder.pkl\"))\n    joblib.dump(feature_names, MODELS_DIR.joinpath(\"feature_names.pkl\"))\nelse:\n    final_model: lgb.LGBMClassifier = joblib.load(MODELS_DIR.joinpath(\"baseline_model.pkl\"))\n    feature_names: list[str] = joblib.load(MODELS_DIR.joinpath(\"feature_names.pkl\"))\n    le: LabelEncoder = joblib.load(MODELS_DIR.joinpath(\"label_encoder.pkl\"))","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-09-02T00:14:19.668146Z","iopub.execute_input":"2025-09-02T00:14:19.669020Z","iopub.status.idle":"2025-09-02T00:14:19.796016Z","shell.execute_reply.started":"2025-09-02T00:14:19.668990Z","shell.execute_reply":"2025-09-02T00:14:19.794843Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"Now we can define a prediction function and test that our model works on the test data.","metadata":{}},{"cell_type":"code","source":"def predict(sequence: pd.DataFrame) -> str:\n    \"\"\"Predicts the gesture based on a sequence of IMU data.\n\n    Args:\n        sequence (pd.DataFrame): Sequence of IMU data.\n\n    Returns:\n        str: Predicted gesture.\n    \"\"\"\n    X = get_processed_imu_features(sequence)\n    X = X.reindex(columns=feature_names, fill_value=0)\n    pred = np.array(final_model.predict(X))[0]\n    return le.inverse_transform([pred])[0]","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-09-02T00:14:31.797693Z","iopub.execute_input":"2025-09-02T00:14:31.798037Z","iopub.status.idle":"2025-09-02T00:14:31.804532Z","shell.execute_reply.started":"2025-09-02T00:14:31.798014Z","shell.execute_reply":"2025-09-02T00:14:31.803279Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"test = pd.read_csv(DATA_DIR.joinpath(\"test.csv\"))\npreds: list[str] = []\n\nfor _, group in test.groupby(\"sequence_id\"):\n    preds.append(predict(group))\n\npreds","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-09-02T00:14:33.540183Z","iopub.execute_input":"2025-09-02T00:14:33.540552Z","iopub.status.idle":"2025-09-02T00:14:33.758606Z","shell.execute_reply.started":"2025-09-02T00:14:33.540525Z","shell.execute_reply":"2025-09-02T00:14:33.757766Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"","metadata":{"trusted":true},"outputs":[],"execution_count":null}]}