File size: 12,277 Bytes
89eb740
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c77bdbe
89eb740
c77bdbe
89eb740
c77bdbe
 
89eb740
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47671b6
89eb740
 
 
 
 
29083b9
89eb740
 
 
 
 
 
 
 
 
 
db9f296
89eb740
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1cca546
 
 
 
 
 
 
 
 
 
 
89eb740
 
 
486dae3
db9f296
47671b6
a700e33
47671b6
89eb740
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4435384
89eb740
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
"""
Heart Disease Risk Predictor — Streamlit chatbot
Deployed on Hugging Face Spaces.

Architecture:
  1. User fills in their health info via the sidebar form.
  2. On submit, the app deterministically computes:
       - 10-year CHD risk + tier (from the trained joblib model)
       - NHANES percentile rankings
       - Top 2-3 counterfactual scenarios
       - PubMed citations for each flagged risk factor
  3. Those results are formatted into a structured context block and handed
     to GPT-5-mini, which explains the results conversationally and answers
     follow-up questions using the same context for the rest of the session.

The LLM never invents predictions — it only narrates and explains the
deterministic results computed in Python.
"""

from dataclasses import dataclass
from pathlib import Path
from typing import Dict, List

import joblib
import openai
import pandas as pd
import streamlit as st
from sklearn.pipeline import Pipeline


# --- Re-declare model classes/constants for joblib unpickling -------------
# These mirror section 2 of heart_risk_modeling.ipynb. They MUST live in
# __main__ for joblib.load() to find TrainedModel.

REQUIRED_FEATURES: List[str] = [
    "age", "male", "cigsPerDay", "sysBP", "totChol", "BMI", "diabetes",
]

MODIFIABLE_TARGETS: Dict[str, Dict] = {
    "cigsPerDay": {"vocab": "smoking",      "target":   0.0, "label": "daily cigarette count"},
    "BMI":        {"vocab": "bmi",          "target":  24.0, "label": "BMI"},
    "sysBP":      {"vocab": "hypertension", "target": 120.0, "label": "systolic blood pressure"},
    "totChol":    {"vocab": "cholesterol",  "target": 180.0, "label": "total cholesterol"},
}

TIER_THRESHOLDS = {"low_max": 0.05, "medium_max": 0.20}


@dataclass
class TrainedModel:
    pipeline: Pipeline
    feature_names: List[str]
    train_size: int
    notes: str = ""

    def _to_frame(self, input_dict):
        return pd.DataFrame([{f: input_dict[f] for f in self.feature_names}])

    def predict_proba(self, input_dict):
        return float(self.pipeline.predict_proba(self._to_frame(input_dict))[0, 1])


def risk_tier(p):
    if p < TIER_THRESHOLDS["low_max"]: return "Low"
    if p < TIER_THRESHOLDS["medium_max"]: return "Medium"
    return "High"


def counterfactual_scenarios(model, user_input, top_n=3):
    baseline = model.predict_proba(user_input)
    out = []
    for feat, meta in MODIFIABLE_TARGETS.items():
        original = float(user_input[feat])
        target = float(meta["target"])
        if original <= target:
            continue
        modified = dict(user_input)
        modified[feat] = target
        new_risk = model.predict_proba(modified)
        out.append({
            "feature": feat, "vocab": meta["vocab"], "label": meta["label"],
            "original_value": original, "new_value": target,
            "baseline_risk": baseline, "new_risk": new_risk,
            "delta": baseline - new_risk,
        })
    out.sort(key=lambda s: s["delta"], reverse=True)
    return out[:top_n]


# --- Cached resource loaders ---------------------------------------------
@st.cache_resource
def load_model():
    return joblib.load("artifacts/risk_model.joblib")


@st.cache_data
def load_reference_data():
    nhanes_summary = pd.read_csv("data/nhanes_reference_summary.csv")
    nhanes_raw = pd.read_csv("data/nhanes_clean.csv")  # available for richer features
    pubmed = pd.read_csv("data/pubmed_clean.csv")
    return nhanes_summary, nhanes_raw, pubmed


