RetailFlow — Analytics, Predictions and Dashboards¶

An end-to-end walk through the Gold layer of the RetailFlow pipeline: the executive KPIs, one section per dashboard visual, the signal checks that justify a predictive layer, the models themselves scored against explicit baselines, and the three front-ends that serve it.

Live dashboard https://chidex-coder.github.io/Retail-Data-Engineering-Pipeline
Source https://github.com/chidex-coder/Retail-Data-Engineering-Pipeline
Data data/gold/analytics_facts.parquet — the cleaned, joined fact rows the dashboards read

The pipeline is Bronze → Silver → Gold on PySpark; this notebook picks up where Gold ends. Every figure uses the same colour tokens and chart geometry as the dashboards, read from the same files, so nothing here is a re-interpretation of the data.

In [1]:
import json, os, sys
import numpy as np
import pandas as pd
import plotly.graph_objects as go
import plotly.io as pio
from IPython.display import display, Markdown, HTML, IFrame

# Locate the repo root whether this runs from notebooks/ or the root.
ROOT = os.getcwd()
while not os.path.exists(os.path.join(ROOT, "data", "gold")) and os.path.dirname(ROOT) != ROOT:
    ROOT = os.path.dirname(ROOT)
sys.path.insert(0, ROOT)

# Static PNG output: renders on GitHub, in nbviewer and in the HTML export.
pio.renderers.default = "png"
pio.defaults.default_width = 900
pio.defaults.default_height = 380
pio.defaults.default_scale = 2

# Colours come straight from the dashboard stylesheet; geometry from the shared theme.
from app.theme import PALETTES, CHART
P = PALETTES["light"]
BAR = CHART["bar"]

pd.set_option("display.float_format", lambda v: f"{v:,.2f}")
print("repo root located:", os.path.basename(ROOT))
repo root located: retail-data-engineering
In [2]:
df = pd.read_parquet(os.path.join(ROOT, "data", "gold", "analytics_facts.parquet"))
for col in df.select_dtypes("category").columns:
    df[col] = df[col].astype("object")
df["Year"] = df["Year"].astype("Int64")
df["Month"] = df["Month"].astype("Int64")
df["OrderDate"] = pd.to_datetime(dict(year=df.Year.fillna(1970).astype(int),
                                      month=df.Month.fillna(1).astype(int), day=1))
df.loc[df.Year.isna(), "OrderDate"] = pd.NaT

print(f"{len(df):,} order lines · {df.CustomerKey.nunique():,} customers · "
      f"{df.ProductKey.nunique():,} products · {df.OrderDate.min():%b %Y} → {df.OrderDate.max():%b %Y}")
df.head()
143,723 order lines · 4,100 customers · 1,190 products · Aug 2023 → Aug 2026
Out[2]:
Year Month Category Brand PaymentMethod Gender Status City CustomerName CustomerKey ProductKey Revenue Profit Quantity OrderDate
0 2026 2 Sports AmazonBasics NaN Male Delivered London Keith Mccall 3808 473 386.60 120.60 2 2026-02-01
1 2025 9 Home & Garden AmazonBasics NaN Female Cancelled London Robert Anderson 4108 566 437.28 141.78 2 2025-09-01
2 2025 4 Toys IKEA Apple Pay Male Returned Nottingham Jennifer Williams 4678 517 216.76 80.88 4 2025-04-01
3 2026 4 Sports Adidas PayPal Female Delivered Birmingham Briana Kent 4086 782 268.60 81.82 2 2026-04-01
4 2025 2 Sports Adidas PayPal Male Delivered London Shannon Patel 1447 1018 342.68 131.16 2 2025-02-01

Chart helpers¶

Two builders mirror the dashboard's horizontal bar and time-series charts: values are written on the marks, gridlines stay recessive, and each chart shows one series so no legend is needed.

In [3]:
def money(v):
    a = abs(v)
    if a >= 1e6: return f"£{v/1e6:.1f}M"
    if a >= 1e3: return f"£{v/1e3:.1f}K"
    return f"£{v:,.0f}"

