--- tags: - fall-detection - xgboost - classification - feature-engineering - keypoint-features library_name: xgboost --- # Fall Detection - XGBoost Classifier ## Model description - Model name: XGBoost Priority 1 Fall Detection Classifier - Version: `v1` (Experiment 01) - Status: `experimantal` - Repository visibility: internal - Model type: binary gradient-boosted classifier (`xgboost.XGBClassifier`) - Input contract: **51 keypoint features only** (17 keypoints × `x, y, confidence`) + bbox `(x1,y1,x2,y2)` — the extra 5 features (`aspect_ratio`, `nose_relative_y`, `torso_angle`, `norm_com_y`, `head_hip_v_dist`) are **extracted in `scripts/run.py`**, not received from upstream; no RGB image input - Output contract: `Fall` / `Normal` (`P(Fall) >= 0.70` → Fall) - Model artifact: `models/xgboost_priority1_fall_model.pkl` - Reference date: 2026-09-21 This repository documents only the **classifier stage** of the fall detection system. **Input contract: 51 keypoint features + bbox only.** The model itself is fed a **56-column pandas DataFrame** that `scripts/run.py` builds by extracting the extra 5 features from that input — upstream never provides the 5, and the model never sees pixels, frames, or CSV files directly. ``` CCTV IMAGE → YOLO26x-Pose → 51 features + bbox (DataFrame) → run.py: +5 features → 56-col DataFrame → XGBoost → Fall / Normal upstream (out of scope) this repo ↑ direct model input ``` The classifier achieves **91.00% accuracy** on the standard test set, **92.67% accuracy / 90.91% F1** on real-world CCTV evaluation, and up to **98.05% precision** for fall events at a strict probability threshold. ## Problem statement Classifying falls directly from raw keypoint coordinates suffers from a severe domain shift: a raw-keypoint baseline reached 92.22% accuracy on a standard train/test split but collapsed to 66.83% on real-world CCTV footage. **Analysis of real-world failures revealed three critical bottlenecks:** 1. **Camera Angle & Height Sensitivity**: Raw coordinates (X, Y) shift significantly when camera pitch, installation height, or lens focal length changes 2. **Crowd & Limb Occlusion**: Lower-body keypoints (knees, ankles) are frequently obscured by furniture or bystanders, causing standard feature vectors to drop key geometry 3. **Loss of Relative Posture Ratios**: Single coordinates lack explicit representation of body aspect ratios, torso inclination, and center-of-mass vertical drops This model resolves the problem with an engineered **56-feature representation** incorporating 5 Priority 1 CCTV-invariant features, improving real-world accuracy to 88.36%+ while keeping 91% on standard test sets. ## Input contract This model/pipeline **does not take an RGB image**. The **input contract is only the 51 keypoint features** (+ bounding box needed to derive the engineered features). The **extra 5 features are extracted inside `scripts/run.py`** — they are never part of the upstream input. ### What goes IN (upstream → `run.py`): 51 features + bbox 1. **Upstream (out of scope)**: YOLO-Pose → 17 keypoints → **51 features only** (`x_i, y_i, conf_i` for i = 0…16, x/y normalized to bbox) + bounding box `(x1, y1, x2, y2)`, delivered as a pandas DataFrame 2. **`scripts/run.py` (this repo)**: extracts the **5 engineered features from that input** → builds the 56-column DataFrame → passes it to XGBoost ``` CCTV IMAGE │ ▼ YOLO26x-Pose (upstream) │ ├── Person bounding box (x1, y1, x2, y2) │ └── 17 pose keypoints (x, y, confidence) │ ▼ INPUT CONTRACT: 51 features + bbox (pandas DataFrame) │ │ scripts/run.py extracts: ├── aspect_ratio ├── nose_relative_y ├── torso_angle ├── norm_com_y └── head_hip_v_dist │ ▼ 56-column DataFrame → XGBoost.predict_proba ``` ### Input columns accepted by `run.py` | Columns | Count | Source | | --- | ---: | --- | | `x_i, y_i, conf_i` for i = 0…16 | **51** | Upstream (input contract) | | `x1, y1, x2, y2` | 4 | Upstream bbox (needed to compute the 5) | | | **55** | **Total input to `run.py`** | - **Dtype**: `float`; `NaN` allowed for occluded keypoints (conf < 0.50 → x/y = NaN) - **Not accepted**: RGB/BGR images, video frames, raw pixels — only the 51 features + bbox DataFrame - **Validation**: each row should have ≥ 5 valid keypoints; otherwise prediction is skipped - **Cardinality**: one row = one person = one prediction. No identity tracking across frames ### What `run.py` extracts (the extra 5 — computed here, not received) The 5 engineered features are **derived inside `run.py`** from the 51 + bbox input and appended as columns 52–56: | Pos | Feature | Formula | Derived from | | --- | --- | --- | --- | | 52 | `aspect_ratio` | `w / h` | bbox | | 53 | `nose_relative_y` | `(Y_nose - y1) / h` | keypoint 0 (nose) | | 54 | `torso_angle` | Angle HipMid → ShoulderMid vs. vertical Y-axis (degrees) | keypoints 5,6,11,12 + bbox | | 55 | `norm_com_y` | Confidence-weighted CoM Y: `(Σ(Y_i·conf_i)/Σ(conf_i) - y1)/h` | all visible keypoints | | 56 | `head_hip_v_dist` | `(Y_hip_mid - Y_nose) / h` | keypoints 0,11,12 | ### What XGBoost receives (after `run.py`) - **Type**: `pandas.DataFrame` (in-memory; not a CSV file) - **Shape**: `(n, 56)` — 51 input features + 5 extracted in `run.py` - **Column order**: `FEATURE_COLS` in `scripts/run.py` (positions 1–51 upstream, 52–56 computed here) - CSV is **never** fed to the model — it is only a transport format into a DataFrame ## Feature engineering rationale The 5 Priority 1 CCTV-invariant features give the classifier a representation that raw coordinates cannot provide: | Feature Name | Formula | CCTV Robustness | Physical Significance | | --- | --- | --- | --- | | **aspect_ratio** | W / H | Very High | Bounding box horizontal spread. Upright: < 1.0, Lying: > 1.0 | | **nose_relative_y** | (Y_nose - Y1) / H | Very High | Head position in box. Standing: 0.0–0.25, Fallen: 0.7–1.0 | | **torso_angle** | Angle(Shoulder-Mid → Hip-Mid) vs Y-axis | High | Resists perspective. Standing: ~0°, Fallen: 80–90° | | **norm_com_y** | Weighted CoM using visible keypoints | High | Vertical center-of-mass. Robust to leg occlusion | | **head_hip_v_dist** | (Y_hip - Y_nose) / H | High | Upper-body extension. Collapses near 0.0 when lying flat | These features provide: - **Scale invariance**: Ratios and normalized coordinates remain stable across camera distances - **Angle invariance**: Geometric relationships resist perspective distortion - **Occlusion robustness**: Upper-body features work even when the lower body is blocked ## Pipeline architecture ``` CCTV IMAGE │ ▼ YOLO26x-Pose │ ├── Person bounding box │ └── 17 pose keypoints │ ├── x ├── y └── confidence │ ▼ 51 features (17 keypoints × 3) + bbox ← upstream pandas DataFrame │ │ scripts/run.py: │ build_feature_frame(upstream_df) ├── aspect_ratio ├── nose_relative_y ├── torso_angle ├── norm_com_y └── head_hip_v_dist │ ▼ 56-column pandas DataFrame ──► XGBoost.predict_proba(df) ──► Fall / Normal ``` Upstream (out of scope) produces the **51 keypoint features + bounding box** as a DataFrame. `scripts/run.py` computes the **5 engineered features**, builds the **56-column DataFrame**, and passes that DataFrame to XGBoost — that stage is in scope for this repository. The model never receives a CSV at inference. ## Output contract For each **row of the 56-column DataFrame** the XGBoost classifier returns a binary decision: ``` 56-column DataFrame → XGBoost → Fall / Normal ``` - `model.predict(X)` → class id: `0` = Normal, `1` = Fall - `model.predict_proba(X)` → `[P(Normal), P(Fall)]` - **Decision rule**: `P(Fall) >= FALL_PROB_THRESH (0.70)` → `Fall`, otherwise `Normal` Example output for one person: ```json { "label_id": 1, "prediction": "Fall", "fall_probability": 0.97, "normal_probability": 0.03, "threshold": 0.70 } ``` One input vector yields exactly one `Fall` / `Normal` label. Class mapping is defined in `config.json` (`0: Normal, 1: Fall`). `fall_probability` can be threshold-tuned (see Configuration and thresholds). ## Configuration and thresholds Classifier configuration lives in `config.json`: ```python CONF_THRESH = 0.50 # Input-side: keypoint confidence below this -> NaN coordinates FALL_PROB_THRESH = 0.70 # Decision threshold on P(Fall) (optimized via grid search) ``` **Optimized performance at these thresholds:** - Accuracy: 92.67% - F1-Score: 90.91% - Precision: 90.11% - Recall: 91.73% **Tunable threshold guidance** — adjust `FALL_PROB_THRESH` based on deployment priorities: - **High precision (fewer false alarms)**: 0.95–0.98 (up to 98.05% precision) - **High recall (catch more falls)**: 0.80–0.90 - **Balanced (default)**: 0.70 `CONF_THRESH` controls how aggressively occluded keypoints become `NaN`. Higher values tolerate less occlusion; lower values keep noisier coordinates. ## Model architecture and training **Algorithm**: XGBoost gradient-boosted decision trees, trained from scratch on the 56-feature dataset. | Hyperparameter | Value | | --- | --- | | `n_estimators` | 150 | | `max_depth` | 5 | | `learning_rate` | 0.03 | | `scale_pos_weight` | 1.0 (train split pre-balanced 1:1) | | `missing` | `np.nan` (native occlusion handling) | | `eval_metric` | `logloss` | | `random_state` | 42 | **Training procedure** (`scripts/train.py`): 1. Load `pose_benchmark_priority1_dataset.csv` (56 features + `split` + `label`) 2. Undersample the train split to an exact 1:1 Normal:Fall ratio (`random_state=42`); test split is left intact 3. Train XGBoost on raw features with native NaN support (no imputer/scaler) 4. Benchmark against Random Forest, SVM (RBF) and MLP; export all artifacts to `models/` **Why XGBoost**: it outperformed Random Forest, SVM and MLP on both standard and real-world test sets, and its native `missing=np.nan` handling removes the need for an imputation pipeline when keypoints are occluded. ## Runtime requirements - **Python**: 3.10+ - **Key dependencies** (classifier only): - XGBoost ≥ 2.0.0 (native NaN handling for occluded keypoints) - NumPy ≥ 1.26.0 - joblib ≥ 1.3.0 (model serialization) - pandas ≥ 2.0.0, scikit-learn ≥ 1.3.0 (training and evaluation only) Install dependencies: ```bash pip install -r requirements.txt ``` The classifier is lightweight and runs on CPU; no GPU is required for training or inference. ## Performance ### Training performance (test set, after 1:1 balancing) **Dataset**: 6,607 samples (3,207 Normal, 3,400 Fall) **Model**: XGBoost with 56 engineered features ``` precision recall f1-score support Normal (0) 0.90 0.91 0.91 3207 Fall (1) 0.92 0.91 0.91 3400 accuracy 0.91 6607 macro avg 0.91 0.91 0.91 6607 weighted avg 0.91 0.91 0.91 6607 ``` **Overall accuracy**: 91.00% ### Threshold grid search (real-world CCTV evaluation) Grid search over the input keypoint confidence and the decision threshold: - Keypoint confidence threshold: 0.25, 0.50 - Fall probability threshold: 0.20 – 0.95 (9 steps) **Top parameter combinations (by F1-score):** | Keypoint_Conf | Fall_Prob_Thresh | Accuracy | F1_Score | Precision | Recall | TN | FP | FN | TP | | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | | 0.50 | 0.70 | 92.67% | 90.91% | 90.11% | 91.73% | 390 | 28 | 23 | 255 | | 0.50 | 0.80 | 92.67% | 90.68% | 92.19% | 89.21% | 397 | 21 | 30 | 248 | | 0.50 | 0.60 | 92.10% | 90.30% | 88.58% | 92.09% | 385 | 33 | 22 | 256 | | 0.50 | 0.90 | 92.53% | 90.04% | 96.31% | 84.53% | 409 | 9 | 43 | 235 | | 0.50 | 0.50 | 91.24% | 89.43% | 86.29% | 92.81% | 377 | 41 | 20 | 258 | | 0.50 | 0.40 | 90.80% | 89.19% | 84.08% | 94.96% | 368 | 50 | 14 | 264 | | 0.50 | 0.30 | 89.51% | 88.09% | 80.60% | 97.12% | 353 | 65 | 8 | 270 | | 0.50 | 0.20 | 88.07% | 86.80% | 77.78% | 98.20% | 340 | 78 | 5 | 273 | | 0.50 | 0.95 | 88.36% | 83.23% | 98.05% | 72.30% | 414 | 4 | 77 | 201 | **Confusion matrix (selected configuration, threshold 0.70):** ``` Predicted Normal Fall Actual Normal 390 28 (TN, FP) Fall 23 255 (FN, TP) ``` **Key insights:** - A keypoint confidence of 0.50 gives the best balance between occlusion tolerance and noisy coordinates - A fall probability threshold of 0.70 achieves the best F1-score; stricter 0.95 maximizes precision (98.05%) but misses ~28% of falls - This configuration (CONF_THRESH=0.50, FALL_PROB_THRESH=0.70) is the deployment default ## Intended use This classifier is intended for feature-vector-based fall detection in: - Live concert environment - On-the-road and touring conditions - IN indoor condition This is a safety assistance tool, not a replacement for human supervision. ## Limitations - **Degrades on heavily degraded inputs**: vectors where most keypoints are `NaN` (severe occlusion, >70% of body blocked) or extreme camera angles (>60° from vertical) fall outside the training distribution - **No temporal smoothing**: single-vector classification without motion history. May produce momentary false positives during normal sitting/bending motions - **No identity tracking**: each vector is classified independently; deployments need their own deduplication for repeated alerts on the same person - **Threshold-sensitive**: precision/recall trade-offs depend on `FALL_PROB_THRESH` and must be tuned per environment - **Training-distribution bound**: trained on a specific dataset; performance on new camera placements, demographics, or fall types should be validated before experimental use - **Oclusion**: if a fallen person is ocluded by a perosn or a object it might don't detect the person as fallen ## Dataset location Training and evaluation datasets are stored at: ``` /home/ctspl/model_training/fall/version3/data/ ``` ## Ownership - Model trainer/developer: Nishant Prasad - Dataset source: Internal CCTV footage collection - Model architecture: XGBoost (`XGBClassifier`, 56 input features) - Training date: 2026-09-21 - Status: experimental ## Repository layout ```text fall-detection/ ├── README.md ├── MODEL_CARD.md ├── CHANGELOG.md ├── config.json # Feature schema, thresholds and paths ├── requirements.txt ├── models/ │ └── xgboost_priority1_fall_model.pkl # Trained XGBoost classifier ├── scripts/ │ ├── train.py # Training + benchmark pipeline │ ├── evaluate.ipynb # Evaluation notebook (DataFrame path) │ └── run.py # Inference: upstream DF (51+bbox) → +5 → 56-col DF → XGBoost ├── docs/ │ ├── spec.md # Development specification │ ├── data.md # Dataset schema │ └── train.log.md # Training provenance └── data/ # Datasets (not committed) ```