返回列表

6th Place: Select Feature Combinations based on RMSE Scores

649. Playground Series - Season 5, Episode 4 | playground-series-s5e4

开始: 2025-04-01 结束: 2025-04-30 音视频处理 数据算法赛
第 6 名:基于 RMSE 分数选择特征组合
作者: masaishi (EXPERT)
排名: 第 6 名
发布时间: 2025-05-01

第 6 名:基于 RMSE 分数选择特征组合

仓库:https://github.com/masaishi/kaggle-s5e4-6th-solution

更新: 我发布了训练代码,使用 5 折交叉验证的单个 LightGBM 模型可以达到 11.63 的私有分数。
代码:Public 11.70 | Private 11.63 | Single LGB Fold 5

TL;DR (太长不看版)

  • 识别了数据泄露 (?) 并应用了针对性修正
  • 将原始数据作为新行加入
  • 基于 RMSE 分数选择特征组合

数据泄露 1:Episode_Length_minutes 中超过 2 位小数

我验证了 AngelosMar 在 这个讨论帖 中最初分享的数据泄露。我自己的验证 EDA 可以在这里找到:Decimal Digits Analysis EDA

分析显示,Episode_Length_minutes 中超过 2 位小数 的数据点与实际的 Listening_Time_minutes strongly correlated (强相关)。在集成预测后,我使用公式 Episode_Length_minutes * 0.9554 覆盖了这些值。

df_train.filter(pl.col("Episode_Length_minutes_Decimal_Len") > 2)
Data Leak 1 Analysis

https://www.kaggle.com/code/masaishi/decimal-digits-analysis-eda?scriptVersionId=236025089&cellId=7

数据泄露 2:异常的 Number_of_Ads 值

通常,Number_of_Ads 的范围是 0-3。然而,我发现了 7 个实例,其值超过 3,这些异常值与 Listening_Time_minutes closely matched ( closely 匹配)。对于这些情况,我在集成预测后应用了 Number_of_Ads * 1.0588

df_train.filter(pl.col("Number_of_Ads") > 3.0)
Data Leak 2 Analysis

https://www.kaggle.com/code/masaishi/decimal-digits-analysis-eda?scriptVersionId=236025089&cellId=8

数据泄露 3:具有相同特征的记录具有相似的收听时间

由于本次比赛使用的是合成数据(如描述中所述),我假设某些特征组合将具有一致的收听时间。我系统地评估了特征组合,以识别收听时间变异性低的组(使用组均值预测时 RMSE 低)。我稍后会写如何计算。

以下四个特征组合在用组均值覆盖预测时显示出显著的 LB 改进:

[
    "Host_Popularity_percentage-Guest_Popularity_percentage-Episode_Num",
    "Host_Popularity_percentage-Guest_Popularity_percentage-ELen_Int",
    "Publication_Day-Guest_Popularity_percentage-ELen_Int-HPperc_Int",
    "Guest_Popularity_percentage-Episode_Num-ELen_Int-HPperc_Int",
]
Data Leak 3 Analysis

特征工程

我实施了许多特征转换,包括:

  1. 周期性日/时间特征 - 将基于时间的特征转换为 sin/cos 表示
  2. 比率特征 - 在现有特征之间创建有意义的比率
  3. 整数/小数分离 - 将数值特征拆分为整数和小数部分
  4. 情感特征 - 将分类情感数据数值化编码
  5. 多项式特征 - 创建重要变量的平方和立方版本