def base_layout(**extra):
    layout = dict(
        paper_bgcolor="white", plot_bgcolor="white",
        font=dict(family=CHART["font"]["family"], size=CHART["font"]["size"], color=P["--text-secondary"]),
        margin=dict(l=8, r=16, t=40, b=28), showlegend=False,
    )
    layout.update(extra)
    return layout

def hbar(series, colour, title, fmt=money, top=None):
    """Largest at the top, value labelled on each bar, x-axis hidden."""
    s = series.sort_values(ascending=False)
    if top: s = s.head(top)
    s = s[::-1]
    fig = go.Figure(go.Bar(
        x=s.values, y=[str(i) for i in s.index], orientation="h",
        marker=dict(color=colour, cornerradius=BAR["cornerRadius"]),
        text=[fmt(v) for v in s.values], textposition="outside", cliponaxis=False,
        textfont=dict(color=P["--text-secondary"], size=CHART["font"]["labelSize"]),
    ))
    fig.update_layout(base_layout(
        title=dict(text=title, font=dict(size=15, color=P["--text-primary"]), x=0),
        bargap=BAR["gap"], margin=dict(l=8, r=70, t=44, b=10),
        xaxis=dict(visible=False, range=[0, s.max() * BAR["headroom"]]),
        yaxis=dict(automargin=True, showgrid=False, tickfont=dict(color=P["--text-secondary"])),
        height=max(260, 34 * len(s) + 90),
    ))
    return fig

def timeseries(series, colour, title, prefix="£", kind="line"):
    x = series.index
    if kind == "line":
        trace = go.Scatter(x=x, y=series.values, mode="lines+markers",
                           line=dict(color=colour, width=CHART["line"]["width"]),
                           marker=dict(size=CHART["line"]["markerSize"], color=colour),
                           fill="tozeroy", fillcolor=colour.replace(")", ",0.12)").replace("rgb(", "rgba(")
                           if colour.startswith("rgb") else None)
    else:
        trace = go.Bar(x=x, y=series.values, marker=dict(color=colour, cornerradius=4))
    fig = go.Figure(trace)
    fig.update_layout(base_layout(
        title=dict(text=title, font=dict(size=15, color=P["--text-primary"]), x=0),
        xaxis=dict(type="date", tickformat="%b %y", showgrid=False, linecolor=P["--axis"]),
        yaxis=dict(showgrid=True, gridcolor=P["--grid"], tickprefix=prefix, tickformat="~s",
                   rangemode="tozero"),
    ))
    return fig

Executive KPIs¶

The eight numbers on the dashboard's KPI cards. Orders counts order lines, which is the grain of fact_sales; products sold is distinct SKUs that appear in at least one order line.

In [4]:
revenue, profit = df.Revenue.sum(), df.Profit.sum()
orders = len(df)
kpis = pd.DataFrame({
    "KPI": ["Total revenue", "Gross profit", "Orders", "Customers", "Products sold",
            "Avg order value", "Avg profit margin", "Cities served"],
    "Value": [money(revenue), money(profit), f"{orders:,}", f"{df.CustomerKey.nunique():,}",
              f"{df.ProductKey.nunique():,}", f"£{revenue/orders:,.2f}",
              f"{profit/revenue*100:.1f}%", f"{df.City.nunique():,}"],
})
kpis.set_index("KPI")
Out[4]:
Value
KPI
Total revenue £73.2M
Gross profit £21.0M
Orders 143,723
Customers 4,100
Products sold 1,190
Avg order value £509.45
Avg profit margin 28.6%
Cities served 40

Q1 · How has monthly revenue trended?¶

The first and last months are partial by construction (the window opens and closes mid-month), so they are dropped rather than left to read as a cliff.

In [5]:
monthly = df.dropna(subset=["OrderDate"]).groupby("OrderDate").agg(
    revenue=("Revenue", "sum"), profit=("Profit", "sum"), orders=("Revenue", "size")).iloc[1:-1]
display(monthly.tail(6).style.format({"revenue": "£{:,.0f}", "profit": "£{:,.0f}", "orders": "{:,}"}))
timeseries(monthly.revenue, P["--revenue"], "Monthly revenue")
  revenue profit orders
