Model Deployment I — From Data to a Working ML Product

Smart Building Energy Demand: Simulated Data, EDA, XGBoost, Flask API, and Streamlit

Author

MLStation

One model. Two applications. Three user-facing API endpoints.

We will build a complete machine-learning product that predicts the next hour’s electricity demand of a commercial building. Nothing important is hidden behind “assume the API exists”: we generate the data, inspect it, train XGBoost, save the fitted pipeline, load it in Flask, expose an API, and build the complete Streamlit frontend.

Continue to Part II → Docker & Cloud Deployment

1. What are we building?

Frontend vs backend. The frontend is what a human sees and operates. Streamlit renders forms, buttons, tables and charts. The backend owns the trained model and business logic. Flask receives HTTP requests, validates them, calls the model, and returns JSON. The frontend never needs to know how XGBoost is implemented.

                    TRAINING TIME
 simulated CSV ──> preprocessing ──> XGBoost ──> SAVE fitted pipeline
                                                │
                                                ▼
                               artifacts/energy_xgb_pipeline.joblib
                                                │
                    INFERENCE TIME              │ LOAD ONCE
                                                ▼
 User ──> Streamlit ──HTTP──> Flask API ──> fitted model ──> prediction
              │                  │
              │                  ├── GET  /api/v1/meta
              │                  ├── POST /api/v1/predict
              │                  └── POST /api/v1/predict-batch
              └── renders prediction, tables and charts

The three endpoints

Endpoint Intended user What it does
GET /api/v1/meta Author / developer / UI Returns model name, version, features, units and training metrics. It lets the UI discover what model is serving predictions.
POST /api/v1/predict Individual user Accepts one building observation as JSON and returns one next-hour energy prediction.
POST /api/v1/predict-batch Batch user / analyst Accepts a CSV file containing many buildings and returns a CSV with predictions.

We also use GET /health. It is an operational endpoint for Docker/cloud health checks, not one of our three audience-facing endpoints.

2. Project structure

energy-demand-deployment/
├── data/
│   └── building_energy.csv
├── artifacts/
│   ├── energy_xgb_pipeline.joblib
│   ├── metrics.json
│   └── test_predictions.csv
├── training/
│   ├── simulate_data.py
│   └── train.py
├── backend/
│   └── app.py
├── frontend/
│   └── streamlit_app.py
├── requirements.txt
└── README.md
New libraries. xgboost supplies our gradient-boosted trees; joblib serializes the complete fitted pipeline; Flask exposes Python functions over HTTP; requests lets Streamlit call Flask; Plotly gives interactive charts; and Streamlit builds the browser UI.
requirements.txt
pandas>=2.2,<3
numpy>=2.0,<3
scikit-learn>=1.6,<2
xgboost>=3.0,<4
joblib>=1.4,<2
flask>=3.1,<4
gunicorn>=23,<24
streamlit>=1.43,<2
requests>=2.32,<3
plotly>=6,<7

Install once:

python -m pip install -r requirements.txt

3. Our simulated dataset

This dataset is simulated. It is deliberately generated for teaching. It does not contain real customer, utility or building records. Because we know the data-generating mechanism, we can create nonlinear relationships that make model choice meaningful.

Each row describes one building-hour. The target is energy_kwh: electricity expected during the next hour.

Column Meaning Type
building_type office, school, retail, clinic categorical
day_of_week Mon–Sun categorical
hour hour of day, 0–23 integer
floor_area_m2 building floor area numeric
outdoor_temp_c outdoor temperature numeric
humidity_pct relative humidity numeric
occupancy estimated people present numeric
previous_hour_kwh most recent observed demand numeric
energy_kwh target: next-hour demand numeric

A simplified data-generating equation is

Y = 18 + 0.0045A + 1.8O + 0.55H + 0.38L + C_{type} + g(T) + h(t) + \epsilon,

where A is area, O occupancy, H humidity, L previous-hour load, g(T) is nonlinear heating/cooling demand, h(t) is a daily-cycle effect, and \epsilon\sim N(0,6^2).

Generate the CSV

The next program creates 6,000 synthetic observations, deliberately adds a small amount of missingness, and writes data/building_energy.csv.

