主题
第2章 项目搭建与配置设计
2.1 概述
在动手写模型之前,先把项目骨架搭好。一个好的项目结构和配置设计,可以让后续几千行代码有条不紊地组织在一起。
本章会完成两件事:
- 搭建 Python 项目结构,配置依赖和开发工具
- 设计系统的核心配置——用
dataclass把所有"魔数"收归一处
2.2 项目结构
coupon-rec/
├── pyproject.toml # 项目元数据与依赖
├── src/
│ ├── config.py # 全局配置
│ ├── features/ # 特征工程
│ │ ├── browse_features.py
│ │ ├── user_features.py
│ │ └── feature_pipeline.py
│ ├── models/ # 四个核心模型
│ │ ├── intent_model.py
│ │ ├── uplift_model.py
│ │ ├── sensitivity_model.py
│ │ └── gmv_model.py
│ ├── optimizer/ # 预算优化
│ │ ├── budget_optimizer.py
│ │ └── coupon_allocator.py
│ ├── experiment/ # AB 实验
│ │ ├── ab_test.py
│ │ └── metrics.py
│ ├── risk/ # 风控
│ │ └── anti_fraud.py
│ └── serving/ # 在线服务
│ └── api.py
├── scripts/
│ ├── generate_sample_data.py # 模拟数据生成
│ ├── train_models.py # 模型训练
│ └── run_allocation.py # 离线分配
├── tests/ # 38 个单元测试
└── data/ # 数据目录(不入库)项目按功能模块划分目录,每个目录对应一个独立的技术主题,也对应本书的一个或多个章节。
2.3 依赖管理
使用 pyproject.toml 管理项目元数据和依赖,这是 Python 社区推荐的现代方式:
toml
<!-- pyproject.toml -->
[project]
name = "coupon-rec"
version = "0.1.0"
description = "智能优惠券发放算法系统 - 电商新用户场景"
requires-python = ">=3.10"
dependencies = [
"lightgbm>=4.0",
"scikit-learn>=1.5",
"pandas>=2.0",
"numpy>=1.26",
"scipy>=1.11",
"fastapi>=0.100",
"uvicorn>=0.23",
"pydantic>=2.0",
"joblib>=1.3",
]
[project.optional-dependencies]
dev = [
"pytest==8.3.4",
"pytest-cov==6.0.0",
"ruff==0.8.4",
]
[build-system]
requires = ["setuptools>=68.0"]
build-backend = "setuptools.build_meta"关键依赖说明:
| 依赖 | 用途 | 选型理由 |
|---|---|---|
lightgbm | 所有模型的训练框架 | 工业界结构化数据建模首选,原生支持类别特征 |
scikit-learn | 数据拆分、评估指标 | ML 生态标准库 |
pandas | 特征工程、数据处理 | 表格数据处理的事实标准 |
scipy | AB 实验的统计检验 | z-test、正态分布 |
fastapi | 在线预测 API | 异步高性能,自带 Swagger 文档 |
pydantic | 请求/响应数据校验 | FastAPI 原生支持,类型安全 |
joblib | 模型序列化 | 比 pickle 更高效,支持大数组压缩 |
为什么不用 causalml?
业界常用的 Uber causalml 库依赖 Cython 编译,在部分 Python 版本(如 3.13)上难以安装。本项目直接用 LightGBM 手写 T-Learner,既避开依赖地狱,又能深入理解算法原理。
2.4 核心配置设计
好的配置设计要做到两点:① 所有超参数集中管理 ② 不可变(避免运行时被意外修改)。
Python 的 frozen dataclass 完美满足这两个要求:
python
<!-- src/config.py -->
from dataclasses import dataclass, field
@dataclass(frozen=True)
class CouponConfig:
"""优惠券系统全局配置."""
# 候选面额集合 (元)
coupon_amounts: tuple[int, ...] = (5, 10, 15, 20, 30, 50)
# 预算
daily_budget: float = 1_000_000.0 # 日预算 100万元
# 毛利率
margin_rate: float = 0.20
# AB 实验流量分配
control_ratio: float = 0.10 # 对照组 10%
model_ratio: float = 0.45 # 模型组 45%
rule_ratio: float = 0.30 # 规则组 30%
explore_ratio: float = 0.15 # 探索组 15%
# 风控阈值
max_coupons_per_day: int = 3 # 单用户日领券上限
max_accounts_per_device: int = 3 # 同设备最大关联账号数
refund_rate_threshold: float = 0.5 # 退款率阈值
# 模型参数
uplift_threshold: float = 0.0 # Uplift 分阈值,>0 才发券
observation_window_days: int = 7 # 转化观察窗口设计要点
frozen=True:配置实例创建后不可修改。如果有人写 config.daily_budget = 0,Python 会直接抛出 FrozenInstanceError。这在多人协作中非常重要——防止某个角落里的代码偷偷改了全局配置。
tuple 而非 list:面额集合用 tuple 而非 list,同样是为了不可变。面额集合在系统生命周期内不应改变。
默认值即文档:每个字段的默认值就是生产环境的推荐值,注释说明了业务含义。新人看代码就能理解每个参数的作用。
2.5 LightGBM 训练参数
四个模型(意图、Uplift、敏感度、GMV)都用 LightGBM,训练参数统一管理:
python
<!-- src/config.py -->
@dataclass
class LGBMParams:
"""LightGBM 通用训练参数."""
objective: str = "binary"
n_estimators: int = 500
learning_rate: float = 0.05
num_leaves: int = 63
feature_fraction: float = 0.8
bagging_fraction: float = 0.8
bagging_freq: int = 5
min_child_samples: int = 50
reg_alpha: float = 0.1
reg_lambda: float = 0.1
verbose: int = -1
random_state: int = 42
def to_dict(self) -> dict:
return {k: v for k, v in self.__dict__.items()}参数说明:
| 参数 | 值 | 作用 |
|---|---|---|
num_leaves=63 | 2^6-1 | 控制树的复杂度,过大容易过拟合 |
feature_fraction=0.8 | 80% | 每棵树随机选 80% 特征,增加多样性 |
bagging_fraction=0.8 | 80% | 每棵树随机选 80% 样本 |
min_child_samples=50 | 最少 50 | 叶子节点最少样本数,防过拟合 |
reg_alpha=0.1 | L1 正则 | 稀疏化特征选择 |
reg_lambda=0.1 | L2 正则 | 平滑系数 |
LGBMParams 不设 frozen=True——不同模型可能需要覆盖 objective(分类用 "binary",回归用 "regression")。
2.6 特征工程配置
python
<!-- src/config.py -->
@dataclass
class FeatureConfig:
"""特征工程配置."""
# 时间窗口 (天)
time_windows: tuple[int, ...] = (1, 3, 7)
# 类别特征列
categorical_features: tuple[str, ...] = (
"device_os",
"device_brand",
"device_price_tier",
"channel_source",
"city_tier",
"top1_category_id",
)
# 数值特征缺失值填充
numeric_fill_value: float = -999.0time_windows = (1, 3, 7):浏览特征会按近 1 天、近 3 天、近 7 天三个时间窗口聚合。窗口太短捕捉不到趋势,太长会引入噪声,(1, 3, 7) 是常用的经验值。
numeric_fill_value = -999.0:LightGBM 原生支持缺失值处理,填充为 -999 而非 0,是为了让模型能区分"真正的 0 值"和"缺失"。
2.7 生命周期阶段枚举
新用户的六个生命周期阶段,用类常量表示,配合默认面额映射:
python
<!-- src/config.py -->
class UserStage:
REGISTERED_NO_BROWSE = "registered_no_browse"
BROWSED_NO_CART = "browsed_no_cart"
CARTED_NO_ORDER = "carted_no_order"
FIRST_ORDER_DONE = "first_order_done"
SECOND_ORDER_DONE = "second_order_done"
SILENT = "silent"
# 默认面额策略 (规则兜底)
STAGE_DEFAULT_AMOUNTS: dict[str, int] = {
UserStage.REGISTERED_NO_BROWSE: 20,
UserStage.BROWSED_NO_CART: 15,
UserStage.CARTED_NO_ORDER: 10,
UserStage.FIRST_ORDER_DONE: 10,
UserStage.SECOND_ORDER_DONE: 5,
UserStage.SILENT: 30,
}STAGE_DEFAULT_AMOUNTS 是规则策略的兜底方案:当模型不可用(冷启动期、模型故障)时,根据用户所处阶段发放固定面额。注意沉默新客面额最高(¥30),因为召回成本本身就高。
2.8 配置的使用方式
整个系统通过单例配置实例来共享配置:
python
<!-- src/config.py -->
default_config = CouponConfig()
default_lgbm_params = LGBMParams()
default_feature_config = FeatureConfig()各模块通过构造函数注入配置,默认使用单例:
python
class BudgetOptimizer:
def __init__(self, config: CouponConfig | None = None):
self.config = config or default_config这种模式的好处:
- 生产环境:直接用默认配置
- 测试:传入自定义配置(比如设小预算方便测试)
- 实验:不同实验组可以用不同配置
本章小结
- 项目采用模块化目录结构,按功能域(features / models / optimizer / experiment / risk / serving)组织
pyproject.toml管理依赖,避免requirements.txt的版本锁定问题- 核心配置用
frozen dataclass,集中管理、不可变、自文档化 LGBMParams提供统一的模型训练参数,不同模型可覆盖特定字段- 六个用户生命周期阶段配合默认面额映射,提供规则策略兜底
思考题
- 为什么
CouponConfig用frozen=True而LGBMParams不用?在什么场景下你会希望运行时修改配置? numeric_fill_value = -999.0和fillna(0)在模型训练中有什么区别?哪些特征填 0 更合理?- 如果要支持多个业务线(如服装、3C、生鲜),配置设计需要做哪些改动?