OrderDate      
2026-02-01 00:00:00 £1,860,889 £531,198 3,536
2026-03-01 00:00:00 £2,516,858 £720,150 4,935
2026-04-01 00:00:00 £2,836,737 £809,617 5,510
2026-05-01 00:00:00 £3,035,186 £869,730 5,873
2026-06-01 00:00:00 £2,828,410 £809,772 5,249
2026-07-01 00:00:00 £2,690,915 £763,749 5,012
No description has been provided for this image

Q2 · Which brands generate the most revenue?¶

Brands arrive dirty (<b>Sony</b>, IKEA📱💻, N/A); the Gold layer has already standardised them. Rows whose brand never resolved are excluded from this breakdown but still count in the totals above.

In [6]:
by_brand = df.dropna(subset=["Brand"]).groupby("Brand").agg(
    revenue=("Revenue", "sum"), profit=("Profit", "sum"), orders=("Revenue", "size"))
by_brand["margin_%"] = by_brand.profit / by_brand.revenue * 100
display(by_brand.sort_values("revenue", ascending=False)
        .style.format({"revenue": "£{:,.0f}", "profit": "£{:,.0f}", "orders": "{:,}", "margin_%": "{:.1f}"}))
hbar(by_brand.revenue, P["--revenue"], "Revenue by brand")
  revenue profit orders margin_%
Brand        
AmazonBasics £25,494,211 £7,103,405 49,260 27.9
Samsung £14,731,238 £3,300,857 17,473 22.4
Nike £9,184,786 £3,758,838 26,795 40.9
Sony £8,036,455 £1,526,781 8,097 19.0
IKEA £6,894,618 £2,063,996 20,995 29.9
Adidas £5,957,057 £2,327,895 14,916 39.1
No description has been provided for this image

Q3 · How does revenue split across categories?¶

Category also decides margin: Books are thin, Clothing is fat. Both columns are shown because the revenue ranking and the profit ranking are not the same.

In [7]:
by_cat = df.dropna(subset=["Category"]).groupby("Category").agg(
    revenue=("Revenue", "sum"), profit=("Profit", "sum"), orders=("Revenue", "size"))
by_cat["margin_%"] = by_cat.profit / by_cat.revenue * 100
display(by_cat.sort_values("revenue", ascending=False)
        .style.format({"revenue": "£{:,.0f}", "profit": "£{:,.0f}", "orders": "{:,}", "margin_%": "{:.1f}"}))
hbar(by_cat.revenue, P["--revenue"], "Revenue by category")
  revenue profit orders margin_%
Category        
Electronics £29,029,322 £5,410,272 27,542 18.6
Home & Garden £16,852,065 £5,050,687 26,992 30.0
Sports £10,823,704 £3,603,469 23,128 33.3
Clothing £10,167,075 £4,840,098 28,962 47.6
Toys £4,782,385 £1,811,559 19,343 37.9
Books £1,564,656 £258,463 17,756 16.5
No description has been provided for this image

Q4 · Which cities drive revenue?¶

Customers are placed in real UK cities with population-weighted volume, so this is a genuine ranking rather than a list of thousands of one-order towns.

In [8]:
by_city = df.dropna(subset=["City"]).groupby("City").Revenue.sum()
share = (by_city.sort_values(ascending=False).head(10) / by_city.sum() * 100).round(1)
display(pd.DataFrame({"revenue": by_city.loc[share.index], "share_%": share})
        .style.format({"revenue": "£{:,.0f}", "share_%": "{:.1f}"}))
hbar(by_city, P["--revenue"], "Top 10 cities by revenue", top=10)
  revenue share_%
City    
London £52,910,242 72.3
Birmingham £6,538,608 8.9
Manchester £1,623,822 2.2
Glasgow £1,234,804 1.7
Liverpool £1,091,887 1.5
Bristol £990,969 1.4
Leeds £980,006 1.3
Sheffield £835,054 1.1
Edinburgh £730,926 1.0
Nottingham £720,383 1.0
No description has been provided for this image

Q5 · How does revenue split by customer gender?¶

In [9]:
by_gender = df.dropna(subset=["Gender"]).groupby("Gender").agg(
    revenue=("Revenue", "sum"), customers=("CustomerKey", "nunique"), orders=("Revenue", "size"))
