从「能跑通」到「能交付」:一套可直接落地的电商用户行为分析平台(Python + SQLite/MySQL + Streamlit 全栈源码与部署文档)

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 人日,收益是把数据异常拦截在跑批阶段而非看板展示阶段。
相关推荐
小小张说故事1 小时前
Python 的 for 到底怎么跑起来的?拆开迭代协议:GET_ITER 之后 FOR_ITER 在循环什么
python
凯瑟琳.奥古斯特1 小时前
动态调优IoU提升OCR文本合并精度
开发语言·python·算法
1024奇点2 小时前
【仓颉语言入门 · 第二篇 第 10 课】
开发语言·数据库·ide·mysql·开源
蒸鱼Yuzheng2 小时前
自动化长任务治理:租约、心跳、孤儿进程与安全回收
自动化测试·python·任务调度·可靠性·分布式系统
2601_962885723 小时前
从 baostock 迁移到 AlphaFeed:接口对照与代码迁移指南
python
AI情绪识别开源3 小时前
检信ALLEMOTION 标定治理与信号质量闸门 · 老年无感认知评估分析器
python
catino3 小时前
分库分表详解
mysql
七夜zippoe3 小时前
启元 Q1 腿部运动学逆解:Python 算站立下蹲关节角
开发语言·python·ai·机器人·腿部运动学
第404号工位3 小时前
插上充电宝真能偷光相册?我拆解了一台改装充电宝,实测“恶意充电”的真实攻击链
linux·python·架构