Show code — simulate and save the teaching dataset
from pathlib import Path
import numpy as np
import pandas as pd

ROOT = Path.cwd()
DATA = ROOT / "data"
DATA.mkdir(exist_ok=True)
rng = np.random.default_rng(42)
N = 6000

hour = rng.integers(0, 24, N)
day = rng.choice(["Mon","Tue","Wed","Thu","Fri","Sat","Sun"], N)
building_type = rng.choice(["office","school","retail","clinic"], N,
                           p=[0.45,0.20,0.22,0.13])
floor_area_m2 = np.round(rng.lognormal(np.log(2200), .48, N), 1)
outdoor_temp_c = np.clip(rng.normal(22, 8, N), -5, 43)
humidity_pct = np.clip(rng.normal(55, 17, N), 15, 98)
weekday = np.isin(day, ["Mon","Tue","Wed","Thu","Fri"]).astype(int)
open_hour = ((hour >= 7) & (hour <= 19)).astype(int)
occupancy = np.maximum(0, (floor_area_m2/28)*weekday*open_hour + rng.normal(4,8,N))
occupancy = np.round(occupancy).astype(float)
previous_hour_kwh = np.maximum(5, 22 + .004*floor_area_m2 + 1.5*occupancy + rng.normal(0,10,N))

type_effect = pd.Series(building_type).map({"office":8,"school":3,"retail":13,"clinic":20}).to_numpy()
cooling = 1.7*np.maximum(outdoor_temp_c-22, 0)**1.25
heating = 1.1*np.maximum(15-outdoor_temp_c, 0)**1.18
daily_cycle = 8*np.sin(2*np.pi*(hour-8)/24)*open_hour
noise = rng.normal(0, 6, N)
energy_kwh = (18 + .0045*floor_area_m2 + 1.8*occupancy + .55*humidity_pct
              + .38*previous_hour_kwh + type_effect + cooling + heating
              + daily_cycle + noise)
energy_kwh = np.maximum(3, energy_kwh)

df = pd.DataFrame({
    "building_id": [f"BLD-{i:05d}" for i in range(N)],
    "building_type": building_type, "day_of_week": day, "hour": hour,
    "floor_area_m2": floor_area_m2, "outdoor_temp_c": np.round(outdoor_temp_c,1),
    "humidity_pct": np.round(humidity_pct,1), "occupancy": occupancy,
    "previous_hour_kwh": np.round(previous_hour_kwh,2),
    "energy_kwh": np.round(energy_kwh,2)
})
for c in ["humidity_pct", "occupancy"]:
    df.loc[rng.choice(N, int(.015*N), replace=False), c] = np.nan

df.to_csv(DATA / "building_energy.csv", index=False)
print(f"Saved {len(df):,} simulated rows to {DATA / 'building_energy.csv'}")
Saved 6,000 simulated rows to /Users/prabhat/Documents/MyWebSpace/mlstation/data/building_energy.csv

Look at the data before modeling

Show code — load the CSV and display sample rows
df = pd.read_csv("data/building_energy.csv")
df.head(10)
building_id building_type day_of_week hour floor_area_m2 outdoor_temp_c humidity_pct occupancy previous_hour_kwh energy_kwh
0 BLD-00000 school Fri 2 2514.8 35.2 59.4 17.0 53.66 165.04
1 BLD-00001 office Sat 18 1229.9 13.2 61.7 2.0 16.94 82.90
2 BLD-00002 school Fri 15 1811.3 15.6 70.2 68.0 136.58 241.47
3 BLD-00003 office Fri 10 1619.6 17.0 69.9 72.0 129.59 254.71
4 BLD-00004 office Thu 10 1370.5 18.7 49.8 NaN 129.84 237.39
5 BLD-00005 school Tue 20 2237.7 21.9 71.2 7.0 51.51 93.92
6 BLD-00006 retail Tue 2 641.3 29.7 49.1 13.0 49.78 124.96
7 BLD-00007 retail Sat 16 1890.8 25.7 59.1 0.0 23.35 99.79
8 BLD-00008 retail Mon 4 2106.7 22.5 58.4 0.0 40.84 95.58
9 BLD-00009 retail Mon 2 1145.7 24.2 40.4 0.0 33.24 82.10
Checkpoint. At this point the CSV exists independently of the model. That separation is intentional: data generation is one stage; model training is another.