by_gender["revenue_per_customer"] = by_gender.revenue / by_gender.customers
display(by_gender.style.format({"revenue": "£{:,.0f}", "customers": "{:,}", "orders": "{:,}",
                                "revenue_per_customer": "£{:,.0f}"}))
hbar(by_gender.revenue, P["--revenue"], "Revenue by gender")
  revenue customers orders revenue_per_customer
Gender        
Female £35,058,192 1,857 67,282 £18,879
Male £31,742,620 1,811 62,541 £17,528
Other £2,770,772 167 5,672 £16,591
No description has been provided for this image

Q6 · Which payment methods carry the most revenue?¶

Payment method is missing on a share of rows — a data-quality artefact the generator injects on purpose. Those rows count toward total revenue and drop out of this chart only.

In [10]:
by_pay = df.dropna(subset=["PaymentMethod"]).groupby("PaymentMethod").agg(
    revenue=("Revenue", "sum"), orders=("Revenue", "size"))
by_pay["avg_order_value"] = by_pay.revenue / by_pay.orders
display(by_pay.sort_values("revenue", ascending=False)
        .style.format({"revenue": "£{:,.0f}", "orders": "{:,}", "avg_order_value": "£{:,.2f}"}))
hbar(by_pay.revenue, P["--revenue"], "Revenue by payment method")
  revenue orders avg_order_value
PaymentMethod      
Credit Card £27,394,335 52,151 £525.29
Bank Transfer £19,307,008 33,703 £572.86
PayPal £14,353,065 30,670 £467.98
Apple Pay £6,260,551 15,644 £400.19
No description has been provided for this image

Q7 · How do orders end up?¶

A three-year window is mostly terminal states: almost everything has been delivered, returned or cancelled. Pending and Shipped are the last few days of orders still in flight. The colours are the reserved status palette — Delivered green, Cancelled red — and every bar is labelled so colour never carries meaning on its own.

In [11]:
status_colours = {"Delivered": P["--status-good"], "Shipped": P["--status-neutral"],
                  "Pending": P["--status-warning"], "Returned": P["--status-serious"],
                  "Cancelled": P["--status-critical"]}
by_status = df.Status.value_counts()
share = (by_status / by_status.sum() * 100).round(1)
display(pd.DataFrame({"orders": by_status, "share_%": share}).style.format({"orders": "{:,}", "share_%": "{:.1f}"}))
s = by_status[::-1]
fig = go.Figure(go.Bar(x=s.values, y=s.index, orientation="h",
                       marker=dict(color=[status_colours[k] for k in s.index], cornerradius=4),
                       text=[f"{v:,}" for v in s.values], textposition="outside", cliponaxis=False))
fig.update_layout(base_layout(title=dict(text="Order status", font=dict(size=15, color=P["--text-primary"]), x=0),
                              bargap=0.38, margin=dict(l=8, r=70, t=44, b=10),
                              xaxis=dict(visible=False, range=[0, s.max() * 1.16]), yaxis=dict(automargin=True)))
fig
  orders share_%
Status    
Delivered 101,500 70.6
Returned 22,642 15.8
Cancelled 18,525 12.9
Shipped 708 0.5
Pending 348 0.2
No description has been provided for this image

Q8 · Who are the top customers?¶

Ranked by surrogate key, then labelled by name, so two customers who share a name stay distinct.

In [12]:
by_cust = (df.dropna(subset=["CustomerName"]).groupby(["CustomerKey", "CustomerName"])
             .agg(revenue=("Revenue", "sum"), orders=("Revenue", "size"))
             .sort_values("revenue", ascending=False).head(10).reset_index(level=0, drop=True))
display(by_cust.style.format({"revenue": "£{:,.0f}", "orders": "{:,}"}))
hbar(by_cust.revenue, P["--revenue"], "Top 10 customers by revenue")
  revenue orders
CustomerName    
Amanda Mcdonald £212,146 262
Holly Mitchell £147,714 199
Jeremy Gibson £145,741 201
Ryan Mann £145,456 198
Odonnell £144,867 163
Stephen Garcia £133,742 164
Rachel Morrow £131,477 174
Scott Frazier £130,743 163
Erin Powers £130,268 149
Colleen Mason £129,898 169
No description has been provided for this image

