长尾目标的 LightGBM 优化实践
背景:长尾分布的建模困境
什么是零膨胀长尾分布
在真实业务场景中,大量目标变量呈现零膨胀长尾分布(Zero-Inflated Long-Tail Distribution):
- 大量零值:目标变量中零值占比极高(如 40%~60%)
- 少量极端大值:尾部存在远超均值的极端值(最大值可达均值的 100 倍以上)
- 重度右偏:偏度(Skewness)远大于 0,分布严重不对称

为什么传统回归会失败
直接用 LightGBM 回归(MSE 损失)拟合这种分布,模型会做出一个”理性但无用”的选择——预测均值附近。
原因有三层:
| 失败原因 | 机制说明 | 后果 |
| 零值拉扯 | 大量零值样本主导损失函数,模型倾向于预测小值 | 正值区域的预测能力丧失 |
| 极端值干扰 | MSE 对大误差平方放大,0.16% 的极端值可贡献 67% 的总方差 | 模型优化方向被少数样本绑架 |
| 信息量不足 | 特征对”零 vs 非零”的区分力有限,难以精确预测多级计数 | 预测值被压缩到均值附近 |
核心矛盾:模型不是在”过拟合”,而是在”欠拟合”——它连训练集都没学好,预测值的标准差只有实际值的一半。
常见的长尾分布场景
这种分布并非个例,以下场景普遍存在:
- 电商:订单金额(大量小额订单 + 少数大额订单)、用户购买频次
- 出行:司机可用数(大量无司机时段 + 少数高供给时段)、等待时长
- 内容平台:视频播放量、评论数、分享数
- 保险:理赔金额(大量零理赔 + 少数巨额理赔)、理赔次数
- 广告:点击次数、转化次数
- 人力资源:请假天数、加班时长
长尾分布下的”欠拟合”
================================================================ 📊 回归评价指标对比 ================================================================ 指标 | 训练集 | 测试集 | Gap(t-tr) -------------------------------------------------------- MAE | 0.830884 | 0.831060 | +0.000176 RMSE | 1.822417 | 1.790311 | -0.032106 R² | 0.267632 | 0.267473 | -0.000159 MAPE(%) | nan | nan | +nan 预测值统计: 训练集: mean=0.9085, std=1.0859 测试集: mean=0.9060, std=1.0813 实际值统计: 训练集: mean=0.9088, std=2.1295 测试集: mean=0.9050, std=2.0918 测试集残差统计: mean=-0.0010 (接近0 → 无系统偏差) std=1.7903, min=-21.0005, max=119.2531 偏度=7.7938 (接近0 → 残差对称) MAE/y_mean = 91.83% (误差占均值比例)
指标 1:R² = 0.267 → 模型只解释了 26.7% 的方差
R²(决定系数)是衡量模型拟合程度的核心指标:
- R² > 0.7:模型可用
- R² > 0.5:勉强可接受
- R² ≈27:基本等于”预测均值”水平
知识点展开:R² = 1 – SS_res/SS_tot,其中 SS_res 是残差平方和,SS_tot 是总平方和。R² = 0.27 意味着模型只捕获了目标变量 27% 的变化规律,剩余 73% 完全没学到。
指标 2:Train ≈ Test(Gap ≈ 0)→ 欠拟合而非过拟合
MAE: train=0.831 test=0.831 gap=+0.0002 RMSE: train=1.822 test=1.790 gap=-0.032 R²: train=0.268 test=0.267 gap=-0.0002
训练集和测试集表现几乎完全一致,这说明:
- 模型没有过拟合(泛化稳定)
- 但模型连训练集都没学好 → 欠拟合
关键区分:过拟合是”训练好、测试差”(gap 大);欠拟合是”训练和测试都差”(gap 小但都差)。前者需要正则化或减模型复杂度,后者需要加特征或换方法。
指标 3:预测值 std ≈ 1.08 vs 实际值 std ≈ 2.09 → 预测被压缩
预测值: mean=0.906, std=1.081 实际值: mean=0.905, std=2.092
预测值的标准差只有实际值的一半,说明模型倾向于预测均值,不敢给出极端值。这是欠拟合的典型表现——模型没有学到区分高低值的特征模式。
指标 4:MAE/y_mean = 91.83% → 误差和目标值一样大
目标均值是 0.905,而平均绝对误差是 0.831。误差大小和要预测的值本身差不多大,说明模型几乎没有任何预测能力。
指标 5:残差偏度 = 7.79 → 目标变量有极端长尾
残差: mean=-0.001, std=1.79, min=-21.0, max=119.25, 偏度=7.79
- mean ≈ 0:无系统偏差
- 偏度 = 7.79:极度右偏
- max = 119.25:存在极端值,实际值约 120 但模型预测约9
指标 6:MAPE = nan → 目标变量含大量零
MAPE 计算时除零,说明 y_true 中有大量 0 值。结合均值仅 0.9,可以推断目标变量是零膨胀分布。
诊断结论汇总
| 维度 | 评估 | 说明 |
| 拟合程度 | ❌ 差 | R²=0.27,欠拟合 |
| 过拟合 | ✅ 无 | train≈test,泛化稳定 |
| 系统偏差 | ✅ 无 | 残差 mean≈0 |
| 残差分布 | ❌ 异常 | 偏度 7.79,有极端值 |
| 业务可用性 | ❌ 不可用 | MAE≈y_mean,误差与值同量级 |
| 根本原因 | 零膨胀长尾分布 | 模型被迫预测均值 |
极端值的方差贡献
核心发现
在深入分析数据分布后,一个关键发现浮出水面:
| 维度 | 数据 | 含义 |
| y ≤ 4 样本占比 | 99.84% | 几乎所有数据都在 0-4 范围 |
| y > 4 样本数 | ~0.16%(约 1 万条) | 极端值占比极小 |
| y > 4 对总方差贡献 | 67.1% | 0.16% 的样本贡献了 2/3 的偏差 |
这就是 R²=0.27 的元凶:模型在 99.84% 的样本上表现可能还不错,但那 0.16% 的极端值(最大到 278)用平方误差一算,直接把 R² 拉垮了。
尝试使用截断优化长尾场景,>4的值全部看成是4
截断(Truncation)在模型层面是否合理?答案是合理,且往往是最优选择,原因有三:
- 模型不需要预测”真实值”,只需要预测到决策可用的粒度。如果业务决策是分桶的(如预测值 0 → 策略 A,[1,2] → 策略 B,[3,4+] → 策略 C),那么 5、10、278 在决策上是等价的。模型去区分 5 和 278 没有任何业务价值,反而因为这部分样本的巨大方差,干扰模型对 0-4 范围内模式的拟合。
- 消除损失函数被极端值劫持。MSE 对大误差平方放大,16% 的极端值在 RMSE 中的权重远超其样本占比。模型为了降低这 0.16% 样本的损失,会牺牲 99.84% 样本的预测精度——这是典型的少数极端值绑架整体优化方向。
回归 vs 分类:决策边界决定建模方式
截断后目标变成 {0, 1, 2, 3, 4} 五个离散值,此时面临一个关键选择:继续用回归,还是转为分类?
决策框架
| 业务配置方式 | 适用模型 | 原因 |
| 离散桶:0, 1, 2, 3, 4 → 分别配规则 | 分类 | 输出直接是桶,无需转换 |
| 连续区间:[0,0.5), [0.5,1.5), [1.5,3), [3,4] → 分别配规则 | 回归 | 输出可灵活设任意阈值 |
回归在连续阈值场景下的优势
如果业务规则引擎支持连续区间阈值配置,回归确实更合适:
- 阈值可灵活调整:模型输出连续值,业务随时调阈值,无需重训模型
- 保留顺序和距离信息:3 和 0.7 虽然都落在桶 0,但前者”大概率无值”、后者”五五开”,回归能区分
- 输出可直接解读为期望值:预测3 = “期望 0.3 个单位”,比分类的概率分布更直观
- 优化目标与业务目标一致:回归优化”预测值与真实值偏差最小”,预测越准决策越好
分类的优势
如果业务决策本身就是离散的,分类更合适:
- 直接输出桶类别:无需额外设阈值分桶
- 概率输出:可输出每个类别的概率,用于决策置信度评估
- 内置不平衡处理:class_weight=’balanced’机制
- 不假设类别间等距:业务上 0→1 的决策跳变可能远大于 3→4
损失函数选择:MSE / Poisson / Tweedie
这是整个优化过程中最容易踩坑的环节。选择错误的损失函数,不仅无法改善模型,还会浪费大量调参时间。
三种损失函数详解
- MSE(objective=’regression’)
- 假设:目标变量服从正态分布,方差恒定
- 适用:连续正态分布数据
- 对长尾数据的问题:对大误差平方放大,极端值主导损失函数;预测偏向均值
- Poisson(objective=’poisson’)
- 假设:目标变量服从泊松分布,方差 = 均值
- 适用:离散计数数据(如事件发生次数)
- 优势:天然为离散计数设计,不存在”连续分布拟合离散整数”的问题
- Tweedie(objective=’tweedie’, p ∈ (1, 2))
- 假设:目标是零膨胀的复合分布——零值有概率质量,正值服从连续 Gamma 分布
- 适用:真正的零膨胀 + 连续正值数据(如保险理赔金额)
- 参数:p=1 为 Poisson,p=2 为 Gamma,p=1.5 为中间态
关键误区:看到大量零值 ≠ 零膨胀
很多人看到目标变量中 48% 是零值,就立刻判定为”零膨胀分布”,然后选择 Tweedie 损失。但这可能是一个严重的误判。
零膨胀的严格定义:零值比例显著超过泊松分布的期望零值比例(比值 > 1.2)。
实际诊断方法:
实际 P(y=0): 47.41% Poisson 期望 P(y=0): 48.17%(由 μ=0.73 计算) 比值: 0.98 ← 实际零值甚至比 Poisson 期望还略少!
结论:这根本不是零膨胀!低均值的泊松分布本身就会产生大量零值。μ=0.73 时,Poisson 自然产生约 48% 的零值,这完全正常。
三个诊断维度
| 诊断维度 | 数据值 | 含义 | 判定 |
| 零值比/Poisson期望零值 | 0.98 | < 1.2 | ❌ 非零膨胀 |
| 过离散度 Var/μ | 1.21 | ≈ 1.0 | ✅ 泊松假设成立 |
| 正值是否连续 | 离散整数 1,2,3,4 | 非连续 | ❌ Tweedie 不适用 |
实践中的教训
教训:先验证假设,再选择方法。不要基于直觉(”看到零值多 = 零膨胀”)直接选 Tweedie,而要用数据说话——计算泊松期望零值比例,与实际比较。
一个有趣的发现:将损失函数从 MSE 改为 Poisson 后,RMSE 下降了 55%,但 R² 完全没变(0.267 → 0.267)。这说明:
误差平方和 SS_res: 3.21 → 0.64 (降低 79.9%) 总平方和 SS_tot: 4.38 → 0.88 (降低 79.9%) R² = 1 - SS_res/SS_tot → 不变
截断让”问题变简单”了,但模型的相对预测能力没有变化。 RMSE 下降完全是因为目标方差也下降了(截断消除了极端值),不是模型变聪明了。
两阶段建模:拆解难度,各自击破
当单回归模型的 R² 始终卡在 0.27 不动时,需要换一个思路:不是模型不够强,而是一个模型试图同时解决两个难度截然不同的任务。
两阶段架构