def percentile_rank(value, summary_row):
    """Approximate ranking from the 5-point summary."""
    pcts = [10, 25, 50, 75, 90]
    cuts = [summary_row[f"p{p}"] for p in pcts]
    if any(pd.isna(c) for c in cuts):
        return None
    if value <= cuts[0]:
        return f"below the 10th percentile"
    for i in range(len(cuts) - 1):
        if cuts[i] <= value <= cuts[i + 1]:
            return f"between the {pcts[i]}th and {pcts[i+1]}th percentile"
    return f"above the 90th percentile"


def get_citations(risk_factor, pubmed_df, n=2):
    matches = pubmed_df[
        pubmed_df["matched_risk_factors"].astype(str).str.contains(
            risk_factor, case=False, na=False
        )
    ]
    if matches.empty:
        return matches
    high = matches[matches["is_high_evidence"] == True]
    pool = high if len(high) >= n else matches
    return pool.sample(min(n, len(pool)))[["title", "citation", "url"]]


def build_context_block(user_input, trained, nhanes_summary, pubmed):
    """Compute everything deterministically; format as a structured block
    for the LLM. The LLM never re-runs these calculations."""
    risk = trained.predict_proba(user_input)
    tier = risk_tier(risk)

    feature_to_nhanes = {
        "age": "age_years", "BMI": "bmi", "sysBP": "sys_bp", "totChol": "total_chol",
    }
    percentile_lines = []
    for feat, nv in feature_to_nhanes.items():
        row = nhanes_summary[nhanes_summary["nhanes_variable"] == nv]
        if not row.empty:
            r = percentile_rank(user_input[feat], row.iloc[0])
            if r:
                percentile_lines.append(f"  - {feat} = {user_input[feat]}: {r} of U.S. adults aged 30–65")

    if user_input["cigsPerDay"] > 0:
        smoking_row = nhanes_summary[nhanes_summary["nhanes_variable"] == "is_current_smoker"]
        if not smoking_row.empty:
            pct = smoking_row.iloc[0]["mean"] * 100
            percentile_lines.append(f"  - smoking: user is a current smoker; {pct:.1f}% of U.S. adults currently smoke")
    if user_input["diabetes"] == 1:
        d_row = nhanes_summary[nhanes_summary["nhanes_variable"] == "has_diabetes"]
        if not d_row.empty:
            pct = d_row.iloc[0]["mean"] * 100
            percentile_lines.append(f"  - diabetes: user has diabetes; {pct:.1f}% of U.S. adults have diabetes")

    scenarios = counterfactual_scenarios(trained, user_input, top_n=3)
    cf_lines = []
    for s in scenarios:
        cf_lines.append(
            f"  - {s['label']}: from {s['original_value']:g} to {s['new_value']:g} "
            f"-> risk {s['baseline_risk']*100:.0f}% -> {s['new_risk']*100:.0f}% "
            f"(delta -{s['delta']*100:.1f} pp)"
        )

    citation_lines = []
    for s in scenarios[:3]:
        cites = get_citations(s["vocab"], pubmed, n=1)
        for _, row in cites.iterrows():
            citation_lines.append(
                f"  - [{s['vocab']}] {row['title']} | {row['citation']} | {row['url']}"
            )

    block = f"""=== USER PROFILE ===
age={user_input['age']}, sex={'male' if user_input['male']==1 else 'female'}, BMI={user_input['BMI']}, sysBP={user_input['sysBP']}, totChol={user_input['totChol']}, cigsPerDay={user_input['cigsPerDay']}, diabetes={'yes' if user_input['diabetes']==1 else 'no'}

=== MODEL OUTPUT ===
10-year CHD risk: {risk*100:.1f}%
Tier: {tier} (Low <5%, Medium 5-20%, High >20%)

=== NHANES PERCENTILE CONTEXT ===
{chr(10).join(percentile_lines) if percentile_lines else '  (no percentile data available)'}

=== TOP COUNTERFACTUAL SCENARIOS (model-predicted, holds other variables constant) ===
{chr(10).join(cf_lines) if cf_lines else '  (user is already at or below all modifiable targets)'}

=== PUBMED CITATIONS FOR FLAGGED RISK FACTORS ===
{chr(10).join(citation_lines) if citation_lines else '  (no flagged factors)'}
"""
    return block, risk, tier, scenarios


# --- UI -------------------------------------------------------------------
st.set_page_config(page_title="Heart Risk Predictor", layout="wide")
st.title("Heart Disease Risk Predictor")
st.caption("Educational tool — not medical advice")

