forkcast / tests /test_numeric.py
adisaljusi's picture
fix(numeric): BMI-band fallback for out-of-distribution profiles
0bce3b4
"""Tests for the numeric block."""
from __future__ import annotations
import pandas as pd
import pytest
from src.numeric.obesity import (
OBESITY_LEVELS,
apply_favc_override,
derive_high_caloric_meal,
)
from src.numeric.profile import (
bmi_to_band,
bmr_mifflin_st_jeor,
daily_target_kcal,
macro_targets,
tdee,
)
from src.numeric.model import predict
def _profile(**overrides):
"""Build a complete profile dict with sensible defaults for tests."""
base = {
"age": 30,
"weight_kg": 75,
"height_cm": 175,
"Gender": "Male",
"family_history_with_overweight": "no",
"FAVC": "no",
"FCVC": 2.0,
"NCP": 3.0,
"CAEC": "Sometimes",
"SMOKE": "no",
"CH2O": 2.0,
"SCC": "no",
"FAF": 1.0,
"TUE": 1.0,
"CALC": "no",
"MTRANS": "Public_Transportation",
"activity_level": "moderate",
"goal": "maintain",
}
base.update(overrides)
return base
def test_bmr_known_values():
# Mifflin-St Jeor for a 30-year-old, 75kg, 178cm male:
# 10*75 + 6.25*178 - 5*30 + 5 = 1717.5 kcal
assert bmr_mifflin_st_jeor(30, 75, 178, "male") == pytest.approx(1717.5, abs=0.5)
def test_tdee_scales_with_activity():
bmr = 1700
assert tdee(bmr, "sedentary") < tdee(bmr, "moderate") < tdee(bmr, "very active")
def test_target_respects_goal():
args = (30, 75, 178, "male", "moderate")
lose = daily_target_kcal(*args, "lose")
maintain = daily_target_kcal(*args, "maintain")
gain = daily_target_kcal(*args, "gain")
assert lose < maintain < gain
assert maintain - lose == 500
assert gain - maintain == 300
def test_macro_targets_sum_to_target_kcal_within_rounding():
target = 2500.0
m = macro_targets(target, "maintain")
total = m["protein_g"] * 4 + m["fat_g"] * 9 + m["carbohydrate_g"] * 4
assert total == pytest.approx(target, rel=0.01)
def test_high_caloric_meal_threshold():
high = {"calories_kcal": 850, "fat_g": 40}
low = {"calories_kcal": 400, "fat_g": 15}
borderline = {"calories_kcal": 750, "fat_g": 20}
assert derive_high_caloric_meal(high) is True
assert derive_high_caloric_meal(low) is False
assert derive_high_caloric_meal(borderline) is False
def test_favc_override_flips_columns():
row = pd.DataFrame([{"FAVC_yes": 0, "FAVC_no": 1, "Age": 30}])
out = apply_favc_override(row, {"calories_kcal": 900, "fat_g": 35})
assert int(out["FAVC_yes"].iloc[0]) == 1
assert int(out["FAVC_no"].iloc[0]) == 0
def test_favc_override_noop_for_low_calorie_meal():
row = pd.DataFrame([{"FAVC_yes": 0, "FAVC_no": 1}])
out = apply_favc_override(row, {"calories_kcal": 400, "fat_g": 10})
assert int(out["FAVC_yes"].iloc[0]) == 0
def test_predict_returns_expected_shape_without_models():
"""When models are absent, predict() must return a coherent fallback."""
out = predict(_profile(weight_kg=75, height_cm=178), nutrition=None)
assert out["obesity_class"] in OBESITY_LEVELS
assert out["daily_target_kcal"] > 0
assert out["predicted_bmi"] > 0
assert "models" in out
# ───────────────────────────────────────────────────────────────────
# BMI-band rule (transparent, training-data-independent)
# ───────────────────────────────────────────────────────────────────
@pytest.mark.parametrize(
"bmi, expected",
[
(12.0, "Insufficient_Weight"),
(17.0, "Insufficient_Weight"),
(18.4, "Insufficient_Weight"),
(18.5, "Normal_Weight"),
(22.0, "Normal_Weight"),
(24.9, "Normal_Weight"),
(25.0, "Overweight_Level_I"),
(27.4, "Overweight_Level_I"),
(27.5, "Overweight_Level_II"),
(29.9, "Overweight_Level_II"),
(30.0, "Obesity_Type_I"),
(34.9, "Obesity_Type_I"),
(35.0, "Obesity_Type_II"),
(39.9, "Obesity_Type_II"),
(40.0, "Obesity_Type_III"),
(60.0, "Obesity_Type_III"),
(100.0, "Obesity_Type_III"),
],
)
def test_bmi_to_band_covers_full_severity_range(bmi, expected):
assert bmi_to_band(bmi) == expected
# ───────────────────────────────────────────────────────────────────
# Predict shape: every call must populate the anomaly fields
# ───────────────────────────────────────────────────────────────────
def test_predict_always_returns_bmi_band_and_anomaly_fields():
out = predict(_profile(), nutrition=None)
assert out["bmi_band"] in OBESITY_LEVELS
assert "anomaly_flags" in out
assert isinstance(out["anomaly_flags"], list)
assert "ood_blended" in out
assert "bmi_raw" in out
def test_predict_probabilities_sum_to_one():
out = predict(_profile(), nutrition=None)
total = sum(out["obesity_probabilities"].values())
assert total == pytest.approx(1.0, abs=1e-3)
# ───────────────────────────────────────────────────────────────────
# Realistic profiles β€” model should land on (or near) the BMI band
# ───────────────────────────────────────────────────────────────────
@pytest.mark.parametrize(
"label, profile_kwargs, expected_band",
[
("lean male", dict(weight_kg=65, height_cm=178), "Normal_Weight"),
("normal male", dict(weight_kg=75, height_cm=178), "Normal_Weight"),
("overweight ii", dict(weight_kg=90, height_cm=178), "Overweight_Level_II"),
("obese i", dict(weight_kg=100, height_cm=178), "Obesity_Type_I"),
("obese ii", dict(weight_kg=115, height_cm=178), "Obesity_Type_II"),
("obese iii female", dict(weight_kg=110, height_cm=165, Gender="Female"), "Obesity_Type_III"),
("insufficient fem.", dict(weight_kg=45, height_cm=165, Gender="Female"), "Insufficient_Weight"),
],
)
def test_predict_matches_bmi_band_on_realistic_profiles(label, profile_kwargs, expected_band):
out = predict(_profile(**profile_kwargs), nutrition=None)
assert out["bmi_band"] == expected_band, f"{label}: bmi_band wrong"
# Final class within 1 step of the BMI band β€” the classifier is allowed
# to land on an adjacent severity bucket for borderline BMIs.
gap = abs(OBESITY_LEVELS.index(out["obesity_class"]) - OBESITY_LEVELS.index(expected_band))
assert gap <= 1, (
f"{label}: predicted {out['obesity_class']} is {gap} classes "
f"from expected band {expected_band}"
)
# Most probability mass should sit on the band or an adjacent bucket.
top_two = sorted(out["obesity_probabilities"].items(), key=lambda kv: -kv[1])[:2]
assert sum(p for _, p in top_two) > 0.7, f"{label}: top-2 mass under 70%"
# ───────────────────────────────────────────────────────────────────
# Anomaly handling β€” out-of-distribution / rare-conditional inputs
# ───────────────────────────────────────────────────────────────────
def test_anomaly_extreme_overweight_male_routes_to_type_iii():
"""Male BMI 44.2 β€” Type_III training data is 99.7% female, so the
raw classifier predicts Type_II. The BMI-band override must correct
this and the final class must be Type_III."""
out = predict(_profile(weight_kg=140, height_cm=178, Gender="Male"), nutrition=None)
assert out["bmi_band"] == "Obesity_Type_III"
assert out["obesity_class"] == "Obesity_Type_III"
assert out["ood_blended"] is True
# A flag mentioning the female-bias must be visible to the user.
assert any("female" in f.lower() for f in out["anomaly_flags"])
assert out["obesity_probabilities"]["Obesity_Type_III"] > 0.5
def test_anomaly_weight_above_training_range_is_flagged_and_blended():
out = predict(_profile(weight_kg=220, height_cm=178), nutrition=None)
assert out["ood_blended"] is True
assert out["obesity_class"] == "Obesity_Type_III"
assert any("trained range" in f.lower() for f in out["anomaly_flags"])
# Despite the model wanting Type_II, the blended final class must
# be at the top of the severity scale.
assert out["obesity_probabilities"]["Obesity_Type_III"] > 0.5
def test_anomaly_weight_below_training_range_is_flagged():
out = predict(_profile(weight_kg=35, height_cm=178), nutrition=None)
assert out["ood_blended"] is True
assert out["obesity_class"] == "Insufficient_Weight"
assert any("below the trained range" in f.lower() for f in out["anomaly_flags"])
def test_anomaly_bmi_below_training_range_is_flagged():
# Tall and very light β†’ BMI ~10.1, below the trained 13.0 floor.
out = predict(_profile(weight_kg=38, height_cm=195), nutrition=None)
assert out["bmi_band"] == "Insufficient_Weight"
assert any("bmi" in f.lower() and "outside" in f.lower() for f in out["anomaly_flags"])
def test_anomaly_age_outside_range_is_flagged():
out = predict(_profile(age=80), nutrition=None)
assert any("age" in f.lower() and "outside" in f.lower() for f in out["anomaly_flags"])
# ───────────────────────────────────────────────────────────────────
# In-distribution behavior β€” model should NOT be overridden when it
# is operating inside the trained envelope.
# ───────────────────────────────────────────────────────────────────
def test_in_distribution_borderline_is_not_blended():
"""BMI 26.8 is right on the Overweight I/II boundary. Adjacent-class
disagreement is acceptable here β€” no OOD blend, no flags about
training range."""
out = predict(_profile(weight_kg=85, height_cm=178), nutrition=None)
assert out["ood_blended"] is False
# No range-violation flags for in-distribution input.
assert not any("range" in f.lower() for f in out["anomaly_flags"])
def test_in_distribution_female_type_iii_is_not_blended():
"""Female Type_III is well-represented (323 examples) β€” the model
should handle this without needing the BMI-band override."""
out = predict(_profile(weight_kg=110, height_cm=165, Gender="Female"), nutrition=None)
assert out["bmi_band"] == "Obesity_Type_III"
assert out["obesity_class"] == "Obesity_Type_III"
assert out["ood_blended"] is False