1. 项目背景与目标
数据分析项目从"能跑通"到"能交付",中间隔着一整套工程化工作:可复现的源码结构、可追溯的数据存储、可执行的部署文档。本文以一个电商用户行为分析平台为例,给出可直接落地的完整方案,包含:
- 完整源码目录结构与核心代码
- 数据库文件(SQLite 单文件 + MySQL 建表脚本)
- 部署文档(本地开发 / Docker / 生产环境)
项目目标:把原始订单与行为日志,加工成用户分层、商品关联、留存趋势三类分析结果,并通过 Web 看板对外提供。
2. 技术选型
| 层次 | 选型 | 说明 |
|---|---|---|
| 数据处理 | pandas、numpy | 清洗、聚合、特征计算 |
| 数据库 | SQLite(开发)/ MySQL 8(生产) | 单文件便于交付,生产可平滑迁移 |
| 可视化 | Plotly + Streamlit | 零前端成本,快速出看板 |
| 调度 | APScheduler | 定时跑批 |
| 部署 | Docker Compose | 一键起服务 |
| 测试 | pytest | 保证指标口径不漂移 |
3. 项目目录结构
text
ecommerce-analytics/
├── app/
│ ├── __init__.py
│ ├── main.py # Streamlit 看板入口
│ ├── pages/
│ │ ├── 1_用户分层.py
│ │ ├── 2_商品关联.py
│ │ └── 3_留存趋势.py
│ └── charts.py # 图表封装
├── src/
│ ├── __init__.py
│ ├── config.py # 配置读取
│ ├── db.py # 数据库连接与建表
│ ├── etl.py # 数据清洗与入库
│ ├── metrics.py # 指标计算(RFM、关联规则、留存)
│ └── scheduler.py # 定时任务
├── data/
│ ├── raw/ # 原始 CSV
│ └── analytics.db # SQLite 数据库文件
├── sql/
│ ├── schema.sql # 建表语句
│ └── seed.sql # 示例数据
├── tests/
│ └── test_metrics.py
├── scripts/
│ ├── init_db.py
│ └── run_etl.py
├── requirements.txt
├── Dockerfile
├── docker-compose.yml
├── .env.example
└── README.md
3.1 数据流转架构
下图展示从原始 CSV 到看板呈现的完整数据链路,以及定时调度在其中的触发位置:
#mermaid-svg-JOKBeOW73IyTL1OI{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-JOKBeOW73IyTL1OI .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-JOKBeOW73IyTL1OI .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-JOKBeOW73IyTL1OI .error-icon{fill:#552222;}#mermaid-svg-JOKBeOW73IyTL1OI .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-JOKBeOW73IyTL1OI .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-JOKBeOW73IyTL1OI .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-JOKBeOW73IyTL1OI .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-JOKBeOW73IyTL1OI .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-JOKBeOW73IyTL1OI .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-JOKBeOW73IyTL1OI .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-JOKBeOW73IyTL1OI .marker{fill:#333333;stroke:#333333;}#mermaid-svg-JOKBeOW73IyTL1OI .marker.cross{stroke:#333333;}#mermaid-svg-JOKBeOW73IyTL1OI svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-JOKBeOW73IyTL1OI p{margin:0;}#mermaid-svg-JOKBeOW73IyTL1OI .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-JOKBeOW73IyTL1OI .cluster-label text{fill:#333;}#mermaid-svg-JOKBeOW73IyTL1OI .cluster-label span{color:#333;}#mermaid-svg-JOKBeOW73IyTL1OI .cluster-label span p{background-color:transparent;}#mermaid-svg-JOKBeOW73IyTL1OI .label text,#mermaid-svg-JOKBeOW73IyTL1OI span{fill:#333;color:#333;}#mermaid-svg-JOKBeOW73IyTL1OI .node rect,#mermaid-svg-JOKBeOW73IyTL1OI .node circle,#mermaid-svg-JOKBeOW73IyTL1OI .node ellipse,#mermaid-svg-JOKBeOW73IyTL1OI .node polygon,#mermaid-svg-JOKBeOW73IyTL1OI .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-JOKBeOW73IyTL1OI .rough-node .label text,#mermaid-svg-JOKBeOW73IyTL1OI .node .label text,#mermaid-svg-JOKBeOW73IyTL1OI .image-shape .label,#mermaid-svg-JOKBeOW73IyTL1OI .icon-shape .label{text-anchor:middle;}#mermaid-svg-JOKBeOW73IyTL1OI .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-JOKBeOW73IyTL1OI .rough-node .label,#mermaid-svg-JOKBeOW73IyTL1OI .node .label,#mermaid-svg-JOKBeOW73IyTL1OI .image-shape .label,#mermaid-svg-JOKBeOW73IyTL1OI .icon-shape .label{text-align:center;}#mermaid-svg-JOKBeOW73IyTL1OI .node.clickable{cursor:pointer;}#mermaid-svg-JOKBeOW73IyTL1OI .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-JOKBeOW73IyTL1OI .arrowheadPath{fill:#333333;}#mermaid-svg-JOKBeOW73IyTL1OI .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-JOKBeOW73IyTL1OI .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-JOKBeOW73IyTL1OI .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-JOKBeOW73IyTL1OI .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-JOKBeOW73IyTL1OI .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-JOKBeOW73IyTL1OI .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-JOKBeOW73IyTL1OI .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-JOKBeOW73IyTL1OI .cluster text{fill:#333;}#mermaid-svg-JOKBeOW73IyTL1OI .cluster span{color:#333;}#mermaid-svg-JOKBeOW73IyTL1OI div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-JOKBeOW73IyTL1OI .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-JOKBeOW73IyTL1OI rect.text{fill:none;stroke-width:0;}#mermaid-svg-JOKBeOW73IyTL1OI .icon-shape,#mermaid-svg-JOKBeOW73IyTL1OI .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-JOKBeOW73IyTL1OI .icon-shape p,#mermaid-svg-JOKBeOW73IyTL1OI .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-JOKBeOW73IyTL1OI .icon-shape .label rect,#mermaid-svg-JOKBeOW73IyTL1OI .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-JOKBeOW73IyTL1OI .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-JOKBeOW73IyTL1OI .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-JOKBeOW73IyTL1OI :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 02:00 触发
02:30 触发
读取明细
只读查询
原始 CSV
data/raw/orders.csv、events.csv
ETL 清洗
src/etl.py
数据库
SQLite(开发)/ MySQL 8(生产)
指标计算
src/metrics.py
RFM 用户分层
user_segments
商品关联规则
item_rules
留存趋势
cohort 计算
Streamlit 看板
app/main.py + pages/
定时调度
src/scheduler.py
各环节职责与失败重试点如下:
- ETL 清洗 :读取
data/raw/下的 CSV,做去空、去重、金额取整与行为类型过滤,再整表覆盖写入orders、events。该步骤幂等,失败后直接重跑run_etl()即可,不会产生重复数据。 - 数据库 :开发期用 SQLite 单文件便于交付,生产切 MySQL 8;
schema.sql只需把AUTOINCREMENT改为AUTO_INCREMENT。连接失败时优先检查DB_PATH与文件权限。 - 指标计算 :从明细表读取数据,产出
user_segments、item_rules与留存结果,结果表每次整表重建,因此重跑refresh_all()是安全的恢复手段。 - 看板与调度:Streamlit 只做只读查询,不写库;APScheduler 在 02:00 跑 ETL、02:30 跑指标,若某次跑批失败,可在次日窗口前手动补跑对应脚本,看板无需重启。
4. 数据库设计
4.1 建表脚本 sql/schema.sql
sql
-- 订单明细表
CREATE TABLE IF NOT EXISTS orders (
order_id VARCHAR(32) PRIMARY KEY,
user_id VARCHAR(32) NOT NULL,
sku_id VARCHAR(32) NOT NULL,
quantity INTEGER NOT NULL DEFAULT 1,
amount DECIMAL(12, 2) NOT NULL,
order_time DATETIME NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'paid'
);
CREATE INDEX idx_orders_user_time ON orders (user_id, order_time);
-- 用户行为日志表
CREATE TABLE IF NOT EXISTS events (
event_id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id VARCHAR(32) NOT NULL,
event_type VARCHAR(16) NOT NULL, -- view / cart / pay
sku_id VARCHAR(32),
event_time DATETIME NOT NULL
);
CREATE INDEX idx_events_time ON events (event_time);
-- 用户分层结果表
CREATE TABLE IF NOT EXISTS user_segments (
user_id VARCHAR(32) PRIMARY KEY,
recency INTEGER,
frequency INTEGER,
monetary DECIMAL(12, 2),
r_score INTEGER,
f_score INTEGER,
m_score INTEGER,
segment VARCHAR(32),
updated_at DATETIME
);
-- 商品关联规则表
CREATE TABLE IF NOT EXISTS item_rules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
antecedent VARCHAR(64),
consequent VARCHAR(64),
support DECIMAL(8, 4),
confidence DECIMAL(8, 4),
lift DECIMAL(8, 4)
);
说明:
AUTOINCREMENT为 SQLite 语法,迁移到 MySQL 时改为AUTO_INCREMENT,DATETIME与DECIMAL可直接沿用。
4.3 字段说明
为方便交付时对接方查阅,下面按表逐字段列出类型、可空性、含义与示例值。类型以 SQLite 实际存储类型为准,迁移 MySQL 时按括号内声明对应调整。
4.3.1 orders 订单明细表
| 字段名 | 类型 | 是否可空 | 含义 | 示例值 |
|---|---|---|---|---|
order_id |
VARCHAR(32) | 否(主键) | 订单唯一编号,ETL 阶段按此去重 | SO20240615000123 |
user_id |
VARCHAR(32) | 否 | 下单用户编号,与 events.user_id 同源 |
U100238 |
sku_id |
VARCHAR(32) | 否 | 商品 SKU 编号 | SKU-A1024 |
quantity |
INTEGER | 否(默认 1) | 购买件数,清洗时过滤 <= 0 的记录 |
2 |
amount |
DECIMAL(12, 2) | 否 | 该行订单金额(元),保留两位小数 | 199.00 |
order_time |
DATETIME | 否 | 下单时间,RFM 的 R/F 指标基于此字段 | 2024-06-15 10:23:41 |
status |
VARCHAR(16) | 否(默认 paid) |
订单状态,如 paid / refunded / closed |
paid |
4.3.2 events 用户行为日志表
| 字段名 | 类型 | 是否可空 | 含义 | 示例值 |
|---|---|---|---|---|
event_id |
INTEGER | 否(主键,自增) | 行为事件自增主键 | 100001 |
user_id |
VARCHAR(32) | 否 | 触发行为的用户编号 | U100238 |
event_type |
VARCHAR(16) | 否 | 行为类型,仅保留 view / cart / pay |
cart |
sku_id |
VARCHAR(32) | 是 | 关联商品编号,页面级行为可为空 | SKU-A1024 |
event_time |
DATETIME | 否 | 行为发生时间,留存计算按此取日期 | 2024-06-15 10:20:05 |
4.3.3 user_segments 用户分层结果表
| 字段名 | 类型 | 是否可空 | 含义 | 示例值 |
|---|---|---|---|---|
user_id |
VARCHAR(32) | 否(主键) | 用户编号,每个用户一行 | U100238 |
recency |
INTEGER | 是 | 距快照日最近一次下单的天数 | 3 |
frequency |
INTEGER | 是 | 统计周期内下单次数(按 order_time 去重计数) |
12 |
monetary |
DECIMAL(12, 2) | 是 | 统计周期内累计消费金额(元) | 2380.50 |
r_score |
INTEGER | 是 | R 指标五分位得分,1--5,越大越近 | 5 |
f_score |
INTEGER | 是 | F 指标五分位得分,1--5,越大越频繁 | 4 |
m_score |
INTEGER | 是 | M 指标五分位得分,1--5,越大金额越高 | 5 |
segment |
VARCHAR(32) | 是 | 分层标签,如 重要价值客户 / 流失客户 |
重要价值客户 |
updated_at |
DATETIME | 是 | 该行指标最近一次刷新时间 | 2024-06-16 02:30:00 |
4.3.4 item_rules 商品关联规则表
| 字段名 | 类型 | 是否可空 | 含义 | 示例值 |
|---|---|---|---|---|
id |
INTEGER | 否(主键,自增) | 规则自增主键 | 1 |
antecedent |
VARCHAR(64) | 是 | 规则前项(先购商品 SKU) | SKU-A1024 |
consequent |
VARCHAR(64) | 是 | 规则后项(连带购买商品 SKU) | SKU-B2048 |
support |
DECIMAL(8, 4) | 是 | 支持度,同时购买该组合的订单占比 | 0.0321 |
confidence |
DECIMAL(8, 4) | 是 | 置信度,买前项后买后项的条件概率 | 0.4125 |
lift |
DECIMAL(8, 4) | 是 | 提升度,大于 1 表示正相关,按此降序排序 | 2.1730 |
对接提示:
user_segments与item_rules为跑批结果表,每次refresh_all()会整表重建,对接方请勿直接写入;orders、events为原始明细表,由 ETL 覆盖写入。
4.2 初始化脚本 scripts/init_db.py
python
"""创建数据库文件并建表。"""
from pathlib import Path
import sqlite3
from src.config import settings
SCHEMA = Path(__file__).resolve().parent.parent / "sql" / "schema.sql"
def init_db() -> None:
Path(settings.db_path).parent.mkdir(parents=True, exist_ok=True)
with sqlite3.connect(settings.db_path) as conn:
conn.executescript(SCHEMA.read_text(encoding="utf-8"))
conn.commit()
print(f"数据库已初始化: {settings.db_path}")
if __name__ == "__main__":
init_db()
5. 核心源码
5.1 配置 src/config.py
python
"""集中配置,支持 .env 覆盖。"""
import os
from dataclasses import dataclass
from pathlib import Path
from dotenv import load_dotenv
load_dotenv()
BASE_DIR = Path(__file__).resolve().parent.parent
@dataclass(frozen=True)
class Settings:
db_path: str = os.getenv("DB_PATH", str(BASE_DIR / "data" / "analytics.db"))
raw_dir: str = os.getenv("RAW_DIR", str(BASE_DIR / "data" / "raw"))
batch_size: int = int(os.getenv("BATCH_SIZE", "5000"))
settings = Settings()
5.2 数据库连接 src/db.py
python
"""数据库连接工具。"""
from contextlib import contextmanager
import sqlite3
import pandas as pd
from src.config import settings
@contextmanager
def get_conn():
conn = sqlite3.connect(settings.db_path)
try:
yield conn
finally:
conn.close()
def read_df(sql: str, params: tuple = ()) -> pd.DataFrame:
with get_conn() as conn:
return pd.read_sql_query(sql, conn, params=params)
def write_df(df: pd.DataFrame, table: str, if_exists: str = "append") -> None:
with get_conn() as conn:
df.to_sql(table, conn, if_exists=if_exists, index=False)
5.3 数据清洗 src/etl.py
python
"""原始 CSV 清洗并写入数据库。"""
from pathlib import Path
import pandas as pd
from src.config import settings
from src.db import write_df
def load_orders() -> pd.DataFrame:
path = Path(settings.raw_dir) / "orders.csv"
df = pd.read_csv(path, parse_dates=["order_time"])
df = df.dropna(subset=["order_id", "user_id", "amount"])
df = df[df["quantity"] > 0]
df["amount"] = df["amount"].astype(float).round(2)
df = df.drop_duplicates(subset=["order_id"])
return df
def load_events() -> pd.DataFrame:
path = Path(settings.raw_dir) / "events.csv"
df = pd.read_csv(path, parse_dates=["event_time"])
df = df.dropna(subset=["user_id", "event_type"])
df = df[df["event_type"].isin(["view", "cart", "pay"])]
return df
def run_etl() -> None:
orders = load_orders()
events = load_events()
write_df(orders, "orders", if_exists="replace")
write_df(events, "events", if_exists="replace")
print(f"入库完成: orders={len(orders)}, events={len(events)}")
if __name__ == "__main__":
run_etl()
5.4 指标计算 src/metrics.py
python
"""RFM 分层、商品关联规则、留存率。"""
from itertools import combinations
import pandas as pd
from src.db import read_df, write_df
def compute_rfm(snapshot: str | None = None) -> pd.DataFrame:
orders = read_df("SELECT user_id, amount, order_time FROM orders")
orders["order_time"] = pd.to_datetime(orders["order_time"])
snapshot_date = pd.to_datetime(snapshot) if snapshot else orders["order_time"].max()
rfm = orders.groupby("user_id").agg(
recency=("order_time", lambda s: (snapshot_date - s.max()).days),
frequency=("order_time", "nunique"),
monetary=("amount", "sum"),
).reset_index()
for col, score in (("recency", "r_score"), ("frequency", "f_score"), ("monetary", "m_score")):
rfm[score] = pd.qcut(rfm[col].rank(method="first"), 5, labels=[1, 2, 3, 4, 5]).astype(int)
rfm["segment"] = rfm.apply(_label_segment, axis=1)
rfm["updated_at"] = pd.Timestamp.now()
return rfm
def _label_segment(row: pd.Series) -> str:
r, f, m = row["r_score"], row["f_score"], row["m_score"]
if r >= 4 and f >= 4 and m >= 4:
return "重要价值客户"
if r <= 2 and f >= 4:
return "重要挽留客户"
if r >= 4 and f <= 2:
return "新客户"
if r <= 2 and f <= 2:
return "流失客户"
return "一般客户"
def compute_item_rules(min_support: float = 0.01, min_confidence: float = 0.2) -> pd.DataFrame:
orders = read_df("SELECT order_id, sku_id FROM orders")
baskets = orders.groupby("order_id")["sku_id"].apply(lambda s: sorted(set(s)))
total = len(baskets)
pair_count: dict[tuple[str, str], int] = {}
single_count: dict[str, int] = {}
for items in baskets:
for item in items:
single_count[item] = single_count.get(item, 0) + 1
for a, b in combinations(items, 2):
pair_count[(a, b)] = pair_count.get((a, b), 0) + 1
rows = []
for (a, b), cnt in pair_count.items():
support = cnt / total
if support < min_support:
continue
for x, y in ((a, b), (b, a)):
confidence = cnt / single_count[x]
if confidence < min_confidence:
continue
lift = confidence / (single_count[y] / total)
rows.append({
"antecedent": x, "consequent": y,
"support": round(support, 4),
"confidence": round(confidence, 4),
"lift": round(lift, 4),
})
return pd.DataFrame(rows).sort_values("lift", ascending=False)
def compute_retention(days: int = 7) -> pd.DataFrame:
events = read_df("SELECT user_id, event_time FROM events")
events["event_time"] = pd.to_datetime(events["event_time"])
events["date"] = events["event_time"].dt.date
first_day = events.groupby("user_id")["date"].min().rename("first_date")
merged = events.join(first_day, on="user_id")
merged["day_diff"] = (pd.to_datetime(merged["date"]) - pd.to_datetime(merged["first_date"])).dt.days
merged = merged[merged["day_diff"] <= days]
cohort = merged.groupby("first_date")["user_id"].nunique().rename("cohort_size")
retained = merged.groupby(["first_date", "day_diff"])["user_id"].nunique().rename("users").reset_index()
retained = retained.merge(cohort, on="first_date")
retained["retention"] = (retained["users"] / retained["cohort_size"]).round(4)
return retained
def refresh_all() -> None:
write_df(compute_rfm(), "user_segments", if_exists="replace")
write_df(compute_item_rules(), "item_rules", if_exists="replace")
print("指标刷新完成")
if __name__ == "__main__":
refresh_all()
5.5 看板入口 app/main.py
python
"""Streamlit 看板首页。"""
import streamlit as st
from src.db import read_df
st.set_page_config(page_title="电商用户行为分析", layout="wide")
st.title("电商用户行为分析平台")
col1, col2, col3 = st.columns(3)
orders = read_df("SELECT COUNT(*) AS cnt, SUM(amount) AS gmv FROM orders")
segments = read_df("SELECT segment, COUNT(*) AS cnt FROM user_segments GROUP BY segment")
col1.metric("订单数", int(orders["cnt"].iloc[0] or 0))
col2.metric("GMV", f"¥{float(orders['gmv'].iloc[0] or 0):,.2f}")
col3.metric("用户分层数", len(segments))
st.subheader("用户分层分布")
st.bar_chart(segments.set_index("segment")["cnt"])
5.6 定时调度 src/scheduler.py
python
"""每日凌晨跑批。"""
from apscheduler.schedulers.blocking import BlockingScheduler
from src.etl import run_etl
from src.metrics import refresh_all
def main() -> None:
scheduler = BlockingScheduler(timezone="Asia/Shanghai")
scheduler.add_job(run_etl, "cron", hour=2, minute=0)
scheduler.add_job(refresh_all, "cron", hour=2, minute=30)
print("调度器已启动")
scheduler.start()
if __name__ == "__main__":
main()
5.7 看板子页面
app/main.py 只做总览,具体分析放在 app/pages/ 下,Streamlit 会自动把它们渲染成侧边栏多页导航。下面两个子页面都遵循同一套写法:侧边栏筛选 → 只读查询 → Plotly 绘图 → st.download_button 导出当前筛选结果。
5.7.1 app/pages/1_用户分层.py
python
"""用户分层子页面:RFM 分层分布与明细导出。"""
import pandas as pd
import plotly.express as px
import streamlit as st
from src.db import read_df
st.set_page_config(page_title="用户分层", layout="wide")
st.title("用户分层(RFM)")
# ---------- 侧边栏筛选 ----------
st.sidebar.header("筛选条件")
segments = read_df("SELECT DISTINCT segment FROM user_segments WHERE segment IS NOT NULL")
segment_options = sorted(segments["segment"].tolist())
selected_segments = st.sidebar.multiselect(
"分层标签",
options=segment_options,
default=segment_options,
)
score_range = st.sidebar.slider(
"R 得分区间",
min_value=1,
max_value=5,
value=(1, 5),
)
min_monetary = st.sidebar.number_input(
"最低累计消费金额(元)",
min_value=0.0,
value=0.0,
step=100.0,
)
# ---------- 只读查询 ----------
sql = """
SELECT user_id, recency, frequency, monetary,
r_score, f_score, m_score, segment, updated_at
FROM user_segments
WHERE segment IN ({placeholders})
AND r_score BETWEEN ? AND ?
AND monetary >= ?
ORDER BY monetary DESC
""".format(placeholders=",".join(["?"] * len(selected_segments)))
params = (*selected_segments, score_range[0], score_range[1], min_monetary)
if not selected_segments:
st.warning("请至少选择一个分层标签。")
st.stop()
df = read_df(sql, params=params)
if df.empty:
st.info("当前筛选条件下没有数据,请放宽条件后重试。")
st.stop()
# ---------- 指标卡 ----------
col1, col2, col3 = st.columns(3)
col1.metric("用户数", f"{len(df):,}")
col2.metric("人均消费", f"¥{df['monetary'].mean():,.2f}")
col3.metric("人均下单次数", f"{df['frequency'].mean():.1f}")
# ---------- Plotly 图表 ----------
left, right = st.columns(2)
with left:
seg_count = df.groupby("segment", as_index=False)["user_id"].count()
seg_count.columns = ["segment", "users"]
fig_bar = px.bar(
seg_count,
x="segment",
y="users",
text="users",
title="各分层用户数",
color="segment",
)
fig_bar.update_layout(showlegend=False, xaxis_title="", yaxis_title="用户数")
st.plotly_chart(fig_bar, use_container_width=True)
with right:
fig_scatter = px.scatter(
df,
x="frequency",
y="monetary",
color="segment",
size="recency",
hover_data=["user_id", "r_score", "f_score", "m_score"],
title="F-M 分布(气泡大小 = R 天数)",
)
fig_scatter.update_layout(xaxis_title="下单次数 F", yaxis_title="累计金额 M")
st.plotly_chart(fig_scatter, use_container_width=True)
# ---------- 明细与导出 ----------
st.subheader("分层明细")
st.dataframe(df, use_container_width=True, hide_index=True)
st.download_button(
label="导出当前筛选结果 CSV",
data=df.to_csv(index=False).encode("utf-8-sig"),
file_name="user_segments_filtered.csv",
mime="text/csv",
)
5.7.2 app/pages/2_商品关联.py
python
"""商品关联子页面:关联规则筛选、散点图与规则导出。"""
import plotly.express as px
import streamlit as st
from src.db import read_df
st.set_page_config(page_title="商品关联", layout="wide")
st.title("商品关联规则")
# ---------- 侧边栏筛选 ----------
st.sidebar.header("筛选条件")
min_lift = st.sidebar.slider("最小提升度 lift", 1.0, 10.0, 1.5, step=0.1)
min_confidence = st.sidebar.slider("最小置信度 confidence", 0.0, 1.0, 0.2, step=0.05)
min_support = st.sidebar.slider("最小支持度 support", 0.0, 0.2, 0.01, step=0.005)
top_n = st.sidebar.number_input("展示条数", min_value=10, max_value=500, value=50, step=10)
# ---------- 只读查询 ----------
sql = """
SELECT antecedent, consequent, support, confidence, lift
FROM item_rules
WHERE lift >= ? AND confidence >= ? AND support >= ?
ORDER BY lift DESC
LIMIT ?
"""
df = read_df(sql, params=(min_lift, min_confidence, min_support, int(top_n)))
if df.empty:
st.info("当前阈值下没有满足条件的规则,请降低 lift / confidence 后重试。")
st.stop()
# ---------- 指标卡 ----------
col1, col2, col3 = st.columns(3)
col1.metric("规则条数", f"{len(df):,}")
col2.metric("最高提升度", f"{df['lift'].max():.2f}")
col3.metric("平均置信度", f"{df['confidence'].mean():.2%}")
# ---------- Plotly 图表 ----------
left, right = st.columns(2)
with left:
fig_scatter = px.scatter(
df,
x="support",
y="confidence",
size="lift",
color="lift",
hover_data=["antecedent", "consequent"],
title="支持度-置信度分布(气泡大小 = lift)",
color_continuous_scale="Viridis",
)
fig_scatter.update_layout(xaxis_title="支持度 support", yaxis_title="置信度 confidence")
st.plotly_chart(fig_scatter, use_container_width=True)
with right:
top_rules = df.head(15).copy()
top_rules["rule"] = top_rules["antecedent"] + " → " + top_rules["consequent"]
fig_bar = px.bar(
top_rules.sort_values("lift"),
x="lift",
y="rule",
orientation="h",
text="lift",
title="Top 15 规则提升度",
)
fig_bar.update_layout(xaxis_title="提升度 lift", yaxis_title="")
st.plotly_chart(fig_bar, use_container_width=True)
# ---------- 明细与导出 ----------
st.subheader("规则明细")
st.dataframe(df, use_container_width=True, hide_index=True)
st.download_button(
label="导出当前筛选结果 CSV",
data=df.to_csv(index=False).encode("utf-8-sig"),
file_name="item_rules_filtered.csv",
mime="text/csv",
)
两个页面都只读查询结果表,不写库;导出统一用 utf-8-sig 编码,避免 Excel 打开中文乱码。若后续要加日期区间筛选,只需在侧边栏补 st.date_input,并把条件拼进 WHERE 子句即可。
6. 测试用例
python
"""指标口径测试。"""
import pandas as pd
from src.metrics import _label_segment
def test_segment_label():
row = pd.Series({"r_score": 5, "f_score": 5, "m_score": 5})
assert _label_segment(row) == "重要价值客户"
row = pd.Series({"r_score": 1, "f_score": 5, "m_score": 3})
assert _label_segment(row) == "重要挽留客户"
row = pd.Series({"r_score": 1, "f_score": 1, "m_score": 1})
assert _label_segment(row) == "流失客户"
运行:
bash
pytest -q
7. 部署文档
7.1 依赖清单 requirements.txt
text
pandas==2.2.2
numpy==1.26.4
plotly==5.22.0
streamlit==1.36.0
APScheduler==3.10.4
python-dotenv==1.0.1
pytest==8.2.2
7.2 环境变量 .env.example
text
DB_PATH=./data/analytics.db
RAW_DIR=./data/raw
BATCH_SIZE=5000
7.3 本地运行
bash
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -r requirements.txt
python scripts/init_db.py # 建库建表
python scripts/run_etl.py # 清洗入库
python -m src.metrics # 计算指标
streamlit run app/main.py # 启动看板
7.4 Docker 部署
Dockerfile:
dockerfile
FROM python:3.11-slim
WORKDIR /app
ENV PYTHONUNBUFFERED=1
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN python scripts/init_db.py
EXPOSE 8501
CMD ["streamlit", "run", "app/main.py", "--server.address=0.0.0.0"]
docker-compose.yml:
yaml
services:
analytics:
build: .
ports:
- "8501:8501"
volumes:
- ./data:/app/data
environment:
- DB_PATH=/app/data/analytics.db
restart: unless-stopped
启动:
bash
docker compose up -d --build
访问 http://localhost:8501 即可。
7.5 生产环境注意事项
- 数据持久化 :
data/目录必须挂载到宿主机或云盘,容器重建不丢库。 - 数据库迁移 :数据量超过百万行后,把
src/db.py换成 SQLAlchemy + MySQL 连接串,schema.sql中AUTOINCREMENT改为AUTO_INCREMENT。 - 定时任务 :生产建议把
scheduler.py拆成独立容器,避免与 Web 服务争抢资源。 - 日志与监控:统一输出到 stdout,由容器日志采集;关键指标(跑批耗时、入库行数)打点告警。
- 权限 :数据库文件权限设为
600,.env不入库,密钥走环境变量注入。
8. 交付清单
| 交付物 | 路径 | 说明 |
|---|---|---|
| 完整源码 | src/、app/、scripts/ |
可直接运行 |
| 数据库文件 | data/analytics.db |
SQLite 单文件,含表结构 |
| 建表脚本 | sql/schema.sql |
支持迁移 MySQL |
| 部署文档 | README.md、Dockerfile、docker-compose.yml |
本地与容器双方案 |
| 测试 | tests/ |
指标口径回归 |
按上述结构落地后,项目即可做到"拉下来就能跑、换台机器也能跑、交给别人还能跑"。
9. 总结与后续扩展
本文围绕「能跑通」到「能交付」的差距,给出了三条工程化主线:一是可复现的源码结构 ,目录分层清晰、配置集中管理,任何人拉下代码都能按同一套路径跑通;二是可追溯的数据存储 ,从原始 CSV 到明细表再到结果表,每一层都有明确的写入方与幂等重跑策略;三是可执行的部署文档,本地、Docker、生产三套方案齐备,配合测试用例保证指标口径不漂移。
后续可按需扩展:
- 接入 Airflow 替代 APScheduler :把
run_etl与refresh_all改写成 DAG 中的两个 Task,落地成本约 1--2 人日(新增依赖与 DAG 文件),收益是获得任务依赖、失败重试、历史回溯与可视化监控。 - 指标表加分区与增量刷新 :在 MySQL 侧按日期分区,并把
refresh_all()的整表重建改为按快照日增量写入,落地成本约 3--5 人日,收益是百万行以上数据量时跑批耗时从小时级降到分钟级。 - 看板增加筛选与导出 :在
app/pages/中补充日期区间、用户分层、商品类目等st.sidebar筛选控件,并加st.download_button导出 CSV,落地成本约 1 人日,收益是业务方无需再找开发取数。 - 补充数据质量校验用例 :在
tests/中新增主键唯一性、金额非负、事件类型枚举、结果表行数波动阈值等断言,落地成本约 1 人日,收益是把数据异常拦截在跑批阶段而非看板展示阶段。