4. Exploratory Data Analysis (EDA)

EDA asks questions. Are values missing? Is the target skewed? Does demand change by building type, temperature and hour? Are relationships nonlinear?

4.1 Data quality

Show code — dimensions, missing values and descriptive statistics
print("Shape:", df.shape)
display(df.isna().sum().to_frame("missing_rows"))
display(df.describe(include="all").T)
Shape: (6000, 10)
missing_rows
building_id 0
building_type 0
day_of_week 0
hour 0
floor_area_m2 0
outdoor_temp_c 0
humidity_pct 90
occupancy 90
previous_hour_kwh 0
energy_kwh 0
count unique top freq mean std min 25% 50% 75% max
building_id 6000 6000 BLD-00000 1 NaN NaN NaN NaN NaN NaN NaN
building_type 6000 4 office 2687 NaN NaN NaN NaN NaN NaN NaN
day_of_week 6000 7 Thu 895 NaN NaN NaN NaN NaN NaN NaN
hour 6000.0 NaN NaN NaN 11.475333 6.926499 0.0 5.0 11.0 18.0 23.0
floor_area_m2 6000.0 NaN NaN NaN 2489.560483 1258.435786 340.4 1611.775 2199.9 3052.0 10222.3
outdoor_temp_c 6000.0 NaN NaN NaN 22.230483 7.965403 -5.0 16.8 22.2 27.7 43.0
humidity_pct 5910.0 NaN NaN NaN 55.345584 16.925535 15.0 43.7 55.5 66.7 98.0
occupancy 5910.0 NaN NaN NaN 38.762944 50.67408 0.0 2.0 11.0 69.0 378.0
previous_hour_kwh 6000.0 NaN NaN NaN 89.962887 78.262301 5.0 36.1875 52.705 131.9625 621.97
energy_kwh 6000.0 NaN NaN NaN 186.57787 125.496408 36.94 98.7475 128.22 256.37 1010.87

4.2 Target distribution

Show code — interactive histogram of next-hour demand
import plotly.express as px
import plotly.graph_objects as go

fig = px.histogram(df, x="energy_kwh", nbins=55, marginal="box",
                   color_discrete_sequence=["#6a11cb"],
                   title="Distribution of next-hour electricity demand")
fig.update_layout(template="plotly_white", xaxis_title="Energy (kWh)", yaxis_title="Building-hours")
fig.show()

4.3 Building types behave differently

Show code — vibrant box plot by building type
fig = px.box(df, x="building_type", y="energy_kwh", color="building_type",
             points="outliers", title="Demand varies by building type")
fig.update_layout(template="plotly_white", showlegend=False,
                  xaxis_title="Building type", yaxis_title="Next-hour energy (kWh)")
fig.show()

4.4 Temperature is nonlinear

Show code — temperature vs demand with interactive hover
sample = df.sample(1600, random_state=42)
fig = px.scatter(sample, x="outdoor_temp_c", y="energy_kwh", color="building_type",
                 opacity=.55, hover_data=["hour","occupancy","previous_hour_kwh"],
                 title="Temperature and demand: one straight line is not enough")
fig.update_layout(template="plotly_white", xaxis_title="Outdoor temperature (°C)",
                  yaxis_title="Next-hour energy (kWh)")
fig.show()

4.5 Daily demand cycle

Show code — mean hourly demand by building type
hourly = df.groupby(["hour","building_type"], as_index=False)["energy_kwh"].mean()
fig = px.line(hourly, x="hour", y="energy_kwh", color="building_type", markers=True,
              title="Average demand across the day")
fig.update_layout(template="plotly_white", xaxis=dict(dtick=1),
                  xaxis_title="Hour", yaxis_title="Mean next-hour energy (kWh)")
fig.show()
EDA → model choice. We see nonlinear temperature effects, interactions between occupancy/time/building type, and mixed numeric/categorical features. A tree ensemble is a natural candidate because trees partition feature space and naturally represent thresholds and interactions.

