Deploy ExtraaLearn Flask backend
Browse files- Dockerfile +19 -0
- README.md +10 -5
- __pycache__/app.cpython-312.pyc +0 -0
- app.py +95 -0
- extraalearn_lead_model_v1_0.joblib +3 -0
- requirements.txt +7 -0
Dockerfile
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Lightweight Python base image
|
| 2 |
+
FROM python:3.12-slim
|
| 3 |
+
|
| 4 |
+
# Hugging Face Docker spaces run the container as a non-root user with UID 1000
|
| 5 |
+
RUN useradd -m -u 1000 user
|
| 6 |
+
USER user
|
| 7 |
+
ENV PATH="/home/user/.local/bin:$PATH"
|
| 8 |
+
WORKDIR /home/user/app
|
| 9 |
+
|
| 10 |
+
# Install dependencies first so Docker can cache this layer
|
| 11 |
+
COPY --chown=user requirements.txt .
|
| 12 |
+
RUN pip install --no-cache-dir --upgrade pip && pip install --no-cache-dir -r requirements.txt
|
| 13 |
+
|
| 14 |
+
# Copy the API code and the serialized model
|
| 15 |
+
COPY --chown=user . .
|
| 16 |
+
|
| 17 |
+
# Serve the Flask app with gunicorn on port 7860 (the port Hugging Face exposes)
|
| 18 |
+
EXPOSE 7860
|
| 19 |
+
CMD ["gunicorn", "--workers", "2", "--bind", "0.0.0.0:7860", "--timeout", "120", "app:app"]
|
README.md
CHANGED
|
@@ -1,10 +1,15 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
|
|
|
| 7 |
pinned: false
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: ExtraaLearn Lead Conversion Backend
|
| 3 |
+
emoji: 🎓
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: indigo
|
| 6 |
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
pinned: false
|
| 9 |
---
|
| 10 |
|
| 11 |
+
Flask REST API serving the ExtraaLearn lead-conversion model.
|
| 12 |
+
|
| 13 |
+
* `GET /` - health check
|
| 14 |
+
* `POST /v1/lead` - JSON body with one lead's features, returns prediction + conversion probability
|
| 15 |
+
* `POST /v1/leadbatch` - multipart CSV upload (field `file`), returns one prediction per lead
|
__pycache__/app.cpython-312.pyc
ADDED
|
Binary file (5.77 kB). View file
|
|
|
app.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import joblib
|
| 2 |
+
import pandas as pd
|
| 3 |
+
from flask import Flask, jsonify, request
|
| 4 |
+
|
| 5 |
+
# Initialise the Flask app and load the serialized pipeline (preprocessing + model) once at startup
|
| 6 |
+
app = Flask("ExtraaLearn Lead Conversion Predictor")
|
| 7 |
+
model = joblib.load("extraalearn_lead_model_v1_0.joblib")
|
| 8 |
+
|
| 9 |
+
# Raw input features the pipeline expects, with their allowed values where categorical
|
| 10 |
+
FEATURES = ["age", "current_occupation", "first_interaction", "profile_completed", "website_visits",
|
| 11 |
+
"time_spent_on_website", "page_views_per_visit", "last_activity", "print_media_type1",
|
| 12 |
+
"print_media_type2", "digital_media", "educational_channels", "referral"]
|
| 13 |
+
NUMERIC = {"age": int, "website_visits": int, "time_spent_on_website": int, "page_views_per_visit": float}
|
| 14 |
+
CATEGORIES = {
|
| 15 |
+
"current_occupation": ["Professional", "Unemployed", "Student"],
|
| 16 |
+
"first_interaction": ["Website", "Mobile App"],
|
| 17 |
+
"profile_completed": ["Low", "Medium", "High"],
|
| 18 |
+
"last_activity": ["Email Activity", "Phone Activity", "Website Activity"],
|
| 19 |
+
**{flag: ["Yes", "No"] for flag in ["print_media_type1", "print_media_type2", "digital_media",
|
| 20 |
+
"educational_channels", "referral"]},
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def validate(record):
|
| 25 |
+
"""Check that a lead record has every feature with a valid value; return a list of errors."""
|
| 26 |
+
errors = []
|
| 27 |
+
for f in FEATURES:
|
| 28 |
+
if f not in record:
|
| 29 |
+
errors.append(f"missing field '{f}'")
|
| 30 |
+
elif f in NUMERIC:
|
| 31 |
+
try:
|
| 32 |
+
if NUMERIC[f](record[f]) < 0:
|
| 33 |
+
errors.append(f"'{f}' must be non-negative")
|
| 34 |
+
except (TypeError, ValueError):
|
| 35 |
+
errors.append(f"'{f}' must be numeric")
|
| 36 |
+
elif record[f] not in CATEGORIES[f]:
|
| 37 |
+
errors.append(f"'{f}' must be one of {CATEGORIES[f]}")
|
| 38 |
+
return errors
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def to_frame(records):
|
| 42 |
+
"""Build a model-ready DataFrame with the right column order and numeric types."""
|
| 43 |
+
df = pd.DataFrame(records)[FEATURES]
|
| 44 |
+
for f, cast in NUMERIC.items():
|
| 45 |
+
df[f] = df[f].astype(cast)
|
| 46 |
+
return df
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
@app.get("/")
|
| 50 |
+
def home():
|
| 51 |
+
"""Health check."""
|
| 52 |
+
return "Welcome to the ExtraaLearn Lead Conversion Prediction API!"
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
@app.post("/v1/lead")
|
| 56 |
+
def predict_lead():
|
| 57 |
+
"""Predict the conversion of a single lead sent as a JSON object."""
|
| 58 |
+
record = request.get_json(silent=True)
|
| 59 |
+
if not isinstance(record, dict):
|
| 60 |
+
return jsonify({"error": "Request body must be a JSON object with the lead's features"}), 400
|
| 61 |
+
errors = validate(record)
|
| 62 |
+
if errors:
|
| 63 |
+
return jsonify({"error": "Invalid input", "details": errors}), 400
|
| 64 |
+
|
| 65 |
+
df = to_frame([record])
|
| 66 |
+
probability = float(model.predict_proba(df)[0, 1])
|
| 67 |
+
prediction = int(model.predict(df)[0])
|
| 68 |
+
return jsonify({
|
| 69 |
+
"prediction": prediction,
|
| 70 |
+
"prediction_label": "Likely to convert" if prediction == 1 else "Unlikely to convert",
|
| 71 |
+
"conversion_probability": round(probability, 4),
|
| 72 |
+
})
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
@app.post("/v1/leadbatch")
|
| 76 |
+
def predict_lead_batch():
|
| 77 |
+
"""Predict the conversion of every lead in an uploaded CSV file (form field 'file')."""
|
| 78 |
+
if "file" not in request.files:
|
| 79 |
+
return jsonify({"error": "Upload a CSV file in the form field 'file'"}), 400
|
| 80 |
+
batch = pd.read_csv(request.files["file"])
|
| 81 |
+
missing = [f for f in FEATURES if f not in batch.columns]
|
| 82 |
+
if missing:
|
| 83 |
+
return jsonify({"error": f"CSV is missing columns: {missing}"}), 400
|
| 84 |
+
|
| 85 |
+
df = to_frame(batch)
|
| 86 |
+
ids = batch["ID"].astype(str).tolist() if "ID" in batch.columns else [str(i) for i in range(len(batch))]
|
| 87 |
+
results = [
|
| 88 |
+
{"ID": i, "prediction": int(p), "conversion_probability": round(float(pr), 4)}
|
| 89 |
+
for i, p, pr in zip(ids, model.predict(df), model.predict_proba(df)[:, 1])
|
| 90 |
+
]
|
| 91 |
+
return jsonify(results)
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
if __name__ == "__main__":
|
| 95 |
+
app.run(host="0.0.0.0", port=7860, debug=False)
|
extraalearn_lead_model_v1_0.joblib
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:928508ec696a1ca14e3204387b3283f134ab8d7f99433c33d256d95100640111
|
| 3 |
+
size 132290
|
requirements.txt
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
pandas==3.0.6
|
| 2 |
+
numpy==2.5.3
|
| 3 |
+
scikit-learn==1.9.1
|
| 4 |
+
xgboost-cpu==3.4.1
|
| 5 |
+
joblib==1.6.0
|
| 6 |
+
flask==3.1.3
|
| 7 |
+
gunicorn==26.2.0
|