Spaces:
Sleeping
Sleeping
Kenneth Chew commited on
add sentiment demo app
Browse files- README.md +15 -8
- app/__init__.py +1 -0
- app/preprocessing.py +40 -0
- main.py +114 -0
- requirements.txt +2 -0
- sentiment.joblib +3 -0
README.md
CHANGED
|
@@ -1,13 +1,20 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji: 🌖
|
| 4 |
colorFrom: blue
|
| 5 |
-
colorTo:
|
| 6 |
sdk: gradio
|
| 7 |
-
sdk_version:
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
---
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: RocketML Sentiment Demo
|
|
|
|
| 3 |
colorFrom: blue
|
| 4 |
+
colorTo: indigo
|
| 5 |
sdk: gradio
|
| 6 |
+
sdk_version: 5.50.0
|
| 7 |
+
app_file: main.py
|
| 8 |
+
python_version: "3.12"
|
| 9 |
+
short_description: TF-IDF + LogReg sentiment classifier (RocketML demo)
|
| 10 |
---
|
| 11 |
|
| 12 |
+
# RocketML -- sentiment demo
|
| 13 |
+
|
| 14 |
+
A live demo of the model served by [RocketML](https://github.com/kenzychew/RocketML):
|
| 15 |
+
a TF-IDF + LogisticRegression sentiment classifier trained on IMDB reviews. Type a
|
| 16 |
+
movie review and get a positive/negative label with a confidence score.
|
| 17 |
+
|
| 18 |
+
The point of RocketML is the platform around the model -- containerised serving,
|
| 19 |
+
CI to GHCR, Prometheus/Grafana monitoring, and a Helm chart for Kubernetes. This
|
| 20 |
+
Space is just the model on its own so you can try it.
|
app/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""RocketML serving application package."""
|
app/preprocessing.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Text preprocessing shared by training and serving.
|
| 2 |
+
|
| 3 |
+
The same cleaning must run at train time and at inference time, so it lives in
|
| 4 |
+
one place and is imported by both ``model/train.py`` and the serving app.
|
| 5 |
+
Cleaning is regex-only (no NLTK) to keep the serving image light.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import re
|
| 9 |
+
|
| 10 |
+
_HTML = re.compile(r"<[^>]+>")
|
| 11 |
+
_URL = re.compile(r"http\S+|www\S+|https\S+", re.MULTILINE)
|
| 12 |
+
_EMAIL = re.compile(r"\S+@\S+")
|
| 13 |
+
_NON_TEXT = re.compile(r"[^a-zA-Z0-9\s']")
|
| 14 |
+
_DOUBLE_APOS = re.compile(r"''")
|
| 15 |
+
_LONE_APOS = re.compile(r"\s'\s")
|
| 16 |
+
_WHITESPACE = re.compile(r"\s+")
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def clean_text(text: str) -> str:
|
| 20 |
+
"""Normalise raw review text for the sentiment model.
|
| 21 |
+
|
| 22 |
+
Strips HTML, lowercases, drops URLs/emails, keeps alphanumerics and
|
| 23 |
+
apostrophes, and collapses whitespace. Applied identically at train and
|
| 24 |
+
inference time so the model sees the same text distribution.
|
| 25 |
+
|
| 26 |
+
Args:
|
| 27 |
+
text: Raw input text.
|
| 28 |
+
|
| 29 |
+
Returns:
|
| 30 |
+
The cleaned text.
|
| 31 |
+
"""
|
| 32 |
+
text = _HTML.sub(" ", text)
|
| 33 |
+
text = text.lower()
|
| 34 |
+
text = _URL.sub("", text)
|
| 35 |
+
text = _EMAIL.sub("", text)
|
| 36 |
+
text = _NON_TEXT.sub(" ", text)
|
| 37 |
+
text = _DOUBLE_APOS.sub("", text)
|
| 38 |
+
text = _LONE_APOS.sub(" ", text)
|
| 39 |
+
text = _WHITESPACE.sub(" ", text)
|
| 40 |
+
return text.strip()
|
main.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Gradio demo for the RocketML sentiment model (Hugging Face Space).
|
| 2 |
+
|
| 3 |
+
The model is the same scikit-learn pipeline RocketML serves. Its TF-IDF step
|
| 4 |
+
references app.preprocessing.clean_text, so the app/ package ships alongside
|
| 5 |
+
this file for joblib.load to resolve.
|
| 6 |
+
|
| 7 |
+
The endpoint is public, so two light guards keep it from being hammered: a
|
| 8 |
+
per-client cooldown and a global daily prediction cap ("demo credits"). Both
|
| 9 |
+
counters are in-memory and reset if the instance restarts, which is acceptable
|
| 10 |
+
for a single-instance demo.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
import os
|
| 14 |
+
import threading
|
| 15 |
+
import time
|
| 16 |
+
from datetime import date, datetime, timezone
|
| 17 |
+
|
| 18 |
+
import gradio as gr
|
| 19 |
+
import joblib
|
| 20 |
+
|
| 21 |
+
MODEL = joblib.load("sentiment.joblib")
|
| 22 |
+
|
| 23 |
+
DAILY_CAP = int(os.environ.get("DEMO_DAILY_CAP", "200"))
|
| 24 |
+
COOLDOWN_SECONDS = 3.0
|
| 25 |
+
|
| 26 |
+
EXAMPLES = [
|
| 27 |
+
"An absolute masterpiece -- beautifully acted and deeply moving.",
|
| 28 |
+
"Boring, predictable, and a complete waste of two hours.",
|
| 29 |
+
"The plot dragged, but the soundtrack was wonderful.",
|
| 30 |
+
]
|
| 31 |
+
|
| 32 |
+
_lock = threading.Lock()
|
| 33 |
+
_day: date | None = None
|
| 34 |
+
_count = 0
|
| 35 |
+
_last_call: dict[str, float] = {}
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _client_id(request: gr.Request | None) -> str:
|
| 39 |
+
"""Best-effort client identifier for throttling.
|
| 40 |
+
|
| 41 |
+
Args:
|
| 42 |
+
request: The incoming Gradio request, if any.
|
| 43 |
+
|
| 44 |
+
Returns:
|
| 45 |
+
The originating client IP (first X-Forwarded-For hop behind the
|
| 46 |
+
Cloud Run proxy), or a placeholder when unavailable.
|
| 47 |
+
"""
|
| 48 |
+
if request is None:
|
| 49 |
+
return "unknown"
|
| 50 |
+
forwarded = request.headers.get("x-forwarded-for")
|
| 51 |
+
if forwarded:
|
| 52 |
+
return forwarded.split(",")[0].strip()
|
| 53 |
+
return request.client.host if request.client else "unknown"
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def _check_limits(client: str) -> None:
|
| 57 |
+
"""Enforce the per-client cooldown and the global daily cap.
|
| 58 |
+
|
| 59 |
+
Args:
|
| 60 |
+
client: Client identifier from _client_id.
|
| 61 |
+
|
| 62 |
+
Raises:
|
| 63 |
+
gr.Error: If the client is calling too fast or today's demo
|
| 64 |
+
credits are spent.
|
| 65 |
+
"""
|
| 66 |
+
global _day, _count
|
| 67 |
+
with _lock:
|
| 68 |
+
today = datetime.now(timezone.utc).date()
|
| 69 |
+
if _day != today:
|
| 70 |
+
_day, _count = today, 0
|
| 71 |
+
_last_call.clear()
|
| 72 |
+
last = _last_call.get(client)
|
| 73 |
+
now = time.monotonic()
|
| 74 |
+
if last is not None and now - last < COOLDOWN_SECONDS:
|
| 75 |
+
raise gr.Error("One prediction every few seconds, please.")
|
| 76 |
+
if _count >= DAILY_CAP:
|
| 77 |
+
raise gr.Error("Sorry, out of demo credits for now -- try again tomorrow.")
|
| 78 |
+
_last_call[client] = now
|
| 79 |
+
_count += 1
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def classify(text: str, request: gr.Request) -> dict[str, float]:
|
| 83 |
+
"""Return the model's class probabilities for the given text.
|
| 84 |
+
|
| 85 |
+
Args:
|
| 86 |
+
text: Raw review text from the UI.
|
| 87 |
+
request: Injected by Gradio; used for rate limiting.
|
| 88 |
+
|
| 89 |
+
Returns:
|
| 90 |
+
Mapping of class label to probability, empty for blank input.
|
| 91 |
+
"""
|
| 92 |
+
if not text or not text.strip():
|
| 93 |
+
return {}
|
| 94 |
+
_check_limits(_client_id(request))
|
| 95 |
+
probs = MODEL.predict_proba([text])[0]
|
| 96 |
+
return {str(label): float(p) for label, p in zip(MODEL.classes_, probs)}
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
demo = gr.Interface(
|
| 100 |
+
fn=classify,
|
| 101 |
+
inputs=gr.Textbox(lines=4, label="Text", placeholder="Type a movie review..."),
|
| 102 |
+
outputs=gr.Label(num_top_classes=2, label="Sentiment"),
|
| 103 |
+
title="RocketML -- sentiment demo",
|
| 104 |
+
description=(
|
| 105 |
+
"A TF-IDF + LogisticRegression sentiment classifier (trained on IMDB). "
|
| 106 |
+
"This is the model served by the RocketML platform: "
|
| 107 |
+
"https://github.com/kenzychew/RocketML -- the demo is lightly "
|
| 108 |
+
"rate-limited and has a daily prediction budget."
|
| 109 |
+
),
|
| 110 |
+
examples=EXAMPLES,
|
| 111 |
+
)
|
| 112 |
+
|
| 113 |
+
if __name__ == "__main__":
|
| 114 |
+
demo.launch(server_name="0.0.0.0", server_port=int(os.environ.get("PORT", "7860")))
|
requirements.txt
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
scikit-learn==1.9.0
|
| 2 |
+
joblib
|
sentiment.joblib
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:c7ce940765dda1b0d9ccab79889f277dd546b6997cb69b840c37dd9d6a62d43f
|
| 3 |
+
size 926260
|