5. Why XGBoost?

A regression tree partitions the feature space into leaves R_1,\dots,R_J and predicts a constant within each leaf:

f(x)=\sum_{j=1}^{J}w_j\,I(x\in R_j).

Boosting builds an additive model,

\hat y_i^{(K)}=\sum_{k=1}^{K} f_k(x_i),

where each new tree corrects errors left by earlier trees. XGBoost minimizes a regularized objective of the form

\mathcal L^{(t)}=\sum_i l(y_i,\hat y_i^{(t-1)}+f_t(x_i)) + \Omega(f_t),

with a tree-complexity penalty commonly written

\Omega(f)=\gamma T+\frac{1}{2}\lambda\sum_{j=1}^{T}w_j^2.

learning_rate shrinks each tree’s contribution; max_depth controls interaction complexity; n_estimators controls how many trees are added; subsampling can reduce variance.

6. Train, evaluate, and save the complete fitted pipeline

What this code does. It (1) loads the simulated CSV, (2) splits train/test data, (3) imputes missing numeric values and one-hot encodes categories, (4) fits XGBoost, (5) evaluates untouched test data, and (6) saves the preprocessing + XGBoost model together. Saving them together prevents training/serving preprocessing mismatch.
Show code — train XGBoost, evaluate it, and SAVE the fitted pipeline
from pathlib import Path
import json, joblib
import numpy as np
import pandas as pd
from sklearn.compose import ColumnTransformer
from sklearn.impute import SimpleImputer
from sklearn.metrics import mean_absolute_error, mean_squared_error, r2_score
from sklearn.model_selection import train_test_split
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder
from xgboost import XGBRegressor

ART = Path("artifacts")
ART.mkdir(exist_ok=True)
df = pd.read_csv("data/building_energy.csv")

FEATURES = ["building_type","day_of_week","hour","floor_area_m2","outdoor_temp_c",
            "humidity_pct","occupancy","previous_hour_kwh"]
TARGET = "energy_kwh"
NUMERIC = ["hour","floor_area_m2","outdoor_temp_c","humidity_pct","occupancy","previous_hour_kwh"]
CATEGORICAL = ["building_type","day_of_week"]

X = df[FEATURES]
y = df[TARGET]
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=.20, random_state=42
)

preprocess = ColumnTransformer([
    ("num", SimpleImputer(strategy="median"), NUMERIC),
    ("cat", Pipeline([
        ("imputer", SimpleImputer(strategy="most_frequent")),
        ("onehot", OneHotEncoder(handle_unknown="ignore"))
    ]), CATEGORICAL)
])

xgb = XGBRegressor(
    objective="reg:squarederror",
    n_estimators=500,
    learning_rate=0.04,
    max_depth=5,
    min_child_weight=3,
    subsample=0.85,
    colsample_bytree=0.85,
    reg_lambda=1.0,
    random_state=42,
    n_jobs=-1
)

pipeline = Pipeline([("preprocess", preprocess), ("model", xgb)])
pipeline.fit(X_train, y_train)
pred = pipeline.predict(X_test)

metrics = {
    "mae": float(mean_absolute_error(y_test, pred)),
    "rmse": float(np.sqrt(mean_squared_error(y_test, pred))),
    "r2": float(r2_score(y_test, pred)),
    "n_train": int(len(X_train)), "n_test": int(len(X_test)),
    "model": "XGBRegressor", "version": "1.0.0"
}

# ===== THE MODEL IS SAVED HERE =====
MODEL_PATH = ART / "energy_xgb_pipeline.joblib"
joblib.dump(pipeline, MODEL_PATH)
# ===================================

(ART / "metrics.json").write_text(json.dumps(metrics, indent=2))
pd.DataFrame({"actual": y_test, "predicted": pred,
              "residual": y_test.to_numpy()-pred}).to_csv(
                  ART / "test_predictions.csv", index=False)