Q9 · How does order volume vary month to month?¶

Order count rather than revenue — it separates "more orders" from "bigger orders".

In [13]:
timeseries(monthly.orders, P["--orders"], "Monthly orders", prefix="", kind="bar")
No description has been provided for this image

Is there anything to predict? Three signal checks¶

A model is only worth training if the data carries structure. These three checks are the same ones tests/test_ml.py enforces — if the synthetic generator were ever flattened back to independent uniform draws, they would fail before any model did.

Q10 · Does return rate depend on category?¶

In [14]:
ret = df.dropna(subset=["Category"]).assign(returned=df.Status.eq("Returned")).groupby("Category").returned.mean() * 100
display(ret.sort_values(ascending=False).to_frame("return_rate_%").style.format("{:.1f}"))
hbar(ret, P["--status-serious"], "Return rate by category (%)", fmt=lambda v: f"{v:.1f}%")
  return_rate_%
Category  
Clothing 25.7
Electronics 17.6
Sports 17.2
Home & Garden 13.4
Toys 9.8
Books 4.8
No description has been provided for this image

Q11 · Does cancellation rate depend on payment method?¶

In [15]:
can = (df.dropna(subset=["PaymentMethod"]).assign(cancelled=df.Status.eq("Cancelled"))
         .groupby("PaymentMethod").cancelled.mean() * 100)
display(can.sort_values(ascending=False).to_frame("cancel_rate_%").style.format("{:.1f}"))
hbar(can, P["--status-critical"], "Cancellation rate by payment method (%)", fmt=lambda v: f"{v:.1f}%")
  cancel_rate_%
PaymentMethod  
Bank Transfer 19.5
Credit Card 11.8
PayPal 10.2
Apple Pay 7.5
No description has been provided for this image

Q12 · Is demand seasonal?¶

In [16]:
idx = df.dropna(subset=["Month"]).groupby("Month").Revenue.sum()
idx = (idx / idx.mean()).rename(index=lambda m: pd.Timestamp(2000, int(m), 1).strftime("%b"))
display(idx.to_frame("seasonal_index").T.style.format("{:.2f}"))
fig = go.Figure(go.Bar(x=idx.index, y=idx.values,
                       marker=dict(color=[P["--revenue"] if v >= 1 else P["--text-muted"] for v in idx.values], cornerradius=4),
                       text=[f"{v:.2f}" for v in idx.values], textposition="outside", cliponaxis=False))
fig.add_hline(y=1.0, line=dict(color=P["--axis"], dash="dot"))
fig.update_layout(base_layout(title=dict(text="Seasonal index (1.0 = average month)", font=dict(size=15, color=P["--text-primary"]), x=0),
                              xaxis=dict(showgrid=False), yaxis=dict(showgrid=True, gridcolor=P["--grid"], rangemode="tozero")))
fig
Month Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec
seasonal_index 0.73 0.71 0.95 1.01 1.10 1.03 1.00 0.95 0.88 0.97 1.23 1.43
No description has been provided for this image

Predictive analytics¶

Three supervised models and a segmentation, trained by exactly the code in ml/train.py. Every model is scored against an explicit baseline and the baseline is shown next to the result — a model that only matches its baseline is a failure, not a pass.

What stops these being fits to noise:

  • customer-history features use expanding windows with a shift(), so an order's own outcome never informs its own features;
  • the fulfilment split is chronological, not random;
  • the repeat-purchase label is measured strictly after the feature cutoff;
  • the forecast is backtested rolling-origin, with regularisation tuned inside each fold.
In [17]:
from ml import features as F
from ml import train as T

orders_ml = F.load_orders(os.path.join(ROOT, "data", "gold", "analytics_facts.parquet"))
print(f"{len(orders_ml):,} dated order lines available to the models")
136,678 dated order lines available to the models

Model 1 · Fulfilment risk — will this order be returned or cancelled?¶

