forkcast / src /numeric /profile.py
adisaljusi's picture
fix(numeric): BMI-band fallback for out-of-distribution profiles
0bce3b4
"""Energy-expenditure helpers used by the inference layer.
Mifflin-St Jeor is the standard modern estimator for resting metabolic
rate. We use it to translate the user's profile into a personalized
daily calorie target, which becomes one input to the RAG coach.
"""
from __future__ import annotations
ACTIVITY_FACTORS: dict[str, float] = {
"sedentary": 1.2,
"light": 1.375,
"moderate": 1.55,
"active": 1.725,
"very active": 1.9,
}
GOAL_DELTAS_KCAL: dict[str, int] = {
"lose": -500,
"maintain": 0,
"gain": 300,
}
# Empirical training-data bounds for the UCI Obesity Levels dataset.
# Profiles outside these ranges are extrapolations — the classifier should
# not be trusted at face value there.
TRAINING_BMI_RANGE: tuple[float, float] = (13.0, 50.8)
TRAINING_WEIGHT_RANGE_KG: tuple[float, float] = (39.0, 173.0)
TRAINING_HEIGHT_RANGE_CM: tuple[float, float] = (145.0, 198.0)
TRAINING_AGE_RANGE: tuple[int, int] = (14, 61)
# BMI cutoffs used to derive the rule-based "BMI band" class. The upper
# bounds are anchored to WHO bands, with the Overweight split (I/II) and
# the Obesity split (I/II/III) following the UCI dataset's own labeling
# convention so the band matches the classifier's class names.
_BMI_BAND_CUTOFFS: list[tuple[float, str]] = [
(18.5, "Insufficient_Weight"),
(25.0, "Normal_Weight"),
(27.5, "Overweight_Level_I"),
(30.0, "Overweight_Level_II"),
(35.0, "Obesity_Type_I"),
(40.0, "Obesity_Type_II"),
(float("inf"), "Obesity_Type_III"),
]
def bmi_to_band(bmi: float) -> str:
"""Map a BMI value to the canonical 7-class band label.
Rule-based, transparent, and immune to training-data skew —
used as a sanity check against the classifier's prediction.
"""
for upper, label in _BMI_BAND_CUTOFFS:
if bmi < upper:
return label
return "Obesity_Type_III"
def bmr_mifflin_st_jeor(age: int, weight_kg: float, height_cm: float, sex: str = "male") -> float:
s = 5 if sex.lower().startswith("m") else -161
return 10.0 * weight_kg + 6.25 * height_cm - 5.0 * age + s
def tdee(bmr: float, activity_level: str) -> float:
return bmr * ACTIVITY_FACTORS[activity_level]
def daily_target_kcal(age: int, weight_kg: float, height_cm: float,
sex: str, activity_level: str, goal: str) -> float:
base = tdee(bmr_mifflin_st_jeor(age, weight_kg, height_cm, sex), activity_level)
return base + GOAL_DELTAS_KCAL[goal]
def macro_targets(target_kcal: float, goal: str) -> dict[str, float]:
splits = {
"lose": (0.35, 0.30, 0.35),
"maintain": (0.25, 0.30, 0.45),
"gain": (0.25, 0.25, 0.50),
}
p_pct, f_pct, c_pct = splits[goal]
return {
"protein_g": (target_kcal * p_pct) / 4.0,
"fat_g": (target_kcal * f_pct) / 9.0,
"carbohydrate_g": (target_kcal * c_pct) / 4.0,
"sodium_mg": 2000.0,
}