# NEW:
import os
openai.api_key = os.getenv("OPENAI_API_KEY")
grounding = os.getenv("GROUNDING")

if not openai.api_key:
    st.error("OPENAI_API_KEY not found. Add it under Settings → Variables and secrets.")
    st.stop()
if not grounding:
    st.error("GROUNDING not found. Add it under Settings → Variables and secrets.")
    st.stop()

# Load artifacts
trained = load_model()
nhanes_summary, nhanes_raw, pubmed = load_reference_data()

if "openai_model" not in st.session_state:
    st.session_state["openai_model"] = "gpt-5-mini"
if "messages" not in st.session_state:
    st.session_state.messages = []
if "context_block" not in st.session_state:
    st.session_state.context_block = None

# --- Sidebar input form --------------------------------------------------
with st.sidebar:
    st.header("Your information")
    with st.form("inputs"):
        age = st.number_input("Age (years)", 30, 90, 50)
        sex = st.selectbox("Sex", ["Female", "Male"])
        is_smoker = st.checkbox("Currently smoke?")
        cigs = st.number_input("Cigarettes/day (if smoker)", 0, 60, 0)
        sysBP = st.number_input("Systolic BP (mmHg)", 70, 250, 120)
        totChol = st.number_input("Total cholesterol (mg/dL)", 80, 500, 200)
        BMI = st.number_input("BMI", 12.0, 70.0, 25.0, step=0.1)
        diabetes = st.checkbox("Diabetes?")
        submit = st.form_submit_button("Compute my risk")

    if submit:
        user_input = {
            "age": age, "male": 1 if sex == "Male" else 0,
            "cigsPerDay": cigs if is_smoker else 0,
            "sysBP": sysBP, "totChol": totChol, "BMI": BMI,
            "diabetes": 1 if diabetes else 0,
        }
        block, risk, tier, scenarios = build_context_block(
            user_input, trained, nhanes_summary, pubmed
        )
        st.session_state.context_block = block
        # Reset the conversation so the bot starts fresh on the new profile
        st.session_state.messages = []
        # Seed the conversation with an opening user message that asks the
        # bot to explain the results
        st.session_state.messages.append({
            "role": "user",
            "content": "Please walk me through my results.",
        })

# --- Main panel: chat ----------------------------------------------------
if st.session_state.context_block is None:
    st.info("Fill in your information in the sidebar and click 'Compute my risk' to begin.")
else:
    # Show the deterministic numbers prominently — they're the source of truth
    with st.expander("Computed results (deterministic — what the chatbot sees)", expanded=False):
        st.code(st.session_state.context_block, language="text")

    # Replay chat history
    for message in st.session_state.messages:
        with st.chat_message(message["role"]):
            st.markdown(message["content"])

    # Handle the seeded opening turn (if no assistant response yet)
    needs_response = (
        len(st.session_state.messages) > 0
        and st.session_state.messages[-1]["role"] == "user"
    )

    # Capture follow-up input
    user_followup = st.chat_input("Ask a follow-up question about your results")
    if user_followup:
        st.session_state.messages.append({"role": "user", "content": user_followup})
        with st.chat_message("user"):
            st.markdown(user_followup)
        needs_response = True

    if needs_response:
        with st.chat_message("assistant"):
            message_placeholder = st.empty()
            full_response = ""

            # Inject the context block into the system instructions for every turn.
            # This keeps the bot grounded in the numbers across follow-ups.
            instructions_with_context = (
                grounding
                + "\n\n=== DETERMINISTIC RESULTS FOR THIS USER (DO NOT MODIFY OR RECALCULATE) ===\n"
                + st.session_state.context_block
            )

            response = openai.responses.create(
                model=st.session_state["openai_model"],
                instructions=instructions_with_context,
                input=[
                    {"role": m["role"], "content": m["content"]}
                    for m in st.session_state.messages
                ],
                stream=True,
            )

            for event in response:
                if event.type == "response.output_text.delta":
                    full_response += event.delta.replace("\\$", "$").replace("$", "\\$")
                    message_placeholder.markdown(full_response + "▌")

            message_placeholder.markdown(full_response)

        st.session_state.messages.append({"role": "assistant", "content": full_response})