mgbam commited on
Commit
74ec90b
·
verified ·
1 Parent(s): 4e252af

Upload 4 files

Browse files
Files changed (4) hide show
  1. Dockerfile +20 -13
  2. README.md +21 -16
  3. app.py +217 -0
  4. requirements.txt +6 -3
Dockerfile CHANGED
@@ -1,20 +1,27 @@
1
- FROM python:3.13.5-slim
 
2
 
3
- WORKDIR /app
 
 
 
 
 
4
 
5
- RUN apt-get update && apt-get install -y \
6
- build-essential \
7
- curl \
8
- git \
9
- && rm -rf /var/lib/apt/lists/*
10
 
11
- COPY requirements.txt ./
12
- COPY src/ ./src/
13
 
14
- RUN pip3 install -r requirements.txt
 
 
 
15
 
16
- EXPOSE 8501
17
 
18
- HEALTHCHECK CMD curl --fail http://localhost:8501/_stcore/health
 
19
 
20
- ENTRYPOINT ["streamlit", "run", "src/streamlit_app.py", "--server.port=8501", "--server.address=0.0.0.0"]
 
 
1
+ # Hugging Face Spaces - Docker (Streamlit) for Sundew Diabetes Watch
2
+ FROM python:3.11-slim
3
 
4
+ # Avoid prompt
5
+ ENV PIP_NO_INPUT=1 PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 # Streamlit defaults for HF
6
+ PORT=7860
7
+
8
+ # System deps (build tools if needed by some wheel)
9
+ RUN apt-get update && apt-get install -y --no-install-recommends build-essential && rm -rf /var/lib/apt/lists/*
10
 
11
+ WORKDIR /app
 
 
 
 
12
 
13
+ COPY requirements.txt /app/requirements.txt
14
+ RUN pip install --upgrade pip && pip install -r /app/requirements.txt
15
 
16
+ # Copy app code
17
+ COPY app.py /app/app.py
18
+ COPY .streamlit/config.toml /app/.streamlit/config.toml
19
+ COPY README.md /app/README.md
20
 
21
+ EXPOSE 7860
22
 
23
+ # HEALTHCHECK so Spaces knows the app is up
24
+ HEALTHCHECK --interval=30s --timeout=5s --start-period=30s CMD python -c "import socket; s=socket.socket(); s.settimeout(3); s.connect(('127.0.0.1', 7860)); s.close()" || exit 1
25
 
26
+ # Streamlit entrypoint
27
+ CMD ["streamlit", "run", "app.py", "--server.port=7860", "--server.address=0.0.0.0"]
README.md CHANGED
@@ -1,19 +1,24 @@
1
- ---
2
- title: Sundew Diabetes Watch
3
- emoji: 🚀
4
- colorFrom: red
5
- colorTo: red
6
- sdk: docker
7
- app_port: 8501
8
- tags:
9
- - streamlit
10
- pinned: false
11
- short_description: Diabetes Watch
12
- ---
13
 
14
- # Welcome to Streamlit!
 
 
15
 
16
- Edit `/src/streamlit_app.py` to customize this app to your heart's desire. :heart:
 
 
 
 
17
 
18
- If you have any questions, checkout our [documentation](https://docs.streamlit.io) and [community
19
- forums](https://discuss.streamlit.io).
 
 
 
 
 
 
 
 
 
 
 
1
+ # 🌿 Sundew Diabetes Watch (Hugging Face Space: Docker + Streamlit)
 
 
 
 
 
 
 
 
 
 
 
2
 
3
+ **Mission:** Low-cost, energy-aware diabetes risk monitoring for everyone — especially communities across Africa.
4
+ This app uses the *Sundew* selective-activation algorithm to run heavier models **only when needed**, saving compute and making
5
+ always-on monitoring practical on affordable hardware.
6
 
7
+ ## How it works
8
+ - Upload a CSV with columns: `timestamp, glucose_mgdl, carbs_g, insulin_units, steps, hr` (optional extras allowed).
9
+ - A lightweight **risk score** runs on each event.
10
+ - **Sundew** decides when to open the gate and run a heavier model for near-term risk.
11
+ - You control the target activation rate to meet power/latency budgets.
12
 
13
+ > **Disclaimer:** Research prototype. Not medical advice.
14
+
15
+ ## Developing locally
16
+ ```bash
17
+ python -m venv .venv && source .venv/bin/activate # Windows: .venv\Scripts\activate
18
+ pip install -r requirements.txt
19
+ streamlit run app.py
20
+ ```
21
+
22
+ ## Deploying as a Hugging Face Space (Docker)
23
+ - Create a new **Docker** Space and push these files.
24
+ - The Dockerfile exposes port 7860 and launches `streamlit run app.py`.
app.py ADDED
@@ -0,0 +1,217 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import math
2
+ from dataclasses import dataclass
3
+ from typing import Optional
4
+
5
+ import numpy as np
6
+ import pandas as pd
7
+ import streamlit as st
8
+
9
+ # ---- Try Sundew; fallback if missing ----
10
+ try:
11
+ from sundew import SundewAlgorithm # provided by sundew-algorithms
12
+ _HAS_SUNDEW = True
13
+ except Exception:
14
+ SundewAlgorithm = None # type: ignore
15
+ _HAS_SUNDEW = False
16
+
17
+ st.set_page_config(page_title="Sundew Diabetes Watch", layout="wide")
18
+ st.title("🌿 Sundew Diabetes Watch")
19
+ st.caption("Energy-aware selective activation for diabetes monitoring — research demo (not medical advice).")
20
+
21
+ # ---------------- Sundew Gate wrapper ----------------
22
+ @dataclass
23
+ class SundewGate:
24
+ target_activation: float = 0.25
25
+ temperature: float = 0.08
26
+ mode: str = "tuned_v2"
27
+
28
+ def __post_init__(self):
29
+ if _HAS_SUNDEW and SundewAlgorithm is not None:
30
+ try:
31
+ self.sd = SundewAlgorithm(
32
+ target_activation=self.target_activation,
33
+ temperature=self.temperature,
34
+ mode=self.mode,
35
+ )
36
+ except TypeError:
37
+ self.sd = SundewAlgorithm()
38
+ else:
39
+ self.sd = None
40
+ # fallback state
41
+ self._tau = 0.5
42
+ self._ema = 0.0
43
+ self._alpha = 0.02
44
+
45
+ def decide(self, score: float) -> bool:
46
+ score = float(max(0.0, min(1.0, score)))
47
+ if self.sd is not None:
48
+ for name in ("decide", "step", "open"):
49
+ if hasattr(self.sd, name):
50
+ try:
51
+ return bool(getattr(self.sd, name)(score))
52
+ except Exception:
53
+ pass
54
+ # stochastic logistic fallback
55
+ p_open = 1 / (1 + math.exp(-(score - self._tau) / max(1e-6, self.temperature)))
56
+ fired = np.random.rand() < p_open
57
+ self._ema = (1 - self._alpha) * self._ema + self._alpha * (1.0 if fired else 0.0)
58
+ self._tau += 0.01 * (self.target_activation - self._ema)
59
+ self._tau = min(0.95, max(0.05, self._tau))
60
+ return fired
61
+
62
+ # ---------------- Lightweight risk scoring ----------------
63
+ def compute_lightweight_score(row: pd.Series) -> float:
64
+ g = float(row.get("glucose_mgdl", np.nan))
65
+ roc = float(row.get("roc_mgdl_min", 0.0))
66
+ insulin = float(row.get("insulin_units", 0.0))
67
+ carbs = float(row.get("carbs_g", 0.0))
68
+ hr = float(row.get("hr", 0.0))
69
+
70
+ low_gap = max(0.0, 80 - g)
71
+ high_gap = max(0.0, g - 140)
72
+ base = (low_gap + high_gap) / 120.0
73
+
74
+ roc_term = min(1.0, abs(roc) / 3.0)
75
+ insulin_term = min(1.0, insulin / 6.0) * (1.0 if roc < -0.5 else 0.3)
76
+ carbs_term = min(1.0, carbs / 50.0) * (1.0 if roc > 0.5 else 0.3)
77
+ activity_term = min(1.0, max(0.0, hr - 100) / 60.0) * (1.0 if insulin > 0.5 else 0.2)
78
+
79
+ score = base + 0.7 * roc_term + 0.5 * insulin_term + 0.4 * carbs_term + 0.3 * activity_term
80
+ return float(max(0.0, min(1.0, score)))
81
+
82
+ # ---------------- Heavy model (simple logreg) ----------------
83
+ from sklearn.linear_model import LogisticRegression
84
+ from sklearn.preprocessing import StandardScaler
85
+ from sklearn.pipeline import Pipeline
86
+
87
+ def make_heavy_model() -> Pipeline:
88
+ return Pipeline([("scaler", StandardScaler()), ("clf", LogisticRegression(max_iter=1000))])
89
+
90
+ def train_heavy_model(df: pd.DataFrame) -> Pipeline:
91
+ df = df.copy()
92
+ # predict 30-min ahead risk: hypo<70 or hyper>180
93
+ df["future_glucose"] = df["glucose_mgdl"].shift(-6) # assuming 5-min cadence
94
+ df["label"] = ((df["future_glucose"] < 70) | (df["future_glucose"] > 180)).astype(int)
95
+ df = df.dropna(subset=["label"]).copy()
96
+
97
+ X = df[["glucose_mgdl", "roc_mgdl_min", "insulin_units", "carbs_g", "hr"]].fillna(0.0).values
98
+ y = df["label"].values
99
+ if len(np.unique(y)) < 2:
100
+ # ensure fit works even with monotone data
101
+ y = np.array([0, 1] * (len(X) // 2 + 1))[: len(X)]
102
+ model = make_heavy_model()
103
+ model.fit(X, y)
104
+ return model
105
+
106
+ # ---------------- UI ----------------
107
+ left, right = st.columns([2, 1])
108
+ with left:
109
+ uploaded = st.file_uploader("Upload CGM CSV (timestamp, glucose_mgdl, carbs_g, insulin_units, steps, hr)", type=["csv"])
110
+ use_synth = st.checkbox("Use synthetic example if no file uploaded", value=True)
111
+ with right:
112
+ target_activation = st.slider("Target heavy-activation rate", 0.05, 0.9, 0.25, 0.01)
113
+ temperature = st.slider("Gate temperature", 0.02, 0.5, 0.08, 0.01)
114
+ mode = st.selectbox("Sundew mode", ["tuned_v2", "conservative", "aggressive", "auto_tuned"], index=0)
115
+
116
+ # ---------------- Load or synthesize data ----------------
117
+ if uploaded is not None:
118
+ df = pd.read_csv(uploaded)
119
+ else:
120
+ if not use_synth:
121
+ st.stop()
122
+ rng = np.random.default_rng(7)
123
+ n = 600
124
+ t0 = pd.Timestamp.utcnow().floor("min")
125
+ times = [t0 + pd.Timedelta(minutes=5 * i) for i in range(n)]
126
+ base = 120 + 25 * np.sin(np.linspace(0, 10 * np.pi, n))
127
+ noise = rng.normal(0, 10, n)
128
+ meals = (rng.random(n) < 0.04).astype(float) * rng.normal(45, 15, n).clip(0, 120)
129
+ insulin = (rng.random(n) < 0.03).astype(float) * rng.normal(4, 1.2, n).clip(0, 8)
130
+ steps = rng.integers(0, 150, size=n)
131
+ hr = 70 + (steps > 80) * rng.integers(30, 60, size=n)
132
+ glucose = base + noise + 0.3 * meals - 0.8 * insulin
133
+ df = pd.DataFrame({
134
+ "timestamp": times,
135
+ "glucose_mgdl": np.round(glucose, 1),
136
+ "carbs_g": np.round(meals, 1),
137
+ "insulin_units": np.round(insulin, 1),
138
+ "steps": steps,
139
+ "hr": hr,
140
+ })
141
+
142
+ # ---- Robust timestamp parsing (handles tz-aware) ----
143
+ from pandas.api.types import is_datetime64_any_dtype
144
+ if "timestamp" not in df.columns:
145
+ st.error("CSV must include a 'timestamp' column.")
146
+ st.stop()
147
+
148
+ if not is_datetime64_any_dtype(df["timestamp"]):
149
+ df["timestamp"] = pd.to_datetime(df["timestamp"], utc=True, errors="coerce")
150
+
151
+ # localize to UTC if naive
152
+ if getattr(df["timestamp"].dt, "tz", None) is None:
153
+ df["timestamp"] = df["timestamp"].dt.tz_localize("UTC")
154
+
155
+ df = df.sort_values("timestamp").reset_index(drop=True)
156
+ df["dt_min"] = df["timestamp"].diff().dt.total_seconds() / 60.0
157
+ df["glucose_prev"] = df["glucose_mgdl"].shift(1)
158
+ df["roc_mgdl_min"] = (df["glucose_mgdl"] - df["glucose_prev"]) / df["dt_min"]
159
+ df["roc_mgdl_min"] = df["roc_mgdl_min"].replace([np.inf, -np.inf], 0.0).fillna(0.0)
160
+
161
+ # Train model
162
+ model = train_heavy_model(df)
163
+
164
+ # Run stream
165
+ gate = SundewGate(target_activation=target_activation, temperature=temperature, mode=mode)
166
+ records = []
167
+ alerts = []
168
+
169
+ for _, row in df.iterrows():
170
+ score = compute_lightweight_score(row)
171
+ open_gate = gate.decide(score)
172
+ decision = "SKIP"
173
+ proba = None
174
+ if open_gate:
175
+ X = np.array([[row.get("glucose_mgdl", 0.0), row.get("roc_mgdl_min", 0.0),
176
+ row.get("insulin_units", 0.0), row.get("carbs_g", 0.0), row.get("hr", 0.0)]])
177
+ try:
178
+ proba = float(model.predict_proba(X)[0, 1])
179
+ except Exception:
180
+ proba = float(model.predict(X)[0])
181
+ decision = "RUN"
182
+ if proba >= 0.6:
183
+ alerts.append({
184
+ "timestamp": row["timestamp"],
185
+ "glucose": row["glucose_mgdl"],
186
+ "risk_proba": proba,
187
+ "note": "⚠ Elevated 30-min risk — please check CGM and plan carbs/insulin."
188
+ })
189
+ records.append({
190
+ "timestamp": row["timestamp"], "glucose": row["glucose_mgdl"], "roc": row["roc_mgdl_min"],
191
+ "score": score, "gate": decision, "risk_proba": proba
192
+ })
193
+
194
+ out = pd.DataFrame(records)
195
+ events = len(out)
196
+ activations = int((out["gate"] == "RUN").sum())
197
+ rate = activations / max(events, 1)
198
+
199
+ c1, c2, c3 = st.columns(3)
200
+ c1.metric("Events", f"{events}")
201
+ c2.metric("Heavy activations", f"{activations}")
202
+ c3.metric("Activation rate", f"{rate:.2%}")
203
+
204
+ st.line_chart(out.set_index("timestamp")["glucose"], height=220)
205
+ st.line_chart(out.set_index("timestamp")["score"], height=220)
206
+
207
+ st.subheader("Decisions (tail)")
208
+ st.dataframe(out.tail(50))
209
+
210
+ st.subheader("Alerts")
211
+ if alerts:
212
+ st.dataframe(pd.DataFrame(alerts))
213
+ else:
214
+ st.info("No high-risk alerts triggered in this window.")
215
+
216
+ st.caption("Engine: {}"
217
+ .format("sundew-algorithms active" if _HAS_SUNDEW else "fallback gate (install sundew-algorithms)"))
requirements.txt CHANGED
@@ -1,3 +1,6 @@
1
- altair
2
- pandas
3
- streamlit
 
 
 
 
1
+ streamlit==1.37.1
2
+ scikit-learn==1.5.1
3
+ numpy==1.26.4
4
+ pandas==2.2.2
5
+ # Sundew adaptive gating algorithm (PyPI)
6
+ sundew-algorithms