print(json.dumps(metrics, indent=2))
print(f"SAVED fitted preprocessing + XGBoost pipeline -> {MODEL_PATH}")
{
  "mae": 5.9861253491719575,
  "rmse": 9.72811041632903,
  "r2": 0.9940518176199364,
  "n_train": 4800,
  "n_test": 1200,
  "model": "XGBRegressor",
  "version": "1.0.0"
}
SAVED fitted preprocessing + XGBoost pipeline -> artifacts/energy_xgb_pipeline.joblib
Exactly what was saved? joblib.dump(pipeline, ...) serializes the fitted ColumnTransformer and fitted XGBRegressor as one object at artifacts/energy_xgb_pipeline.joblib. We do not retrain when the API starts.

Metrics

For n test observations:

MAE=\frac1n\sum_i|y_i-\hat y_i|,

RMSE=\sqrt{\frac1n\sum_i(y_i-\hat y_i)^2},

R^2=1-\frac{\sum_i(y_i-\hat y_i)^2}{\sum_i(y_i-\bar y)^2}.

MAE is easy to interpret in kWh; RMSE penalizes large misses more strongly; R^2 measures improvement relative to predicting the test-set mean.

Prediction quality visualization

Show code — actual vs predicted and residual diagnostics
res = pd.read_csv("artifacts/test_predictions.csv")
fig = px.scatter(res, x="actual", y="predicted", opacity=.5,
                 color="residual", color_continuous_scale="RdBu",
                 title="Test set: actual vs predicted demand")
lo = min(res.actual.min(), res.predicted.min()); hi = max(res.actual.max(), res.predicted.max())
fig.add_trace(go.Scatter(x=[lo,hi], y=[lo,hi], mode="lines", name="perfect prediction",
                         line=dict(dash="dash", color="black")))
fig.update_layout(template="plotly_white", xaxis_title="Actual kWh", yaxis_title="Predicted kWh")
fig.show()
Show code — residual distribution
fig = px.histogram(res, x="residual", nbins=50, marginal="box",
                   color_discrete_sequence=["#0077b6"], title="Residuals = actual − predicted")
fig.add_vline(x=0, line_dash="dash")
fig.update_layout(template="plotly_white")
fig.show()

7. Backend: Flask owns and loads the model

Backend responsibility. Flask is the gatekeeper between the outside world and the model. It loads the artifact once when the process starts, validates request structure, converts incoming data to the same feature schema used during training, calls MODEL.predict(), and returns machine-readable responses.

This is where the saved model is loaded

Save the following complete file as backend/app.py.

backend/app.py
from pathlib import Path
import io, json
import joblib
import pandas as pd
from flask import Flask, jsonify, request, Response

ROOT = Path(__file__).resolve().parents[1]
MODEL_PATH = ROOT / "artifacts" / "energy_xgb_pipeline.joblib"
METRICS_PATH = ROOT / "artifacts" / "metrics.json"

# ===== THE SAVED MODEL IS LOADED HERE, ONCE AT API STARTUP =====
MODEL = joblib.load(MODEL_PATH)
# ==============================================================
METRICS = json.loads(METRICS_PATH.read_text())

FEATURES = ["building_type","day_of_week","hour","floor_area_m2","outdoor_temp_c",
            "humidity_pct","occupancy","previous_hour_kwh"]
app = Flask(__name__)

def validate_columns(frame):
    missing = [c for c in FEATURES if c not in frame.columns]
    if missing:
        raise ValueError(f"Missing required columns: {missing}")
    return frame[FEATURES]

@app.get("/health")
def health():
    return jsonify({"status":"ok", "model_loaded": True})

# Endpoint 1: AUTHOR / METADATA
@app.get("/api/v1/meta")
def meta():
    return jsonify({
        "project":"Smart Building Energy Demand",
        "model":"XGBoost regression pipeline",
        "version":METRICS["version"],
        "target":"next-hour energy_kwh",
        "features":FEATURES,
        "metrics":METRICS
    })

# Endpoint 2: INDIVIDUAL USER
@app.post("/api/v1/predict")
def predict_one():
    try:
        payload = request.get_json(force=True)
        frame = validate_columns(pd.DataFrame([payload]))
        prediction = float(MODEL.predict(frame)[0])
        return jsonify({"prediction_kwh": round(prediction, 2)})
    except Exception as exc:
        return jsonify({"error": str(exc)}), 400