In [18]:
risk_model, risk_metrics, risk_by_cat, risk_by_pay = T.train_fulfilment_risk(orders_ml)
pd.DataFrame({
    "metric": ["ROC AUC", "PR AUC", "Brier score"],
    "model": [risk_metrics["roc_auc"], risk_metrics["pr_auc"], risk_metrics["brier"]],
    "baseline": [risk_metrics["baseline_roc_auc"], risk_metrics["baseline_pr_auc"], risk_metrics["baseline_brier"]],
    "note": ["chance", "base rate", "constant prediction"],
}).set_index("metric").style.format({"model": "{:.3f}", "baseline": "{:.3f}"})
Out[18]:
  model baseline note
metric      
ROC AUC 0.634 0.500 chance
PR AUC 0.391 0.289 base rate
Brier score 0.196 0.205 constant prediction
In [19]:
display(risk_by_cat.set_index("Category")
        .style.format({"predicted_risk": "{:.1%}", "actual_risk": "{:.1%}", "orders": "{:,}", "revenue_at_risk": "£{:,.0f}"}))
r = risk_by_cat.iloc[::-1]
fig = go.Figure([
    go.Bar(name="Predicted", x=r.predicted_risk * 100, y=r.Category, orientation="h",
           marker=dict(color=P["--cost"], cornerradius=4)),
    go.Bar(name="Actual", x=r.actual_risk * 100, y=r.Category, orientation="h",
           marker=dict(color=P["--status-neutral"], cornerradius=4)),
])
fig.update_layout(base_layout(barmode="group", showlegend=True, legend=dict(orientation="h", y=1.1, x=0),
                              title=dict(text="Predicted vs actual fulfilment risk by category (held-out orders)",
                                         font=dict(size=15, color=P["--text-primary"]), x=0),
                              xaxis=dict(ticksuffix="%", showgrid=True, gridcolor=P["--grid"]),
                              yaxis=dict(automargin=True)))
fig
  predicted_risk actual_risk orders revenue_at_risk
Category        
Clothing 36.1% 38.5% 5,488 £1,978,483
Electronics 34.1% 34.2% 5,418 £5,874,320
Sports 29.7% 30.2% 4,402 £2,117,413
Home & Garden 27.9% 27.7% 5,065 £3,212,485
Toys 21.2% 20.6% 3,709 £954,634
Books 14.0% 13.2% 3,254 £295,037
No description has been provided for this image

Model 2 · Repeat purchase — will this customer order again within 90 days?¶

The honest baseline is a recency-only rule: "recent buyers come back" is what any analyst would say without a model, so the model has to beat it.

In [20]:
repeat_model, repeat_metrics, customer_scores = T.train_repeat_purchase(orders_ml)
display(pd.DataFrame({
    "metric": ["ROC AUC", "PR AUC"],
    "model": [repeat_metrics["roc_auc"], repeat_metrics["pr_auc"]],
    "baseline": [repeat_metrics["recency_only_roc_auc"], repeat_metrics["baseline_pr_auc"]],
    "note": ["recency-only rule", "positive rate"],
}).set_index("metric").style.format({"model": "{:.3f}", "baseline": "{:.3f}"}))

print(f"cutoff {repeat_metrics['cutoff']} · {repeat_metrics['positive_rate']:.1%} of customers returned")
display(Markdown("**Highest churn risk** — customers the model expects not to return:"))
customer_scores.sort_values("churn_risk", ascending=False).head(10).style.format(
    {"churn_risk": "{:.1%}", "recency_days": "{:.0f}", "frequency": "{:.0f}", "monetary": "£{:,.0f}"})
  model baseline note
metric      
ROC AUC 0.965 0.947 recency-only rule
PR AUC 0.953 0.473 positive rate
cutoff 2026-05-03 · 46.5% of customers returned

Highest churn risk — customers the model expects not to return:

Out[20]:
  CustomerKey recency_days tenure_days frequency monetary avg_value avg_quantity risk_rate orders_per_month will_return churn_risk