df = df.with_columns(
      # Day features
      pl.col("Publication_Day").cast(pl_f_type).mul(2 * np.pi / 7).sin().alias("Day_sin"),
      pl.col("Publication_Day").cast(pl_f_type).mul(2 * np.pi / 7).cos().alias("Day_cos"),
      pl.col("Publication_Day").cast(pl_f_type).mul(4 * np.pi / 7).sin().alias("Day_sin2"),
      pl.col("Publication_Day").cast(pl_f_type).mul(4 * np.pi / 7).cos().alias("Day_cos2"),
      # Time features
      pl.col("Publication_Time").cast(pl_f_type).mul(2 * np.pi / 4).sin().alias("Time_sin"),
      pl.col("Publication_Time").cast(pl_f_type).mul(2 * np.pi / 4).cos().alias("Time_cos"),
      pl.col("Publication_Time").cast(pl_f_type).mul(4 * np.pi / 24).sin().alias("Time_sin2"),
      pl.col("Publication_Time").cast(pl_f_type).mul(4 * np.pi / 24).cos().alias("Time_cos2"),
      # Ratio features
      (pl.col("Episode_Length_minutes") / (pl.col("Number_of_Ads") + 1)).fill_null(0).alias("Length_per_Ads"),
      (pl.col("Episode_Length_minutes") / (pl.col("Host_Popularity_percentage") + 1)).fill_null(0).alias("Length_per_Host"),
      (pl.col("Episode_Length_minutes") / (pl.col("Guest_Popularity_percentage") + 1)).fill_null(0).alias("Length_per_Guest"),
      # Episode length features
      pl.col("Episode_Length_minutes").floor().alias("ELen_Int"),
      (pl.col("Episode_Length_minutes") - pl.col("Episode_Length_minutes").floor()).alias("ELen_Dec"),
      pl.col("Host_Popularity_percentage").floor().alias("HPperc_Int"),
      (pl.col("Host_Popularity_percentage") - pl.col("Host_Popularity_percentage").floor()).alias("HPperc_Dec"),
      # Sentiment features
      (pl.col("Episode_Sentiment") == "2").cast(pl.Int8).alias("Is_Positive_Sentiment"),
      pl.when(pl.col("Episode_Sentiment") == "2").then(0.75).otherwise(0.717).cast(pl_f_type).alias("Sentiment_Multiplier"),
      # Squared features
      (pl.col("Episode_Length_minutes") ** 2).alias("Episode_Length_squared"),
      (pl.col("Episode_Length_minutes") ** 3).alias("Episode_Length_squared2"),
  )

  df = df.with_columns(
      (np.sin(2 * np.pi * pl.col("Episode_Num") / 100)).alias("Long_Term_Cycle_Sin"),
      (np.cos(2 * np.pi * pl.col("Episode_Num") / 100)).alias("Long_Term_Cycle_Cos"),
      (pl.col("Episode_Length_minutes") * pl.col("Sentiment_Multiplier")).alias("Expected_Listening_Time_Sentiment"),
  )

  df = df.with_columns(
      (
          (pl.col("Episode_Length_minutes") - pl.col("Episode_Length_minutes").median()).pow(2)
          + (pl.col("Host_Popularity_percentage") - pl.col("Host_Popularity_percentage").median()).pow(2)
          + (pl.col("Guest_Popularity_percentage") - pl.col("Guest_Popularity_percentage").median()).pow(2)
          + (pl.col("Number_of_Ads") - pl.col("Number_of_Ads").median()).pow(2)
      ).alias("Diff_Squared")
  )

最有影响力的特征仅仅是 pl.col("Episode_Length_minutes").floor().alias("ELen_Int") - 提取剧集长度的整数部分。

利用原始数据集

我通过两种方式 Incorporate 了这个原始数据集:

  1. 与比赛训练数据直接连接;“原始数据作为新列”
  2. 添加特定行作为“原始数据作为新行”

对于第二种方法,我的灵感来自 Chris Deotte 在 Binary Prediction with a Rainfall Dataset 比赛 (Playground Series - Season 5, Episode 3) 中的解决方案帖子,分享在 这里

该技术涉及在原始数据集中查找与我 target encoding 过程中识别的特征组组合相匹配的行。当我找到匹配的特征组时,我将这些原始行及其实际的 Listening_Time_minutes 值作为新训练数据添加。这本质上允许我用来自原始来源的额外 ground truth 值来丰富我的数据集。

for cols in combinations_list:
        n = f"pte_{'_'.join(cols)}"
        means = df_pltpd.group_by(cols).agg(pl.col("Listening_Time_minutes").mean().alias("mean_listening_time"))
        df = df.join(means, on=cols, how="left").with_columns(pl.col("mean_listening_time").fill_null(m).alias(n)).drop("mean_listening_time")

选择特征组合进行目标编码

代码:https://www.kaggle.com/code/masaishi/select-feature-combinations-by-rmse

我想分享我通过目标编码寻找最佳特征组合的方法。该方法系统地评估不同的特征分组,以识别哪些组合最能预测播客收听时间。

这是我实施的逐步过程:

  1. 首先,将数据集分为训练集 (80%) 和验证集 (20%):
  2. 生成要测试的特征组合(每次 1-4 个特征):
  3. 对于每个组合,通过连接特征值创建组:
# For first feature in combination
if comb[0] in df.select(cs.numeric()).columns:
    concat_expr = pl.col(comb[0]).round(round_num).cast(pl.Utf8)
else:
    concat_expr = pl.col(comb[0]).cast(pl.Utf8)

# Add remaining features to create group identifier
for col_name in comb[1:]:
    if col_name in df.select(cs.numeric()).columns:
        concat_expr = concat_expr + "_" + pl.col(col_name).round(round_num).cast(pl.Utf8)
    else:
        concat_expr = concat_expr + "_" + pl.col(col_name).cast(pl.Utf8)
  1. 计算训练数据中每个组的平均收听时间:
df_train = df_train.with_columns(
    concat_expr.alias("group").cast(pl.Categorical)
)

df_group = df_train.group_by("group").agg(
    pl.col("Listening_Time_minutes").count().alias("count"),
    pl.col("Listening_Time_minutes").std().alias("std_listening_time"),
    pl.col("Listening_Time_minutes").mean().alias("mean_listening_time"),
    pl.col("Episode_Length_minutes").mean().alias("mean_episode_length"),
)
  1. 将这些组均值应用到验证集作为预测:
df_valid = df_valid.with_columns(
    concat_expr.alias("group").cast(pl.Categorical)
)
df_valid = df_valid.join(
    df_group[["group", "mean_listening_time"]],
    on="group",
    how="left",
)
  1. 计算每个组合的 RMSE 和其他统计信息:
std_distributes.append({
    "group": "-".join(comb),
    "cover_rate": df_group["count"].sum() / df_train["Listening_Time_minutes"].count(),
    "len": len(df_group),
    "mean_count": df_group["count"].mean(),
    "median_count": df_group["count"].median(),
    "std_count": df_group["count"].std(),
    "mean_std_listening_time": df_group["std_listening_time"].mean(),
    "rmse": calculate_rmse(df_valid["Listening_Time_minutes"], df_valid["mean_listening_time"]),
})
  1. 按 RMSE 对结果排序以找到最佳特征组合:
std_distributes = std_distributes.sort("rmse")

这种方法本质上是一种跨不同特征组合执行目标编码的系统方式。最佳组合提供了关于哪些播客属性最强烈影响收听时间的宝贵见解。

Feature Selection Results

模型集成

对于我的最终提交,我创建了一个集成,结合了几种不同的回归模型:

  • HistGradientBoostingRegressor
  • LGBMRegressor
  • SVR (Support Vector Regression)
  • TabNetRegressor
  • XGBRegressor

实验管理

有效的实验管理对我在这次比赛中的成功至关重要。我使用 Wandb 跟踪实验参数,同时实施了一个自动提交系统,其中包括验证分数和 Wandb 实验名称在提交消息中。这使我很容易识别哪些代码变更产生了最佳结果。

Experiment Management 0 Experiment Management 1

在整个比赛过程中进行了超过 1,000 次实验,从一开始就建立这种结构化环境被证明是无价的。截图示例显示了我的跟踪系统如何在视觉上组织结果。

我还仔细构建了代码架构以最大化灵活性:

src/
├── config.py
├── data
│   ├── data_class.py
│   ├── data_process.py
│   ├── default_selecteds.py
│   ├── encoders.py
│   ├── feature_eng.py
│   ├── simple_data_process.py
│   ├── simple_feature_eng.py
│   └── tabnet_feature_eng.py
├── main.py
├── models
│   ├── hgbr.py
│   ├── lgb.py
│   ├── svr.py
│   ├── tabnet.py
│   ├── test.py
│   └── xgb.py
└── utils.py

每个模型都遵循一致的接口,具有标准化的输入/输出格式:

def train_model(fold: int, datasetXy: DatasetXy) -> tuple[float, list | None]:
    # Model implementation

@dataclass
class DatasetXy:
    X_train: pl.DataFrame
    y_train: pl.Series
    X_valid: pl.DataFrame
    y_valid: pl.Series
    X_test: Optional[pl.DataFrame] = None
    y_test: Optional[pl.Series] = None

这种设计使我很容易测试新模型或将成功的特征工程技术应用于不同的算法。我的配置系统还允许在预测和评估模式之间快速切换。

虽然这些细节可能看起来微不足道,但它们显著增强了我在整个比赛过程中高效迭代的能力。

特别感谢:

同比赛其他方案