# Endpoint 3: BATCH USER
@app.post("/api/v1/predict-batch")
def predict_batch():
    try:
        if "file" not in request.files:
            raise ValueError("Upload a CSV using form field 'file'.")
        frame = pd.read_csv(request.files["file"])
        model_frame = validate_columns(frame.copy())
        frame["predicted_energy_kwh"] = MODEL.predict(model_frame).round(2)
        output = frame.to_csv(index=False)
        return Response(output, mimetype="text/csv",
                        headers={"Content-Disposition":"attachment; filename=predictions.csv"})
    except Exception as exc:
        return jsonify({"error": str(exc)}), 400

if __name__ == "__main__":
    app.run(host="0.0.0.0", port=8000, debug=True)

Run it from the project root:

python backend/app.py

Test the API without Streamlit

curl http://localhost:8000/api/v1/meta
curl -X POST http://localhost:8000/api/v1/predict \
  -H "Content-Type: application/json" \
  -d '{"building_type":"office","day_of_week":"Tue","hour":14,"floor_area_m2":2500,"outdoor_temp_c":31,"humidity_pct":58,"occupancy":72,"previous_hour_kwh":165}'
Separation achieved. The model artifact belongs to the backend. Streamlit will call HTTP endpoints; it will not import XGBoost or load the .joblib file.

8. Frontend: complete Streamlit application

Frontend responsibility. Streamlit gathers human input, sends requests, handles success/error states, and visualizes returned results. This means the UI could later be replaced by React, a mobile app, or another client without retraining the model.

Save this complete file as frontend/streamlit_app.py.

frontend/streamlit_app.py
import io, os
import pandas as pd
import plotly.express as px
import requests
import streamlit as st

API_BASE = os.getenv("API_BASE_URL", "http://localhost:8000")
st.set_page_config(page_title="EnergyCast", page_icon="⚡", layout="wide")

st.markdown("""
<style>
.block-container {padding-top: 2rem;}
.hero {padding:1.4rem 1.7rem;border-radius:18px;background:linear-gradient(120deg,#0b3d91,#6a11cb);color:white;margin-bottom:1rem}
.metric-card {padding:1rem;border:1px solid #dbeafe;border-radius:14px;background:#f8fbff}
</style>
<div class="hero"><h1>⚡ EnergyCast</h1><p>Next-hour building energy prediction powered by XGBoost + Flask</p></div>
""", unsafe_allow_html=True)

@st.cache_data(ttl=60)
def get_meta():
    r = requests.get(f"{API_BASE}/api/v1/meta", timeout=10)
    r.raise_for_status()
    return r.json()

try:
    meta = get_meta()
    st.success(f"API connected • {meta['model']} • version {meta['version']}")
except Exception as e:
    st.error(f"Cannot reach backend at {API_BASE}: {e}")
    st.stop()

tab1, tab2, tab3 = st.tabs(["👤 Individual prediction", "📦 Batch prediction", "ℹ️ Model information"])

with tab1:
    st.subheader("Describe one building-hour")
    c1, c2, c3 = st.columns(3)
    with c1:
        building_type = st.selectbox("Building type", ["office","school","retail","clinic"])
        day = st.selectbox("Day", ["Mon","Tue","Wed","Thu","Fri","Sat","Sun"], index=1)
        hour = st.slider("Hour", 0, 23, 14)
    with c2:
        area = st.number_input("Floor area (m²)", 100.0, 20000.0, 2500.0, 100.0)
        temp = st.slider("Outdoor temperature (°C)", -5.0, 45.0, 31.0, .5)
        humidity = st.slider("Humidity (%)", 15.0, 100.0, 58.0, 1.0)
    with c3:
        occupancy = st.number_input("Occupancy", 0, 1000, 72)
        previous = st.number_input("Previous-hour demand (kWh)", 1.0, 2000.0, 165.0, 1.0)

    payload = {"building_type":building_type,"day_of_week":day,"hour":hour,
               "floor_area_m2":area,"outdoor_temp_c":temp,"humidity_pct":humidity,
               "occupancy":occupancy,"previous_hour_kwh":previous}

    if st.button("⚡ Predict next-hour demand", type="primary", use_container_width=True):
        try:
            r = requests.post(f"{API_BASE}/api/v1/predict", json=payload, timeout=15)
            r.raise_for_status()
            prediction = r.json()["prediction_kwh"]
            st.metric("Predicted next-hour electricity", f"{prediction:,.2f} kWh",
                      delta=f"{prediction-previous:+.2f} vs previous hour")
            chart = pd.DataFrame({"period":["Previous hour","Predicted next hour"],
                                  "kWh":[previous,prediction]})
            fig = px.bar(chart, x="period", y="kWh", text_auto=".2f",
                         color="period", title="Recent vs predicted demand")
            fig.update_layout(showlegend=False)
            st.plotly_chart(fig, use_container_width=True)
        except Exception as e:
            st.error(f"Prediction failed: {e}")