核心思想:
阶段 1: 二分类 — 预测 y=0 还是 y>0 阶段 2: 回归 — 仅对 y>0 的样本预测具体值 最终预测 = P(y>0) × E[y|y>0]
为什么两阶段有效
直觉上”有没有值”应该比”有几个值”更容易判断,但数据恰恰相反:
| 阶段 | 任务 | AUC | 难度 |
| 阶段 1 | y=0 vs y>0 | 0.69 | 中等(灰色地带) |
| 阶段 2 | y=1 vs y≥2 | 0.82 | 优秀 |
原因分析:
- y=0 的成因更复杂:可能混杂多种不同原因(确实无值、数据未捕获、边界状态、系统异常),特征模式各异,模型难以统一区分
- y≥1 后更规律:一旦确认有值,问题变成”量多还是量少”,区域密度、历史统计、时间规律等特征发挥的作用更大
单回归被”拖累”的机制
单回归模型试图一步到位预测 0-4,被迫同时面对两个难度截然不同的任务:
任务难度: 区分 0 vs 1+ : AUC=0.69 (难) ← 占 89% 的样本,主导损失函数 区分 1 vs 2+ : AUC=0.82 (容易) ← 只占 11% 样本,被淹没 单回归结果: → 被 89% 的"难任务"拖累 → "易任务"的信息被压缩到预测值的小幅波动中 → 模型预测值偏向均值,谁也区分不好 → R² = 0.267
完整代码实现
下面给出一套可直接运行的两阶段建模流程,涵盖数据加载、Optuna 超参搜索、最终模型训练、综合评估、以及笛卡尔积全量组合预测导出。代码已做以下优化:
- 去除业务特定字段名,变量命名通用化
- 自动识别类别特征,避免categorical_features = feature_cols 的硬编码风险
- 将公共调参逻辑抽象为suggest_common_params,减少重复代码
- 阶段1/阶段2 均使用 5-fold CV + MedianPruner,提升搜索效率
- 评估函数内置业务决策桶准确率,方便直接对齐业务指标
- 笛卡尔积导出模块独立封装,便于对接运营后台
数据加载与基础配置
import os
import warnings
from datetime import datetime
from itertools import product
import numpy as np
import pandas as pd
import lightgbm as lgb
import optuna
from sklearn.model_selection import KFold, train_test_split
from sklearn.metrics import (
mean_absolute_error, mean_squared_error, r2_score,
roc_auc_score, classification_report
)
warnings.filterwarnings('ignore')
optuna.logging.set_verbosity(optuna.logging.WARNING)
def load_data(csv_path):
"""加载数据,返回特征列、目标列和类别特征列表。"""
df = pd.read_csv(csv_path)
feature_cols = list(df.columns[:-1])
target_col = df.columns[-1]
df[target_col] = df[target_col].astype(float)
# 自动识别类别特征,避免误将全部特征当类别特征
categorical_features = [
c for c in feature_cols
if df[c].dtype == 'object' or pd.api.types.is_categorical_dtype(df[c])
]
return df, feature_cols, target_col, categorical_features
CONFIG = {
'csv_path': 'data.csv',
'target_clip_upper': 4, # 按业务决策粒度截断目标变量
'test_size': 0.2,
'val_size': 0.15,
'random_state': 42,
'n_trials': 100,
'timeout': 900,
'n_splits': 5,
'early_stopping_rounds': 50,
}
df, feature_cols, target_col, categorical_features = load_data(CONFIG['csv_path'])
df_model = df[feature_cols + [target_col]].copy()
df_model[target_col] = df_model[target_col].clip(upper=CONFIG['target_clip_upper'])
X = df_model[feature_cols]
y = df_model[target_col]
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=CONFIG['test_size'], random_state=CONFIG['random_state']
)
print(f"训练集大小: {X_train.shape[0]:,}")
print(f"测试集大小: {X_test.shape[0]:,}")
阶段1:二分类 Optuna 调优 (y=0 vs y>0)
FIXED_PARAMS_CLF = {
'objective': 'binary',
'metric': 'auc',
'boosting_type': 'gbdt',
'verbosity': -1,
'n_jobs': -1,
'random_state': CONFIG['random_state'],
'feature_pre_filter': False,
'n_estimators': 1000,
}
def suggest_common_params(trial, fixed_params):
"""建议两阶段通用的 LightGBM 超参数。"""
return {
**fixed_params,
'num_leaves': trial.suggest_int('num_leaves', 16, 128),
'learning_rate': trial.suggest_float('learning_rate', 0.01, 0.3, log=True),
'min_child_samples': trial.suggest_int('min_child_samples', 5, 50),
'subsample': trial.suggest_float('subsample', 0.6, 1.0),
'subsample_freq': trial.suggest_int('subsample_freq', 1, 10),
'colsample_bytree': trial.suggest_float('colsample_bytree', 0.6, 1.0),
'reg_alpha': trial.suggest_float('reg_alpha', 1e-8, 10.0, log=True),
'reg_lambda': trial.suggest_float('reg_lambda', 1e-8, 10.0, log=True),
}
y_binary_train = (y_train > 0).astype(int)
def objective_stage1(trial):
"""阶段1目标函数:5-fold CV 优化 AUC。"""
params = suggest_common_params(trial, FIXED_PARAMS_CLF)
kf = KFold(n_splits=CONFIG['n_splits'], shuffle=True, random_state=CONFIG['random_state'])
auc_scores = []
for fold_idx, (train_idx, val_idx) in enumerate(kf.split(X_train)):
X_tr, X_val = X_train.iloc[train_idx], X_train.iloc[val_idx]
y_tr = y_binary_train.iloc[train_idx]
y_val = y_binary_train.iloc[val_idx]
model = lgb.LGBMClassifier(**params)
model.fit(
X_tr, y_tr,
categorical_feature=categorical_features,
eval_set=[(X_val, y_val)],
callbacks=[
lgb.early_stopping(CONFIG['early_stopping_rounds'], verbose=False),
lgb.log_evaluation(0)
]
)
y_proba = model.predict_proba(X_val)[:, 1]
auc_scores.append(roc_auc_score(y_val, y_proba))
trial.report(np.mean(auc_scores), fold_idx)
if trial.should_prune():
raise optuna.TrialPruned()
return np.mean(auc_scores)
study_s1 = optuna.create_study(
direction='maximize',
sampler=optuna.samplers.TPESampler(seed=CONFIG['random_state']),
pruner=optuna.pruners.MedianPruner(n_startup_trials=3, n_warmup_steps=2)
)
study_s1.optimize(objective_stage1, n_trials=CONFIG['n_trials'],
timeout=CONFIG['timeout'], show_progress_bar=False)
print(f"阶段1 最佳CV AUC: {study_s1.best_value:.6f}")
阶段2:Poisson 回归 Optuna 调优 (y>0 子集)
FIXED_PARAMS_REG = {
'objective': 'poisson',
'metric': 'rmse',
'boosting_type': 'gbdt',
'verbosity': -1,
'n_jobs': -1,
'random_state': CONFIG['random_state'],
'feature_pre_filter': False,
'n_estimators': 1000,
}
X_train_pos = X_train[y_train > 0]
y_train_pos = y_train[y_train > 0]
def objective_stage2(trial):
"""阶段2目标函数:5-fold CV 优化 RMSE(仅 y>0 子集)。"""
params = suggest_common_params(trial, FIXED_PARAMS_REG)
kf = KFold(n_splits=CONFIG['n_splits'], shuffle=True, random_state=CONFIG['random_state'])
rmse_scores = []
for fold_idx, (train_idx, val_idx) in enumerate(kf.split(X_train_pos)):
X_tr, X_val = X_train_pos.iloc[train_idx], X_train_pos.iloc[val_idx]
y_tr = y_train_pos.iloc[train_idx]
y_val = y_train_pos.iloc[val_idx]
model = lgb.LGBMRegressor(**params)
model.fit(
X_tr, y_tr,
categorical_feature=categorical_features,
eval_set=[(X_val, y_val)],
eval_metric='rmse',
callbacks=[
lgb.early_stopping(CONFIG['early_stopping_rounds'], verbose=False),
lgb.log_evaluation(0)
]
)
y_pred = model.predict(X_val)
rmse_scores.append(mean_squared_error(y_val, y_pred, squared=False))
trial.report(np.mean(rmse_scores), fold_idx)
if trial.should_prune():
raise optuna.TrialPruned()
return np.mean(rmse_scores)
study_s2 = optuna.create_study(
direction='minimize',
sampler=optuna.samplers.TPESampler(seed=CONFIG['random_state']),
pruner=optuna.pruners.MedianPruner(n_startup_trials=3, n_warmup_steps=2)
)
study_s2.optimize(objective_stage2, n_trials=CONFIG['n_trials'],
timeout=CONFIG['timeout'], show_progress_bar=False)
print(f"阶段2 最佳CV RMSE: {study_s2.best_value:.6f}")
训练最终两阶段模型
# 阶段1:划分验证集用于 early stopping
X_tr_s1, X_val_s1, y_tr_s1, y_val_s1 = train_test_split(
X_train, y_binary_train, test_size=CONFIG['val_size'], random_state=CONFIG['random_state']
)
best_params_s1 = {**FIXED_PARAMS_CLF, **study_s1.best_params}
final_model_s1 = lgb.LGBMClassifier(**best_params_s1)
final_model_s1.fit(
X_tr_s1, y_tr_s1,
categorical_feature=categorical_features,
eval_set=[(X_val_s1, y_val_s1)],
callbacks=[
lgb.early_stopping(CONFIG['early_stopping_rounds'], verbose=True),
lgb.log_evaluation(100)
]
)
# 阶段2:在 y>0 子集上划分验证集
X_tr_s2, X_val_s2, y_tr_s2, y_val_s2 = train_test_split(
X_train_pos, y_train_pos, test_size=CONFIG['val_size'], random_state=CONFIG['random_state']
)
best_params_s2 = {**FIXED_PARAMS_REG, **study_s2.best_params}
final_model_s2 = lgb.LGBMRegressor(**best_params_s2)
final_model_s2.fit(
X_tr_s2, y_tr_s2,
categorical_feature=categorical_features,
eval_set=[(X_val_s2, y_val_s2)],
eval_metric='rmse',
callbacks=[
lgb.early_stopping(CONFIG['early_stopping_rounds'], verbose=True),
lgb.log_evaluation(100)
]
)
综合评估
def mean_absolute_percentage_error(y_true, y_pred):
y_true = np.asarray(y_true, dtype=float)
return np.mean(np.abs((y_true - y_pred) / np.where(y_true == 0, np.nan, y_true))) * 100
def two_stage_predict(model_clf, model_reg, X):
"""两阶段预测:P(y>0) * E[y|y>0]。"""
p_positive = model_clf.predict_proba(X)[:, 1]
expected_if_positive = model_reg.predict(X)
return p_positive * expected_if_positive
def evaluate_two_stage(model_clf, model_reg, X_train, y_train, X_test, y_test,
bucket_thresholds=None):
"""
两阶段模型综合评估。
bucket_thresholds: 业务决策桶阈值,例如 [0.5, 1.5, 3.0] 表示 4 个桶。
"""
y_train_pred = two_stage_predict(model_clf, model_reg, X_train)
y_test_pred = two_stage_predict(model_clf, model_reg, X_test)
# 阶段1 AUC
y_binary_test = (y_test > 0).astype(int)
p_positive_test = model_clf.predict_proba(X_test)[:, 1]
auc_test = roc_auc_score(y_binary_test, p_positive_test)
# 阶段2 R2
mask_test = y_test > 0
r2_pos = r2_score(y_test[mask_test], model_reg.predict(X_test[mask_test]))
metric_fns = {
'MAE': mean_absolute_error,
'RMSE': lambda y, p: np.sqrt(mean_squared_error(y, p)),
'R2': r2_score,
'MAPE(%)': mean_absolute_percentage_error,
}
metrics = {
name: {'train': fn(y_train, y_train_pred), 'test': fn(y_test, y_test_pred)}
for name, fn in metric_fns.items()
}
print(f"阶段1 AUC: {auc_test:.4f}")
print(f"阶段2 R2 (y>0): {r2_pos:.4f}")
print(f"{'指标':<10} | {'训练集':>12} | {'测试集':>12} | {'Gap':>12}")
print("-" * 58)
for name, v in metrics.items():
print(f"{name:<10} | {v['train']:>12.6f} | {v['test']:>12.6f} | {v['test']-v['train']:>+12.6f}")
# 业务决策桶准确率
if bucket_thresholds is None:
bucket_thresholds = [0.5, 1.5, 3.0]
def to_bucket(v, thresholds):
for i, t in enumerate(thresholds):
if v < t:
return i
return len(thresholds)
pred_buckets = np.array([to_bucket(p, bucket_thresholds) for p in y_test_pred])
true_buckets = np.array([to_bucket(v, bucket_thresholds) for v in y_test.values])
print("\n业务决策桶分类报告:")
print(classification_report(true_buckets, pred_buckets, digits=4))
return metrics
metrics = evaluate_two_stage(final_model_s1, final_model_s2,
X_train, y_train, X_test, y_test)
笛卡尔积全量组合预测导出
def export_cartesian_predictions(model_clf, model_reg, X_train,
feature_cols_for_cartesian,
output_dir='./predictions_output',
extra_feature_defaults=None):
"""
对指定特征生成笛卡尔积,用两阶段模型预测,并导出为 CSV。
适用于运营后台按特征组合查询预测分值的场景。
"""
os.makedirs(output_dir, exist_ok=True)
model_features = list(X_train.columns)
# 1. 提取各特征唯一值
unique_values = {}
for col in feature_cols_for_cartesian:
unique_values[col] = sorted(X_train[col].unique().tolist())
# 2. 处理未纳入笛卡尔积的特征
missing = [c for c in feature_cols_for_cartesian if c not in model_features]
extra = [c for c in model_features if c not in feature_cols_for_cartesian]
if missing:
raise ValueError(f"以下特征不在模型输入中: {missing}")
default_values = extra_feature_defaults or {}
for col in extra:
if col not in default_values:
default_values[col] = X_train[col].mode()[0]
total = np.prod([len(v) for v in unique_values.values()])
print(f"笛卡尔积总组合数: {total:,}")
# 3. 生成笛卡尔积
combinations = list(product(*[unique_values[col] for col in feature_cols_for_cartesian]))
df_cartesian = pd.DataFrame(combinations, columns=feature_cols_for_cartesian)
for col in feature_cols_for_cartesian:
df_cartesian[col] = df_cartesian[col].astype(X_train[col].dtype)
for col, val in default_values.items():
df_cartesian[col] = val
df_cartesian = df_cartesian[model_features]
# 4. 两阶段预测
p_positive = model_clf.predict_proba(df_cartesian)[:, 1]
expected_if_positive = model_reg.predict(df_cartesian)
y_pred = p_positive * expected_if_positive
# 5. 导出结果
df_result = df_cartesian[feature_cols_for_cartesian].copy()
df_result['p_positive'] = np.round(p_positive, 6)
df_result['expected_if_positive'] = np.round(expected_if_positive, 6)
df_result['predicted_value'] = np.round(y_pred, 6)
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
df_result.to_csv(os.path.join(output_dir, f'predictions_{timestamp}.csv'),
index=False, encoding='utf-8-sig')
df_result.to_csv(os.path.join(output_dir, 'predictions_latest.csv'),
index=False, encoding='utf-8-sig')
return df_result
# 使用示例:
# FEATURE_COLS_FOR_CARTESIAN = ['feat_a', 'feat_b', 'feat_c', ...]
# export_cartesian_predictions(final_model_s1, final_model_s2, X_train,
# FEATURE_COLS_FOR_CARTESIAN)
一句话总结
目标变量长尾分布下,LightGBM 优化的核心不是调参,而是”让模型解决正确的问题”——通过截断对齐决策粒度、选择匹配分布的损失函数、用两阶段建模拆解难度、用 AUC 分层法定位特征瓶颈。
避坑清单
- 看到零值多就直接用 Tweedie(先验证是否真零膨胀)
- 截断后继续用回归而不评估分类(截断后可能是分类问题)
- 只看 R²/RMSE 不看业务桶准确率(指标可能虚假提升)
- 单回归模型一步到位(两个难度不同的任务应拆分)
- 跳过分布分析直接调参(诊断优先于优化)
- 用 MSE 拟合计数数据(计数数据用 Poisson)
- 忽略特征诊断只改模型(瓶颈可能在特征而非模型)





