|
|
| import streamlit as st |
| import pandas as pd |
| import numpy as np |
| import os |
| import json |
| import faiss |
| import torch |
|
|
| from sentence_transformers import SentenceTransformer |
| from transformers import ( |
| AutoTokenizer, |
| AutoModelForCausalLM |
| ) |
|
|
|
|
| |
| |
| |
|
|
| st.set_page_config( |
| page_title="RxReview", |
| page_icon="💊", |
| layout="wide" |
| ) |
|
|
|
|
| |
| |
| |
|
|
| DATA_PATH = "rxreview_priority_data.csv" |
|
|
| RAG_DIR = "rag" |
|
|
| RAG_CHUNKS_PATH = os.path.join( |
| RAG_DIR, |
| "rxreview_rag_chunks.csv" |
| ) |
|
|
| RAG_INDEX_PATH = os.path.join( |
| RAG_DIR, |
| "rxreview_faiss.index" |
| ) |
|
|
| RAG_CONFIG_PATH = os.path.join( |
| RAG_DIR, |
| "rxreview_rag_config.json" |
| ) |
|
|
|
|
| |
| |
| |
|
|
| @st.cache_data |
| def load_priority_data(): |
|
|
| return pd.read_csv( |
| DATA_PATH |
| ) |
|
|
|
|
| priority_df = load_priority_data() |
|
|
|
|
| |
| |
| |
|
|
| def select_by_capacity( |
| data, |
| score_col, |
| capacity_fraction |
| ): |
|
|
| temp = data.sort_values( |
| score_col, |
| ascending=False |
| ).reset_index(drop=True) |
|
|
| n_selected = int( |
| np.ceil( |
| len(temp) |
| * capacity_fraction |
| ) |
| ) |
|
|
| return temp.iloc[ |
| :n_selected |
| ].copy() |
|
|
|
|
| def rxreview_value_simulator( |
| data, |
| capacity_fraction, |
| pharmacist_hourly_cost, |
| review_minutes_per_patient, |
| cost_per_readmission, |
| preventable_fraction, |
| intervention_effectiveness |
| ): |
|
|
| selected = select_by_capacity( |
| data, |
| "rxreview_priority_score", |
| capacity_fraction |
| ) |
|
|
| patients_selected = len( |
| selected |
| ) |
|
|
| total_readmissions = int( |
| data[ |
| "readmit_30" |
| ].sum() |
| ) |
|
|
| captured_readmissions = int( |
| selected[ |
| "readmit_30" |
| ].sum() |
| ) |
|
|
| pharmacist_hours = ( |
| patients_selected |
| * |
| review_minutes_per_patient |
| / |
| 60 |
| ) |
|
|
| intervention_cost = ( |
| pharmacist_hours |
| * |
| pharmacist_hourly_cost |
| ) |
|
|
| capture_rate = ( |
| captured_readmissions |
| / |
| total_readmissions |
| if total_readmissions > 0 |
| else 0 |
| ) |
|
|
| lift = ( |
| capture_rate |
| / |
| capacity_fraction |
| if capacity_fraction > 0 |
| else 0 |
| ) |
|
|
| potentially_preventable = ( |
| captured_readmissions |
| * |
| preventable_fraction |
| ) |
|
|
| avoided_readmissions = ( |
| potentially_preventable |
| * |
| intervention_effectiveness |
| ) |
|
|
| gross_savings = ( |
| avoided_readmissions |
| * |
| cost_per_readmission |
| ) |
|
|
| net_value = ( |
| gross_savings |
| - |
| intervention_cost |
| ) |
|
|
| denominator = ( |
| captured_readmissions |
| * |
| preventable_fraction |
| * |
| cost_per_readmission |
| ) |
|
|
| break_even_effectiveness = ( |
| intervention_cost |
| / |
| denominator |
| if denominator > 0 |
| else np.nan |
| ) |
|
|
| return { |
|
|
| "patients_selected": |
| patients_selected, |
|
|
| "pharmacist_hours": |
| pharmacist_hours, |
|
|
| "captured_readmissions": |
| captured_readmissions, |
|
|
| "capture_rate": |
| capture_rate, |
|
|
| "lift": |
| lift, |
|
|
| "intervention_cost": |
| intervention_cost, |
|
|
| "avoided_readmissions": |
| avoided_readmissions, |
|
|
| "gross_savings": |
| gross_savings, |
|
|
| "net_value": |
| net_value, |
|
|
| "break_even_effectiveness": |
| break_even_effectiveness, |
|
|
| "selected": |
| selected |
| } |
|
|
|
|
| |
| |
| |
|
|
| @st.cache_resource |
| def load_embedding_model(): |
|
|
| return SentenceTransformer( |
| "sentence-transformers/all-MiniLM-L6-v2" |
| ) |
|
|
|
|
| @st.cache_resource |
| def load_faiss_index(): |
|
|
| return faiss.read_index( |
| RAG_INDEX_PATH |
| ) |
|
|
|
|
| @st.cache_data |
| def load_rag_chunks(): |
|
|
| return pd.read_csv( |
| RAG_CHUNKS_PATH |
| ) |
|
|
|
|
| @st.cache_resource |
| def load_generation_model(): |
|
|
| model_name = ( |
| "Qwen/Qwen2.5-1.5B-Instruct" |
| ) |
|
|
| tokenizer = ( |
| AutoTokenizer.from_pretrained( |
| model_name |
| ) |
| ) |
|
|
| model = ( |
| AutoModelForCausalLM |
| .from_pretrained( |
| model_name, |
| torch_dtype="auto", |
| device_map="auto" |
| ) |
| ) |
|
|
| model.eval() |
|
|
| return tokenizer, model |
|
|
|
|
| |
| |
| |
|
|
| def retrieve_rag_chunks( |
| question, |
| top_k=4 |
| ): |
|
|
| embedding_model = ( |
| load_embedding_model() |
| ) |
|
|
| rag_index = ( |
| load_faiss_index() |
| ) |
|
|
| chunks = ( |
| load_rag_chunks() |
| ) |
|
|
| query_embedding = ( |
| embedding_model.encode( |
| [question], |
| convert_to_numpy=True, |
| normalize_embeddings=True |
| ) |
| ) |
|
|
| scores, indices = ( |
| rag_index.search( |
| query_embedding.astype( |
| "float32" |
| ), |
| top_k |
| ) |
| ) |
|
|
| results = ( |
| chunks.iloc[ |
| indices[0] |
| ].copy() |
| ) |
|
|
| results[ |
| "similarity_score" |
| ] = scores[0] |
|
|
| return results |
|
|
|
|
| def build_rag_messages( |
| question, |
| retrieved_chunks |
| ): |
|
|
| sections = [] |
|
|
| for i, (_, row) in enumerate( |
| retrieved_chunks.iterrows(), |
| start=1 |
| ): |
|
|
| sections.append( |
| f""" |
| SOURCE {i} |
| Source file: {row['source_file']} |
| Chunk ID: {row['chunk_id']} |
| |
| {row['text']} |
| """ |
| ) |
|
|
| context = "\n".join( |
| sections |
| ) |
|
|
| system_message = """ |
| You are the RxReview discharge-support assistant. |
| |
| Answer using only the retrieved source context. |
| |
| Do not provide: |
| - patient-specific prescribing advice, |
| - medication dosing recommendations, |
| - recommendations to start, stop, or change medication. |
| |
| If the source context does not explicitly support the requested information, |
| state that the retrieved RxReview sources do not provide enough information. |
| |
| Keep answers concise and practical. |
| """ |
|
|
| user_message = f""" |
| QUESTION: |
| {question} |
| |
| SOURCE CONTEXT: |
| {context} |
| |
| Answer using only the context above. |
| """ |
|
|
| return [ |
| { |
| "role": "system", |
| "content": system_message |
| }, |
| { |
| "role": "user", |
| "content": user_message |
| } |
| ] |
|
|
|
|
| def answer_rag_question( |
| question, |
| top_k=4, |
| min_retrieval_score=0.40 |
| ): |
|
|
| retrieved = ( |
| retrieve_rag_chunks( |
| question, |
| top_k |
| ) |
| ) |
|
|
| top_score = ( |
| retrieved[ |
| "similarity_score" |
| ].max() |
| if len(retrieved) > 0 |
| else 0 |
| ) |
|
|
| if top_score < min_retrieval_score: |
|
|
| answer = ( |
| "The retrieved RxReview sources " |
| "do not provide enough information " |
| "to answer this question." |
| ) |
|
|
| return answer, retrieved |
|
|
| tokenizer, model = ( |
| load_generation_model() |
| ) |
|
|
| messages = ( |
| build_rag_messages( |
| question, |
| retrieved |
| ) |
| ) |
|
|
| model_inputs = ( |
| tokenizer.apply_chat_template( |
| messages, |
| tokenize=True, |
| add_generation_prompt=True, |
| return_tensors="pt", |
| return_dict=True |
| ) |
| ) |
|
|
| model_inputs = { |
| key: |
| value.to( |
| model.device |
| ) |
| for key, value |
| in model_inputs.items() |
| } |
|
|
| input_length = ( |
| model_inputs[ |
| "input_ids" |
| ].shape[-1] |
| ) |
|
|
| with torch.no_grad(): |
|
|
| generated_ids = ( |
| model.generate( |
| **model_inputs, |
| max_new_tokens=250, |
| do_sample=False, |
| repetition_penalty=1.05 |
| ) |
| ) |
|
|
| new_tokens = ( |
| generated_ids[ |
| 0, |
| input_length: |
| ] |
| ) |
|
|
| answer = ( |
| tokenizer.decode( |
| new_tokens, |
| skip_special_tokens=True |
| ) |
| ) |
|
|
| return ( |
| answer.strip(), |
| retrieved |
| ) |
|
|
|
|
| |
| |
| |
|
|
| st.title( |
| "💊 RxReview" |
| ) |
|
|
| st.subheader( |
| "Pharmacist-Led Diabetes " |
| "Discharge Review Decision Support" |
| ) |
|
|
| st.caption( |
| "Prioritize limited pharmacist capacity " |
| "using readmission risk, medication complexity, " |
| "cost/value analysis, and grounded discharge guidance." |
| ) |
|
|
|
|
| |
| |
| |
|
|
| tab1, tab2, tab3, tab4, tab5 = ( |
| st.tabs([ |
| "Dashboard", |
| "Review Queue", |
| "Cost & Capacity", |
| "Discharge Guidance", |
| "About" |
| ]) |
| ) |
|
|
|
|
| |
| |
| |
|
|
| with tab1: |
|
|
| st.header( |
| "RxReview Dashboard" |
| ) |
|
|
| capacity = st.slider( |
| "Pharmacist Review Capacity (%)", |
| min_value=5, |
| max_value=30, |
| value=10, |
| step=5 |
| ) |
|
|
| selected = select_by_capacity( |
| priority_df, |
| "rxreview_priority_score", |
| capacity / 100 |
| ) |
|
|
| total_readmissions = int( |
| priority_df[ |
| "readmit_30" |
| ].sum() |
| ) |
|
|
| captured = int( |
| selected[ |
| "readmit_30" |
| ].sum() |
| ) |
|
|
| capture_rate = ( |
| captured |
| / |
| total_readmissions |
| ) |
|
|
| lift = ( |
| capture_rate |
| / |
| (capacity / 100) |
| ) |
|
|
| col1, col2, col3, col4 = ( |
| st.columns(4) |
| ) |
|
|
| col1.metric( |
| "Patients Reviewed", |
| f"{len(selected):,}" |
| ) |
|
|
| col2.metric( |
| "Readmissions Captured", |
| f"{captured:,}" |
| ) |
|
|
| col3.metric( |
| "Capture Rate", |
| f"{capture_rate:.1%}" |
| ) |
|
|
| col4.metric( |
| "Lift vs Random", |
| f"{lift:.2f}×" |
| ) |
|
|
| st.markdown( |
| "### Capacity vs Capture" |
| ) |
|
|
| chart_rows = [] |
|
|
| for c in [ |
| 0.05, |
| 0.10, |
| 0.15, |
| 0.20, |
| 0.25, |
| 0.30 |
| ]: |
|
|
| temp = select_by_capacity( |
| priority_df, |
| "rxreview_priority_score", |
| c |
| ) |
|
|
| captured_c = ( |
| temp[ |
| "readmit_30" |
| ].sum() |
| ) |
|
|
| chart_rows.append({ |
| "Capacity %": |
| c * 100, |
|
|
| "Readmissions Captured %": |
| ( |
| captured_c |
| / |
| total_readmissions |
| * 100 |
| ) |
| }) |
|
|
| chart_df = pd.DataFrame( |
| chart_rows |
| ) |
|
|
| st.line_chart( |
| chart_df, |
| x="Capacity %", |
| y="Readmissions Captured %" |
| ) |
|
|
|
|
| |
| |
| |
|
|
| with tab2: |
|
|
| st.header( |
| "Patient Review Queue" |
| ) |
|
|
| queue_capacity = ( |
| st.slider( |
| "Queue Capacity (%)", |
| min_value=5, |
| max_value=30, |
| value=10, |
| step=5, |
| key="queue_capacity" |
| ) |
| ) |
|
|
| queue_df = select_by_capacity( |
| priority_df, |
| "rxreview_priority_score", |
| queue_capacity / 100 |
| ) |
|
|
| display_cols = [ |
| col |
| for col in [ |
| "patient_nbr", |
| "xgb_calibrated_readmission_risk", |
| "medication_complexity_score", |
| "medication_complexity_weight", |
| "rxreview_priority_score", |
| "rxreview_priority_score_100", |
| "medication_complexity_level", |
| "rxreview_priority_level" |
| ] |
| if col in queue_df.columns |
| ] |
|
|
| st.dataframe( |
| queue_df[ |
| display_cols |
| ].head(100), |
| use_container_width=True |
| ) |
|
|
| st.caption( |
| "The queue is ranked by the RxReview " |
| "Priority Score. Scores support prioritization " |
| "and do not replace clinical judgment." |
| ) |
|
|
|
|
| |
| |
| |
|
|
| with tab3: |
|
|
| st.header( |
| "Cost & Capacity Simulator" |
| ) |
|
|
| col1, col2 = ( |
| st.columns(2) |
| ) |
|
|
| with col1: |
|
|
| sim_capacity = st.slider( |
| "Review Capacity (%)", |
| 5, |
| 30, |
| 10, |
| 5 |
| ) |
|
|
| hourly_cost = st.number_input( |
| "Pharmacist Hourly Cost ($)", |
| min_value=0.0, |
| value=75.0, |
| step=5.0 |
| ) |
|
|
| review_minutes = st.slider( |
| "Minutes per Review", |
| 10, |
| 60, |
| 30, |
| 5 |
| ) |
|
|
| with col2: |
|
|
| readmission_cost = ( |
| st.number_input( |
| "Cost per Readmission ($)", |
| min_value=0.0, |
| value=15000.0, |
| step=1000.0 |
| ) |
| ) |
|
|
| preventable_pct = ( |
| st.slider( |
| "Potentially Preventable (%)", |
| 0, |
| 100, |
| 25, |
| 5 |
| ) |
| ) |
|
|
| effectiveness_pct = ( |
| st.slider( |
| "Intervention Effectiveness (%)", |
| 0, |
| 100, |
| 20, |
| 5 |
| ) |
| ) |
|
|
| sim = rxreview_value_simulator( |
|
|
| priority_df, |
|
|
| sim_capacity / 100, |
|
|
| hourly_cost, |
|
|
| review_minutes, |
|
|
| readmission_cost, |
|
|
| preventable_pct / 100, |
|
|
| effectiveness_pct / 100 |
| ) |
|
|
| st.markdown( |
| "### Scenario Results" |
| ) |
|
|
| c1, c2, c3, c4 = ( |
| st.columns(4) |
| ) |
|
|
| c1.metric( |
| "Patients Reviewed", |
| f"{sim['patients_selected']:,}" |
| ) |
|
|
| c2.metric( |
| "Pharmacist Hours", |
| f"{sim['pharmacist_hours']:,.0f}" |
| ) |
|
|
| c3.metric( |
| "Readmissions Captured", |
| f"{sim['captured_readmissions']:,}" |
| ) |
|
|
| c4.metric( |
| "Lift", |
| f"{sim['lift']:.2f}×" |
| ) |
|
|
| c5, c6, c7 = ( |
| st.columns(3) |
| ) |
|
|
| c5.metric( |
| "Intervention Cost", |
| f"${sim['intervention_cost']:,.0f}" |
| ) |
|
|
| c6.metric( |
| "Gross Savings", |
| f"${sim['gross_savings']:,.0f}" |
| ) |
|
|
| c7.metric( |
| "Net Value", |
| f"${sim['net_value']:,.0f}" |
| ) |
|
|
| st.metric( |
| "Break-Even Effectiveness", |
| ( |
| f"{sim['break_even_effectiveness']:.1%}" |
| ) |
| ) |
|
|
| st.caption( |
| "Financial outputs are scenario estimates " |
| "based on adjustable assumptions, not observed " |
| "savings from the UCI dataset." |
| ) |
|
|
|
|
| |
| |
| |
|
|
| with tab4: |
|
|
| st.header( |
| "Discharge Guidance Assistant" |
| ) |
|
|
| st.write( |
| "Ask questions about medication reconciliation, " |
| "discharge planning, patient education, " |
| "follow-up, care transitions, or " |
| "readmission-reduction practices." |
| ) |
|
|
| question = st.text_area( |
| "Question", |
| placeholder=( |
| "Example: What should hospitals do " |
| "to reconcile medications at discharge?" |
| ) |
| ) |
|
|
| if st.button( |
| "Get Grounded Guidance" |
| ): |
|
|
| if question.strip(): |
|
|
| with st.spinner( |
| "Retrieving evidence and generating response..." |
| ): |
|
|
| answer, sources = ( |
| answer_rag_question( |
| question |
| ) |
| ) |
|
|
| st.markdown( |
| "### Answer" |
| ) |
|
|
| st.write( |
| answer |
| ) |
|
|
| st.markdown( |
| "### Retrieved Sources" |
| ) |
|
|
| st.dataframe( |
| sources[ |
| [ |
| "source_file", |
| "chunk_id", |
| "similarity_score" |
| ] |
| ], |
| use_container_width=True |
| ) |
|
|
| else: |
|
|
| st.warning( |
| "Enter a question first." |
| ) |
|
|
|
|
| |
| |
| |
|
|
| with tab5: |
|
|
| st.header( |
| "About RxReview" |
| ) |
|
|
| st.markdown( |
| """ |
| **RxReview** is a decision-support prototype designed to |
| help hospitals prioritize patients with diabetes for |
| pharmacist-led discharge review when pharmacist capacity |
| is limited. |
| |
| The application combines: |
| |
| - calibrated 30-day readmission risk, |
| - medication complexity, |
| - operational capacity constraints, |
| - scenario-based cost/value analysis, |
| - and authoritative AHRQ/CMS discharge guidance. |
| |
| ### Important limitation |
| |
| RxReview does not determine whether a medication regimen |
| is clinically correct and does not recommend medication |
| changes or doses. |
| |
| It supports prioritization for human pharmacist review. |
| """ |
| ) |
|
|