with tab2:
    st.subheader("Predict many rows from a CSV")
    template = pd.DataFrame([payload])
    st.download_button("⬇️ Download one-row CSV template", template.to_csv(index=False),
                       "batch_template.csv", "text/csv")
    uploaded = st.file_uploader("Upload CSV", type="csv")
    if uploaded is not None:
        preview = pd.read_csv(uploaded)
        st.dataframe(preview.head(20), use_container_width=True)
        uploaded.seek(0)
        if st.button("📦 Run batch prediction", type="primary"):
            files = {"file": (uploaded.name, uploaded.getvalue(), "text/csv")}
            r = requests.post(f"{API_BASE}/api/v1/predict-batch", files=files, timeout=60)
            if r.ok:
                result = pd.read_csv(io.BytesIO(r.content))
                st.success(f"Predicted {len(result):,} rows")
                st.dataframe(result.head(50), use_container_width=True)
                st.download_button("⬇️ Download predictions", r.content,
                                   "predictions.csv", "text/csv")
            else:
                st.error(r.text)

with tab3:
    st.subheader("What model is serving this app?")
    st.json(meta)
    m = meta.get("metrics", {})
    a,b,c = st.columns(3)
    a.metric("Test MAE", f"{m.get('mae', float('nan')):.2f} kWh")
    b.metric("Test RMSE", f"{m.get('rmse', float('nan')):.2f} kWh")
    c.metric("Test R²", f"{m.get('r2', float('nan')):.3f}")

Run the frontend in a second terminal:

streamlit run frontend/streamlit_app.py

Open the URL Streamlit prints, normally http://localhost:8501.

9. End-to-end request lifecycle

  1. User clicks Predict in Streamlit
  2. Streamlit creates JSON from the form
  3. requests.post(…) sends JSON to Flask
  4. Flask validates columns
  5. The already-loaded pipeline imputes + encodes the row
  6. XGBoost predicts next-hour kWh
  7. Flask returns JSON
  8. Streamlit turns the response into a metric and chart

10. Put Part I on GitHub

GitHub stores and versions our source code. It is not yet running our Flask server.

git init
git add .
git commit -m "Part I: XGBoost energy prediction app"
git branch -M main
git remote add origin https://github.com/YOUR-USERNAME/energy-demand-deployment.git
git push -u origin main

Do not commit passwords, cloud service-account keys, .env secrets, or private credentials. Add them to .gitignore and use the hosting platform’s secret manager/environment variables.

11. What Part I accomplished

You now have four independently understandable assets:

  1. data/building_energy.csv — simulated teaching data.
  2. artifacts/energy_xgb_pipeline.joblib — saved fitted preprocessing + XGBoost model.
  3. backend/app.py — loads that model and exposes HTTP inference.
  4. frontend/streamlit_app.py — calls the backend and provides the human interface.
But localhost is not deployment. If you close your laptop, nobody else can use the service. Part II packages both applications into Docker containers and publishes them to scalable cloud infrastructure.

Part II → Docker, GitHub & Google Cloud Run


Series navigation

Part I: Data → EDA → XGBoost → Flask → Streamlit
Part II: Docker → GitHub → Google Cloud Run
Part III: Hosting alternatives → decision framework → practical deployment choices

Back to top