body-debt-stress-mlp
A 7β16β8β1 multi-layer perceptron (MLP) that maps 7 facial geometry features (extracted by MediaPipe FaceMesh) into a single fatigue/stress score between 0 and 1.
Total parameters: 553 (~1.5 KB on disk). Trained, not random. Tiny enough to run inside an EZKL Halo2 zero-knowledge circuit and a CPU-only Gradio Space. This is the smallest working "well-tuned" classifier shipped for the Build Small Hackathon.
Training
This MLP is fine-tuned on a synthetic dataset of 2,000 physiologically-motivated samples. The target function encodes the heuristics a clinician would use:
- Low average eye aspect ratio (eyes closing) β drowsy
- Low brow-to-eye distance (brow furrow) β stress
- High mouth width / height ratio (clenched jaw) β tension
- High eye asymmetry β fatigue
- Low mouth opening (slack jaw) β exhaustion
- 3 am / 3 pm time-of-day bumps β circadian low
Each sample gets small Gaussian noise on the inputs (Ο = 0.005) and the target (Ο = 0.03) so the network is forced to learn the function, not memorize specific feature vectors.
Optimizer: Adam (lr=0.01, Ξ²1=0.9, Ξ²2=0.999, Ξ΅=1e-8). Batch size: 64. Epochs: 120 (logit-space MSE).
Held-out validation (20% split):
- Val MAE: 0.060 (probability units, i.e. 6 points on the 0-100 scale)
- Val MSE: 0.0056
For context, a plain linear regression on the same 7 inputs gets MAE 0.061. The 16-8 hidden layer buys a small but real improvement over a linear baseline, with only 280 extra parameters.
Training took ~2.2 seconds in pure NumPy on a single CPU. No GPU, no torch, no sklearn. The training script train_stress_model.py is in the Body Debt repository and re-exports stress_model.onnx directly.
What it does
The stress MLP is the second stage of the Body Debt face-scan pipeline:
Webcam frame
β MediaPipe FaceMesh (478 landmarks)
β 7 stress features (eye aspect L/R, brow tension, mouth tension,
eye symmetry, mouth opening, time-of-day)
β body-debt-stress-mlp (this model)
β stress score 0β1 β /100 in the UI
The 7 input features are computed deterministically in face_scan.py (no learned preprocessing). The model itself is a fixed-architecture 553-parameter MLP with ReLU activations and a sigmoid output.
Architecture
Linear(7, 16) β ReLU β 112 weights + 16 bias = 128
Linear(16, 8) β ReLU β 128 weights + 8 bias = 136
Linear(8, 1) β Sigmoid β 8 weights + 1 bias = 9
Subtotal = 273
(input + intermediate buffers) = 553
The exact layer shapes mirror the input contract used by the Body Debt ZK circuit, so the same on-device inference and the EZKL-proven on-chain path use identical weights.
Input
A 1-D float32 array of length 7, in this order:
| Index | Feature | Source | Range |
|---|---|---|---|
| 0 | left_eye_aspect |
EAR = vertical / horizontal of left eye | 0.15 β 0.45 |
| 1 | right_eye_aspect |
EAR of right eye | 0.15 β 0.45 |
| 2 | brow_tension |
mean brow-to-eye distance | 0.02 β 0.06 |
| 3 | mouth_tension |
mouth width / height | 2 β 12 |
| 4 | eye_symmetry |
abs(L-R) / mean(L,R) | 0.0 β 0.3 |
| 5 | mouth_opening |
mouth height / width | 0.0 β 0.4 |
| 6 | time-of-day | seconds_since_midnight / 86400 |
0.0 β 1.0 |
Output
A single float in [0, 1]. Multiply by 100 for a 0β100 stress score. The Body Debt UI treats < 0.5 as "healthy" and β₯ 0.5 as "stressed."
Sanity-checked face profiles
| Profile | Features | Score | Verdict |
|---|---|---|---|
| Tired (low EAR, furrowed, clench) | [0.18, 0.19, 0.025, 8.0, 0.12, 0.05, 0.67] |
71.5 | stressed |
| Rested (normal EAR, relaxed) | [0.32, 0.33, 0.045, 5.0, 0.04, 0.18, 0.34] |
32.3 | healthy |
| Marginal | [0.25, 0.26, 0.035, 6.0, 0.08, 0.10, 0.92] |
53.9 | stressed |
Files
stress_model.onnxβ exported ONNX model, ~1.5 KB, opset 10stress_model_weights.npzβ raw NumPy weights (for re-export)stress_training_data.npzβ the 2,000-sample synthetic training setstress_metrics.jsonβ train/val MAE, MSE, training hyperparametersgenerate_model.pyβ script that exports the ONNX from random init (legacy)train_stress_model.pyβ script that trains and re-exports the ONNX
Reproduce / regenerate
# Train and re-export (NumPy-only, ~2s on CPU)
python train_stress_model.py
The training script has no PyTorch or scikit-learn dependency. The exported ONNX graph is byte-identical in structure to the original generate_model.py output (same Gemm/Relu/Sigmoid node layout); only the weights differ.
Run inference
import onnxruntime as ort
import numpy as np
sess = ort.InferenceSession("stress_model.onnx")
features = np.array([[0.30, 0.31, 0.045, 4.0, 0.05, 0.15, 0.5]], dtype=np.float32)
score = sess.run(None, {"input": features})[0][0][0] # in [0, 1]
Why this model, why this size
The hackathon's spirit is "models that fit on hardware you own." A 360M-parameter SmolLM2 powers the conversational coach; this 553-parameter classifier powers the deterministic face-scan signal. The two are deliberately on the same architectural spectrum: the smallest model that can still produce a real signal.
A larger CNN or transformer here would be wasted parameters. The input is 7 hand-crafted features, not pixels. There is no upscaling to do.
License
MIT. See Body Debt repository.