Spaces:
Sleeping
Sleeping
File size: 9,226 Bytes
8b47064 e69e3a3 8b47064 e69e3a3 8b47064 e69e3a3 8b47064 e69e3a3 8b47064 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 | """
Feature importance and leave-one-feature-out ablation for the 10 face_orientation features.
Run: python -m evaluation.feature_importance
Outputs:
- XGBoost gain-based importance (from trained checkpoint)
- Leave-one-feature-out LOPO F1 (ablation): drop each feature in turn, report mean LOPO F1.
- Writes evaluation/feature_selection_justification.md
"""
import os
import sys
import numpy as np
from sklearn.preprocessing import StandardScaler
from sklearn.metrics import f1_score
from xgboost import XGBClassifier
_PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
if _PROJECT_ROOT not in sys.path:
sys.path.insert(0, _PROJECT_ROOT)
from data_preparation.prepare_dataset import load_per_person, SELECTED_FEATURES
SEED = 42
FEATURES = SELECTED_FEATURES["face_orientation"]
def _resolve_xgb_path():
return os.path.join(_PROJECT_ROOT, "checkpoints", "xgboost_face_orientation_best.json")
def xgb_feature_importance():
"""Load trained XGBoost and return gain-based importance for the 10 features."""
path = _resolve_xgb_path()
if not os.path.isfile(path):
print(f"[WARN] No XGBoost checkpoint at {path}; skip importance.")
return None
model = XGBClassifier()
model.load_model(path)
imp = model.get_booster().get_score(importance_type="gain")
# Booster uses f0, f1, ...; we use same order as FEATURES (training order)
by_idx = {int(k.replace("f", "")): v for k, v in imp.items() if k.startswith("f")}
order = [by_idx.get(i, 0.0) for i in range(len(FEATURES))]
return dict(zip(FEATURES, order))
def run_ablation_lopo():
"""Leave-one-feature-out: for each feature, train XGBoost on the other 9 with LOPO, report mean F1."""
by_person, _, _ = load_per_person("face_orientation")
persons = sorted(by_person.keys())
n_folds = len(persons)
results = {}
for drop_feat in FEATURES:
idx_keep = [i for i, f in enumerate(FEATURES) if f != drop_feat]
f1s = []
for held_out in persons:
train_X = np.concatenate([by_person[p][0] for p in persons if p != held_out])
train_y = np.concatenate([by_person[p][1] for p in persons if p != held_out])
X_test, y_test = by_person[held_out]
X_tr = train_X[:, idx_keep]
X_te = X_test[:, idx_keep]
scaler = StandardScaler().fit(X_tr)
X_tr_sc = scaler.transform(X_tr)
X_te_sc = scaler.transform(X_te)
xgb = XGBClassifier(
n_estimators=600, max_depth=8, learning_rate=0.05,
subsample=0.8, colsample_bytree=0.8,
reg_alpha=0.1, reg_lambda=1.0,
eval_metric="logloss",
random_state=SEED, verbosity=0,
)
xgb.fit(X_tr_sc, train_y)
pred = xgb.predict(X_te_sc)
f1s.append(f1_score(y_test, pred, average="weighted"))
results[drop_feat] = np.mean(f1s)
return results
def run_baseline_lopo_f1():
"""Full 10-feature LOPO mean F1 for reference."""
by_person, _, _ = load_per_person("face_orientation")
persons = sorted(by_person.keys())
f1s = []
for held_out in persons:
train_X = np.concatenate([by_person[p][0] for p in persons if p != held_out])
train_y = np.concatenate([by_person[p][1] for p in persons if p != held_out])
X_test, y_test = by_person[held_out]
scaler = StandardScaler().fit(train_X)
X_tr_sc = scaler.transform(train_X)
X_te_sc = scaler.transform(X_test)
xgb = XGBClassifier(
n_estimators=600, max_depth=8, learning_rate=0.05,
subsample=0.8, colsample_bytree=0.8,
reg_alpha=0.1, reg_lambda=1.0,
eval_metric="logloss",
random_state=SEED, verbosity=0,
)
xgb.fit(X_tr_sc, train_y)
pred = xgb.predict(X_te_sc)
f1s.append(f1_score(y_test, pred, average="weighted"))
return np.mean(f1s)
# Channel subsets for ablation (subset name -> list of feature names)
CHANNEL_SUBSETS = {
"head_pose": ["head_deviation", "s_face", "pitch"],
"eye_state": ["ear_left", "ear_avg", "ear_right", "perclos"],
"gaze": ["h_gaze", "gaze_offset", "s_eye"],
}
def run_channel_ablation():
"""LOPO XGBoost with head-only, eye-only, gaze-only, and all 10. Returns dict subset_name -> mean F1."""
by_person, _, _ = load_per_person("face_orientation")
persons = sorted(by_person.keys())
results = {}
for subset_name, feat_list in CHANNEL_SUBSETS.items():
idx_keep = [FEATURES.index(f) for f in feat_list]
f1s = []
for held_out in persons:
train_X = np.concatenate([by_person[p][0] for p in persons if p != held_out])
train_y = np.concatenate([by_person[p][1] for p in persons if p != held_out])
X_test, y_test = by_person[held_out]
X_tr = train_X[:, idx_keep]
X_te = X_test[:, idx_keep]
scaler = StandardScaler().fit(X_tr)
X_tr_sc = scaler.transform(X_tr)
X_te_sc = scaler.transform(X_te)
xgb = XGBClassifier(
n_estimators=600, max_depth=8, learning_rate=0.05,
subsample=0.8, colsample_bytree=0.8,
reg_alpha=0.1, reg_lambda=1.0,
eval_metric="logloss",
random_state=SEED, verbosity=0,
)
xgb.fit(X_tr_sc, train_y)
pred = xgb.predict(X_te_sc)
f1s.append(f1_score(y_test, pred, average="weighted"))
results[subset_name] = np.mean(f1s)
baseline = run_baseline_lopo_f1()
results["all_10"] = baseline
return results
def main():
print("=== Feature importance (XGBoost gain) ===")
imp = xgb_feature_importance()
if imp:
for name in FEATURES:
print(f" {name}: {imp.get(name, 0):.2f}")
order = sorted(imp.items(), key=lambda x: -x[1])
print(" Top-5 by gain:", [x[0] for x in order[:5]])
print("\n=== Leave-one-feature-out ablation (LOPO mean F1) ===")
baseline = run_baseline_lopo_f1()
print(f" Baseline (all 10 features) mean LOPO F1: {baseline:.4f}")
ablation = run_ablation_lopo()
for feat in FEATURES:
delta = baseline - ablation[feat]
print(f" drop {feat}: F1={ablation[feat]:.4f} (Δ={delta:+.4f})")
worst_drop = min(ablation.items(), key=lambda x: x[1])
print(f" Largest F1 drop when dropping: {worst_drop[0]} (F1={worst_drop[1]:.4f})")
print("\n=== Channel ablation (LOPO mean F1) ===")
channel_f1 = run_channel_ablation()
for name, f1 in channel_f1.items():
print(f" {name}: {f1:.4f}")
out_dir = os.path.join(_PROJECT_ROOT, "evaluation")
out_path = os.path.join(out_dir, "feature_selection_justification.md")
lines = [
"# Feature selection justification",
"",
"The face_orientation model uses 10 of 17 extracted features. This document summarises empirical support.",
"",
"## 1. Domain rationale",
"",
"The 10 features were chosen to cover three channels:",
"- **Head pose:** head_deviation, s_face, pitch",
"- **Eye state:** ear_left, ear_right, ear_avg, perclos",
"- **Gaze:** h_gaze, gaze_offset, s_eye",
"",
"Excluded: v_gaze (noisy), mar (rare events), yaw/roll (redundant with head_deviation/s_face), blink_rate/closure_duration/yawn_duration (temporal overlap with perclos).",
"",
"## 2. XGBoost feature importance (gain)",
"",
"From the trained XGBoost checkpoint (gain on the 10 features):",
"",
"| Feature | Gain |",
"|---------|------|",
]
if imp:
for name in FEATURES:
lines.append(f"| {name} | {imp.get(name, 0):.2f} |")
order = sorted(imp.items(), key=lambda x: -x[1])
lines.append("")
lines.append(f"**Top 5 by gain:** {', '.join(x[0] for x in order[:5])}.")
else:
lines.append("(Run with XGBoost checkpoint to populate.)")
lines.extend([
"",
"## 3. Leave-one-feature-out ablation (LOPO)",
"",
f"Baseline (all 10 features) mean LOPO F1: **{baseline:.4f}**.",
"",
"| Feature dropped | Mean LOPO F1 | Δ vs baseline |",
"|------------------|--------------|---------------|",
])
for feat in FEATURES:
delta = baseline - ablation[feat]
lines.append(f"| {feat} | {ablation[feat]:.4f} | {delta:+.4f} |")
worst_drop = min(ablation.items(), key=lambda x: x[1])
lines.append("")
lines.append(f"Dropping **{worst_drop[0]}** hurts most (F1={worst_drop[1]:.4f}), consistent with it being important.")
lines.append("")
lines.append("## 4. Conclusion")
lines.append("")
lines.append("Selection is supported by (1) domain rationale (three attention channels), (2) XGBoost gain importance, and (3) leave-one-out ablation. SHAP or correlation-based pruning can be added in future work.")
lines.append("")
with open(out_path, "w", encoding="utf-8") as f:
f.write("\n".join(lines))
print(f"\nReport written to {out_path}")
if __name__ == "__main__":
main()
|