844 1034 640 1006 122 £94,326 773.161066 3.885246 0.295082 3.638171 0 100.0%
3473 4234 640 1006 122 £94,311 773.041721 3.885246 0.286885 3.638171 0 100.0%
2270 2780 884 1006 6 £987 164.545000 2.333333 0.166667 0.178926 0 100.0%
1042 1285 487 579 121 £108,619 897.677769 3.537190 0.297521 6.269430 0 100.0%
1810 2226 428 975 133 £107,118 805.400451 3.766917 0.308271 4.092308 0 100.0%
558 696 671 1006 117 £82,657 706.470513 3.529915 0.316239 3.489066 0 100.0%
74 93 518 1006 115 £87,438 760.327304 3.469565 0.269565 3.429423 0 100.0%
795 979 609 671 116 £99,507 857.821293 3.853448 0.267241 5.186289 0 100.0%
1214 1490 914 1006 137 £111,849 816.419489 3.883212 0.299270 4.085487 0 100.0%
1564 1905 609 1006 139 £111,857 804.725899 3.755396 0.352518 4.145129 0 100.0%
In [21]:
fig = go.Figure(go.Histogram(x=customer_scores.churn_risk, nbinsx=40,
                             marker=dict(color=P["--orders"], line=dict(width=1, color="white"))))
fig.update_layout(base_layout(title=dict(text="Distribution of churn risk across customers", font=dict(size=15, color=P["--text-primary"]), x=0),
                              xaxis=dict(tickformat=".0%", showgrid=False), yaxis=dict(showgrid=True, gridcolor=P["--grid"]),
                              bargap=0.05))
fig
No description has been provided for this image

Model 3 · Revenue forecast — the next three months¶

Ridge on a linear trend plus month dummies, backtested rolling-origin. The interval width comes from realised backtest error, not from the fit. Two baselines: last month carried forward, and the same month last year.

In [22]:
forecast_model, fc_metrics, fc = T.train_forecast(orders_ml)
display(pd.DataFrame({
    "MAPE": [fc_metrics["mape"], fc_metrics["naive_mape"], fc_metrics["seasonal_naive_mape"]],
}, index=["model", "naive", "seasonal naive"]).style.format("{:.1%}"))

future = fc[fc.forecast.notna()][["date", "forecast", "lower", "upper"]].set_index("date")
display(Markdown(f"**Forecast** — next {len(future)} months, total **{money(future.forecast.sum())}**:"))
future.style.format("£{:,.0f}")
  MAPE
model 12.9%
naive 20.9%
seasonal naive 35.1%

Forecast — next 3 months, total £8.3M:

Out[22]:
  forecast lower upper
date      
2026-08-01 00:00:00 £2,452,847 £1,831,959 £3,073,735
2026-09-01 00:00:00 £2,845,406 £2,125,150 £3,565,663
2026-10-01 00:00:00 £3,031,921 £2,264,452 £3,799,391
In [23]:
hist, fut = fc[fc.actual.notna()], fc[fc.forecast.notna()]
bridge = hist.tail(1)
band = pd.concat([bridge, fut])
fig = go.Figure([
    go.Scatter(x=list(band.date) + list(band.date[::-1]),
               y=list(band.upper.fillna(band.actual)) + list(band.lower.fillna(band.actual)[::-1]),
               fill="toself", fillcolor="rgba(41,98,255,0.14)", line=dict(width=0), hoverinfo="skip", showlegend=False),
    go.Scatter(name="Actual", x=hist.date, y=hist.actual, mode="lines+markers",
               line=dict(color=P["--revenue"], width=2), marker=dict(size=6)),
    go.Scatter(name="Forecast", x=pd.concat([bridge.date, fut.date]), y=pd.concat([bridge.actual, fut.forecast]),
               mode="lines+markers", line=dict(color=P["--profit"], width=2, dash="dot"), marker=dict(size=7)),
])
fig.update_layout(base_layout(showlegend=True, legend=dict(orientation="h", y=1.1, x=0),
                              title=dict(text="Monthly revenue with 3-month forecast and backtested interval",
                                         font=dict(size=15, color=P["--text-primary"]), x=0),
                              xaxis=dict(type="date", tickformat="%b %y", showgrid=False),
                              yaxis=dict(showgrid=True, gridcolor=P["--grid"], tickprefix="£", tickformat="~s", rangemode="tozero")))
