背景:长尾分布的建模困境

什么是零膨胀长尾分布

在真实业务场景中,大量目标变量呈现零膨胀长尾分布(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)
  • 忽略特征诊断只改模型(瓶颈可能在特征而非模型)
0