Spaces:
Sleeping
Sleeping
File size: 11,486 Bytes
191f139 0bce3b4 191f139 0bce3b4 191f139 0bce3b4 191f139 0bce3b4 | 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 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 | """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
|