fig
No description has been provided for this image

Model 4 · Customer segmentation — K-means on recency, frequency, monetary¶

Unsupervised, so there is no baseline to beat; the check is whether the clusters are distinct and ranked sensibly by value.

In [24]:
seg_model, seg_metrics, profile, seg_rows = T.train_segments(orders_ml)
display(profile.set_index("segment")[["customers", "recency_days", "frequency", "monetary"]]
        .style.format({"customers": "{:,}", "recency_days": "{:.0f}", "frequency": "{:.1f}", "monetary": "£{:,.0f}"}))
colours = [P["--revenue"], P["--profit"], P["--quantity"], P["--orders"]]
fig = go.Figure(go.Bar(x=profile.segment, y=profile.monetary,
                       marker=dict(color=colours[:len(profile)], cornerradius=4),
                       text=[f"{c:,} cust" for c in profile.customers], textposition="outside", cliponaxis=False))
fig.update_layout(base_layout(title=dict(text="Average spend per segment", font=dict(size=15, color=P["--text-primary"]), x=0),
                              xaxis=dict(showgrid=False), yaxis=dict(showgrid=True, gridcolor=P["--grid"], tickprefix="£", tickformat="~s"),
                              bargap=0.45))
fig
  customers recency_days frequency monetary
segment        
Champions 656 0 82.4 £55,325
Loyal 585 345 65.0 £37,146
Occasional 477 0 17.5 £4,686
At risk 2,382 416 15.2 £3,927
No description has been provided for this image

Scoreboard¶

Every model beside the number it had to beat. This is the same table the dashboards render from data/ml/metrics.json.

In [25]:
scoreboard = pd.DataFrame([
    ["Fulfilment risk", f"AUC {risk_metrics['roc_auc']:.3f}", "chance 0.500", risk_metrics["roc_auc"] > 0.55],
    ["Repeat purchase", f"AUC {repeat_metrics['roc_auc']:.3f}",
     f"recency rule {repeat_metrics['recency_only_roc_auc']:.3f}", repeat_metrics["roc_auc"] > repeat_metrics["recency_only_roc_auc"]],
    ["Revenue forecast", f"MAPE {fc_metrics['mape']:.1%}",
     f"naive {fc_metrics['naive_mape']:.1%} · seasonal {fc_metrics['seasonal_naive_mape']:.1%}",
     fc_metrics["mape"] < min(fc_metrics["naive_mape"], fc_metrics["seasonal_naive_mape"])],
    ["Segmentation", f"{seg_metrics['k']} segments", "unsupervised", True],
], columns=["model", "result", "baseline", "beats baseline"]).set_index("model")
scoreboard
Out[25]:
result baseline beats baseline
model
Fulfilment risk AUC 0.634 chance 0.500 True
Repeat purchase AUC 0.965 recency rule 0.947 True
Revenue forecast MAPE 12.9% naive 20.9% · seasonal 35.1% True
Segmentation 4 segments unsupervised True

The dashboards¶

Everything above is served three ways from the same Gold data, the same stylesheet and the same chart geometry.

Front-end Where How it computes
Static report https://chidex-coder.github.io/Retail-Data-Engineering-Pipeline the fact rows are embedded; every filter re-aggregates in the browser (~70 ms)
Dash app python run_dash.py · Fly.io pandas on the server per callback (~13 ms)
Streamlit app streamlit run streamlit_app.py pandas, cached per filter set

All three carry a Notebook tab that opens this notebook. The predictive section on each is rendered from the artefacts ml/train.py writes to data/ml/.

In [26]:
display(HTML('<img src="https://github.com/chidex-coder/Retail-Data-Engineering-Pipeline/raw/main/dashboards/preview-light.png" '
             'alt="RetailFlow executive dashboard" style="max-width:100%;border:1px solid #ddd;border-radius:10px">'))
RetailFlow executive dashboard
In [27]:
IFrame("https://chidex-coder.github.io/Retail-Data-Engineering-Pipeline/", width="100%", height=900)
Out[27]:

Regenerate this notebook with python notebooks/build_notebook.py after re-running the pipeline; it executes end to end and exports docs/notebook.html for GitHub Pages.