什么是秩融合(Rank Fusion)-基于一个kaggle比赛的实践理解

从概率到排序:秩融合(Rank Fusion)详解------在航空公司满意度预测中把 AUC 提升到 0.96053

在二分类竞赛中,我们经常会训练多个模型:LightGBM 擅长处理数值特征和高阶切分,CatBoost 擅长处理类别特征和类别组合。单模型已经有不错的分数时,继续调参往往只能带来很小的收益。这时可以把多个模型的预测结果融合起来。

本篇以航空公司乘客满意度预测实验为例,介绍一种对 ROC-AUC 特别合适的融合方法:秩融合(rank fusion,也叫 rank averaging) 。我们从逻辑回归的 0.92045 做起,经过特征工程和模型改进,固定验证集上的 AUC 达到 0.96002;最终使用秩融合提交后,Kaggle public AUC 达到 0.96053,比最初 HGB 提交的 0.95728 提高了 0.00325。

比赛地址:https://www.kaggle.com/competitions/playground-series-s6e10/leaderboard

一、什么是秩融合

假设有 M M M 个模型,对同一批样本输出预测分数:

p 1 ( x ) , p 2 ( x ) , ... , p M ( x ) p_1(x),p_2(x),\ldots,p_M(x) p1(x),p2(x),...,pM(x)

概率融合会直接对这些分数求加权平均:

p p r o b ( x ) = ∑ m = 1 M w m p m ( x ) , ∑ m w m = 1 p_{prob}(x)=\sum_{m=1}^{M}w_m p_m(x), \qquad \sum_m w_m=1 pprob(x)=m=1∑Mwmpm(x),m∑wm=1

秩融合先在同一批样本内把每个模型的预测转换成名次或百分位,再融合名次。令 r m ( x ) r_m(x) rm(x) 表示模型 m m m 的归一化名次,则:

r m ( x ) = rank ⁡ ( p m ( x ) ) N r_m(x)=\frac{\operatorname{rank}(p_m(x))}{N} rm(x)=Nrank(pm(x))

其中 N N N 是样本数。最终分数为:

s ( x ) = ∑ m = 1 M w m r m ( x ) s(x)=\sum_{m=1}^{M}w_m r_m(x) s(x)=m=1∑Mwmrm(x)

在代码中,pandas.Series.rank(pct=True) 就可以完成百分位秩变换。若两个样本的原始预测相同,通常使用平均秩处理并列值。

一个简单例子:

样本 模型 A 原始分数 模型 B 原始分数 A 的百分位秩 B 的百分位秩
甲 0.90 0.55 1.00 0.67
乙 0.60 0.80 0.67 1.00
丙 0.20 0.10 0.33 0.33

如果两个模型等权,甲和乙的秩融合分数都是 0.835,说明两个模型对这两个样本的判断存在互补关系;丙仍然排在后面。注意,秩融合保留的是模型的相对顺序,不再把 0.90 和 0.80 当作具有可直接相加的概率。

二、为什么 ROC-AUC 适合秩融合

ROC-AUC 的含义是:随机抽取一个正样本和一个负样本,模型把正样本排在负样本前面的概率。因此,AUC 主要关心排序,不关心分数是否经过完美校准。

如果对所有预测分数应用同一个严格单调变换,样本顺序不会改变,AUC 也不会改变。秩变换正是把每个模型的输出映射为统一的百分位尺度:最小值接近 0,最大值接近 1。这样做有三个好处:

  1. 消除量纲和校准差异。 一个模型输出集中在 0.35~0.60,另一个模型输出接近 0~1 时,直接概率平均会让后者影响更大;秩变换后两者都在同一百分位尺度上。
  2. 让融合目标与评估指标一致。 竞赛评估的是 AUC,融合的也是各模型的排序信息。
  3. 利用模型互补性。 模型 A 排对了一部分样本,模型 B 在另一部分样本上更可靠;平均名次可以减少单模型的局部排序错误。

秩融合得到的分数是"排序分数",不是概率。它适合 AUC、平均精度等依赖排序的指标;如果业务需要概率校准、LogLoss、风险阈值或概率解释,就应优先使用校准后的概率融合。

三、我们的数据和单模型实验

本次任务是 playground-series-s6e10 航空公司满意度二分类预测。训练集有 699,635 行,测试集有 299,844 行,正类比例约为 0.4436。

第一版基线使用缺失值填充、类别独热和数值标准化:

模型 固定验证 AUC Kaggle public AUC
Logistic Regression 0.92045 ---
HistGradientBoosting 0.95699 0.95728

HGB 比逻辑回归高出 0.03654,说明满意度与延误、服务评分及类别变量之间存在明显的非线性关系。基线中加入了三个简单但有效的特征:到达延误缺失指示、总延误、13 个服务评分的均值。

继续分析数据后发现,Flight Distance 只有 3,474 个不同取值,很多取值重复出现。它更像是"航线标识",而不只是连续距离。于是我们增加了路线级特征:

  • route_count:该路线在 fit/query 数据中的出现次数;
  • 路线内服务评分、年龄、出发延误的均值;
  • Flight Distance 的平滑目标编码;
  • CatBoost 使用评分与 Class、Type of Travel、Customer Type 的 39 个类别交叉特征。

目标编码采用平滑公式:

T E ( g ) = sum ⁡ ( y g ) + α ⋅ p r i o r count ⁡ ( g ) + α TE(g)=\frac{\operatorname{sum}(y_g)+\alpha\cdot prior}{\operatorname{count}(g)+\alpha} TE(g)=count(g)+αsum(yg)+α⋅prior

项目中取全局正类比例为 prior,平滑参数为 (\alpha=25)。训练行的编码通过 5 折 OOF 生成,验证集和测试集只使用训练部分计算映射,避免把标签泄露给模型。

相关模型结果如下:

模型 固定验证 AUC Kaggle public AUC
route LightGBM 0.95958 0.95985
route CatBoost + 39 个交叉特征 0.95984 ---
route CatBoost / LightGBM 概率融合 0.96002 0.96040
全量 route LightGBM --- 0.95999
全量 LightGBM + route CatBoost 概率融合 --- 0.96051
LightGBM + route CatBoost 秩融合 --- 0.96053

这里有一个值得注意的实验结论:并不是特征越多越好。Flight Distance 与 Class、Type of Travel、Customer Type 的组合目标编码,在固定切分上只有 0.95951;加入全部组合后反而降到 0.95937。路线目标编码的平滑参数改为 5 或 100、删除原始距离列,也没有超过默认设置。秩融合的收益来自两个模型的排序互补,而不是继续堆叠一批未经验证的特征。

四、我们是怎样应用秩融合的

最终融合使用两个预测文件,并保持它们的测试行顺序和 id 完全一致:

  • LightGBM:路线目标编码、路线统计特征,使用全量训练集拟合,文件为 artifacts/submission_route_lgb_full.csv;
  • CatBoost:路线特征和评分-上下文类别交叉,文件为 artifacts/submission_route_cat.csv。

本次实验最终采用 LightGBM 0.4、CatBoost 0.6。实际融合代码可以写成:

python 复制代码
import pandas as pd

lgb = pd.read_csv("artifacts/submission_route_lgb_full.csv")
cat = pd.read_csv("artifacts/submission_route_cat.csv")

# 先确认两份文件的样本顺序一致
assert lgb["id"].equals(cat["id"])

# 在同一批测试样本内转为百分位秩;并列值使用平均秩
lgb_rank = lgb["satisfaction"].rank(method="average", pct=True)
cat_rank = cat["satisfaction"].rank(method="average", pct=True)

# AUC 任务中融合排序,而不是强行平均概率
score = 0.4 * lgb_rank + 0.6 * cat_rank

submission = pd.DataFrame({
    "id": lgb["id"],
    "satisfaction": score,
})
submission.to_csv("artifacts/submission_route_full_rankblend_04.csv", index=False)

这个过程不需要重新训练模型,计算成本只有两列预测值的排序和加权平均。0.4 和 0.6 不是模型的概率校准系数,而是两个模型在最终排序中的贡献比例。融合后的分数均值约为 0.5,这是百分位秩的正常现象,不代表测试集正类比例变成了 50%。

为了理解权重,可以把最终排序写成:

s ( x ) = 0.4 × r a n k L G B ( x ) + 0.6 × r a n k C a t ( x ) s(x)=0.4\times rank_{LGB}(x)+0.6\times rank_{Cat}(x) s(x)=0.4×rankLGB(x)+0.6×rankCat(x)

当两个模型对某个样本意见一致时,融合会强化这个顺序;当一个模型排得高、另一个模型排得低时,权重更大的 CatBoost 会产生更大影响。本次实验最终采用 CatBoost 0.6、LightGBM 0.4 的权重。

五、秩融合和概率融合的区别

概率融合的代码是:

python 复制代码
score = 0.4 * lgb["satisfaction"] + 0.6 * cat["satisfaction"]

它假设两个模型的输出具有相近的概率含义。这个假设在模型结构不同、正则化不同、训练轮数不同的时候未必成立。概率平均通常在模型校准较好、需要概率解释时更合适。

秩融合的代码是:

python 复制代码
score = (0.4 * lgb["satisfaction"].rank(pct=True)
         + 0.6 * cat["satisfaction"].rank(pct=True))

它只要求每个模型能提供有用的排序,不要求两个模型的分数刻度一致。对于本项目,概率融合的 public AUC 是 0.96051,秩融合为 0.96053,提升只有 0.00002,但这正是合理的:两个模型本身已经很强且相关性较高,秩融合主要修正少量边界样本的相对顺序,而不会产生大幅变化。

六、实际使用时的几个细节

第一,权重必须用 OOF 验证结果选择。 不能用训练集上的拟合预测调权重,因为训练预测过于乐观。更稳妥的做法是让每个模型在相同折上产生 OOF 预测,然后分别测试概率平均和秩平均。固定一次验证切分只能用于快速迭代,最终最好用多折 OOF 检查权重是否稳定。

第二,排序范围必须一致。 每个模型都应在同一批样本上独立排名,不能一个模型在训练集排名、另一个模型在测试集排名,也不能忘记对齐 id。若模型输出的是分批预测,需要先拼回完整测试集后再排名。

第三,要处理并列值。 method="average" 是常见选择;first 会依赖文件行顺序,可能产生不必要的偏差。若模型输出大量离散分数,秩融合的信息量会下降,可以考虑增加模型多样性或使用概率融合做对照。

第四,不要把秩分数当概率。 融合文件可以直接提交给 AUC 竞赛,但不能据此解释"某位乘客有 80% 的概率满意"。如果要做运营决策,应保留原始概率,单独进行 Platt scaling、Isotonic Regression 等校准。

第五,融合的前提是模型有互补性。 两个完全相同的模型只会重复同一套排序,平均后几乎没有收益。可以用预测相关系数、Spearman 相关系数,或统计两个模型在哪些正负样本对上意见不同,来判断是否值得融合。模型 AUC 略低并不意味着它没有价值,只要它能修正模型 B 的一部分错误排序,就可能提升融合结果。

七、复现实验

项目中的关键脚本和产物如下:

python 复制代码
project='/Users/mark/Nutstore Files/.symlinks/坚果云/CodeFile/pycharmProject/2026_predicting-airline-satisfaction'
export DYLD_LIBRARY_PATH=/opt/miniconda3/pkgs/llvm-openmp-22.1.8-hc7d1edf_0/lib
export PYTHONPATH="$project/src"

"$project/.venv/bin/python" "$project/src/train_route_lgb_full.py"
"$project/.venv/bin/python" "$project/src/train_route_cat.py"

验证指标保存在 artifacts/metrics_*.json,预测文件保存在 artifacts/。最终秩融合文件是:

text 复制代码
artifacts/submission_route_full_rankblend_04.csv

八、完整源码(按当前项目文件)

上一节的两行命令是运行入口,下面列出当前实验中实际使用的源码文件。四个文件放在项目的 src/ 目录下,文件名和代码块标题一一对应。先运行 train_route_lgb_full.py 和 train_route_cat.py,再使用前文的秩融合代码生成最终提交文件。

src/train_baseline.py:基础特征与数据预处理

python 复制代码
from __future__ import annotations

import argparse
import json
import time
from pathlib import Path

import numpy as np
import pandas as pd
from sklearn.compose import ColumnTransformer
from sklearn.ensemble import HistGradientBoostingClassifier
from sklearn.impute import SimpleImputer
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import roc_auc_score
from sklearn.model_selection import train_test_split
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler


ROOT = Path(__file__).resolve().parents[1]
DATA = ROOT / "data"
ARTIFACTS = ROOT / "artifacts"


def load_data() -> tuple[pd.DataFrame, pd.DataFrame, pd.Series, pd.Series]:
    train = pd.read_csv(DATA / "train.csv")
    test = pd.read_csv(DATA / "test.csv")
    y = train.pop("satisfaction").astype(int)
    train = train.drop(columns=["id"])
    test_ids = test["id"].copy()
    test = test.drop(columns=["id"])
    return train, test, y, test_ids


def add_features(df: pd.DataFrame) -> pd.DataFrame:
    out = df.copy()
    # The missing arrival delay is informative, so retain an indicator.
    out["arrival_delay_missing"] = out["Arrival Delay in Minutes"].isna().astype("int8")
    out["total_delay"] = (
        out["Departure Delay in Minutes"]
        + out["Arrival Delay in Minutes"].fillna(out["Arrival Delay in Minutes"].median())
    )
    out["service_mean"] = out[
        [
            "Inflight wifi service",
            "Departure/Arrival time convenient",
            "Ease of Online booking",
            "Gate location",
            "Food and drink",
            "Online boarding",
            "Seat comfort",
            "Inflight entertainment",
            "On-board service",
            "Leg room service",
            "Baggage handling",
            "Checkin service",
            "Cleanliness",
        ]
    ].mean(axis=1)
    return out


def make_logistic(numeric: list[str], categorical: list[str]) -> Pipeline:
    numeric_pipe = Pipeline(
        [
            ("impute", SimpleImputer(strategy="median", add_indicator=True)),
            ("scale", StandardScaler()),
        ]
    )
    categorical_pipe = Pipeline(
        [
            ("impute", SimpleImputer(strategy="most_frequent")),
            ("onehot", OneHotEncoder(handle_unknown="ignore")),
        ]
    )
    pre = ColumnTransformer(
        [("num", numeric_pipe, numeric), ("cat", categorical_pipe, categorical)],
        sparse_threshold=0.3,
    )
    return Pipeline(
        [
            ("pre", pre),
            (
                "model",
                LogisticRegression(
                    C=1.0,
                    solver="lbfgs",
                    max_iter=100,
                    random_state=42,
                ),
            ),
        ]
    )


def make_hist_gradient_boosting(numeric: list[str], categorical: list[str]) -> Pipeline:
    # One-hot features are few (the categorical columns have small cardinality),
    # so a dense representation is practical for this lightweight baseline.
    pre = ColumnTransformer(
        [
            (
                "num",
                SimpleImputer(strategy="median", add_indicator=True),
                numeric,
            ),
            (
                "cat",
                Pipeline(
                    [
                        ("impute", SimpleImputer(strategy="most_frequent")),
                        ("onehot", OneHotEncoder(handle_unknown="ignore", sparse_output=False)),
                    ]
                ),
                categorical,
            ),
        ],
        sparse_threshold=0.0,
    )
    return Pipeline(
        [
            ("pre", pre),
            (
                "model",
                HistGradientBoostingClassifier(
                    learning_rate=0.08,
                    max_iter=180,
                    max_leaf_nodes=31,
                    min_samples_leaf=30,
                    l2_regularization=1.0,
                    random_state=42,
                ),
            ),
        ]
    )


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("--model", choices=["logistic", "hgb"], default="logistic")
    args = parser.parse_args()

    ARTIFACTS.mkdir(exist_ok=True)
    train, test, y, test_ids = load_data()
    train = add_features(train)
    test = add_features(test)
    categorical = train.select_dtypes(include=["object", "string", "category"]).columns.tolist()
    numeric = [c for c in train.columns if c not in categorical]
    x_train, x_valid, y_train, y_valid = train_test_split(
        train, y, test_size=0.2, stratify=y, random_state=42
    )

    model = (
        make_logistic(numeric, categorical)
        if args.model == "logistic"
        else make_hist_gradient_boosting(numeric, categorical)
    )
    started = time.time()
    model.fit(x_train, y_train)
    valid_pred = model.predict_proba(x_valid)[:, 1]
    valid_auc = roc_auc_score(y_valid, valid_pred)
    test_pred = model.predict_proba(test)[:, 1]
    submission = pd.DataFrame({"id": test_ids, "satisfaction": test_pred})
    output = ARTIFACTS / f"submission_{args.model}.csv"
    submission.to_csv(output, index=False)
    pd.DataFrame({"row_index": x_valid.index, "satisfaction": valid_pred}).to_csv(
        ARTIFACTS / f"validation_{args.model}.csv", index=False
    )
    metrics = {
        "model": args.model,
        "valid_auc": float(valid_auc),
        "train_rows": int(len(x_train)),
        "valid_rows": int(len(x_valid)),
        "test_rows": int(len(test)),
        "elapsed_seconds": round(time.time() - started, 2),
        "submission": str(output),
    }
    (ARTIFACTS / f"metrics_{args.model}.json").write_text(
        json.dumps(metrics, ensure_ascii=False, indent=2), encoding="utf-8"
    )
    print(json.dumps(metrics, ensure_ascii=False, indent=2))


if __name__ == "__main__":
    main()

src/train_route_lgb.py:路线特征与 OOF 目标编码

python 复制代码
from __future__ import annotations

import json
import time
import argparse
from pathlib import Path

import numpy as np
import pandas as pd
from lightgbm import LGBMClassifier, early_stopping, log_evaluation
from sklearn.metrics import roc_auc_score
from sklearn.model_selection import StratifiedKFold, train_test_split

from train_baseline import add_features


ROOT = Path(__file__).resolve().parents[1]
DATA = ROOT / "data"
ARTIFACTS = ROOT / "artifacts"


RATING_COLS = [
    "Inflight wifi service",
    "Departure/Arrival time convenient",
    "Ease of Online booking",
    "Gate location",
    "Food and drink",
    "Online boarding",
    "Seat comfort",
    "Inflight entertainment",
    "On-board service",
    "Leg room service",
    "Baggage handling",
    "Checkin service",
    "Cleanliness",
]


def load_data():
    train = pd.read_csv(DATA / "train.csv")
    test = pd.read_csv(DATA / "test.csv")
    y = train.pop("satisfaction").astype(int)
    test_ids = test.pop("id")
    train = train.drop(columns=["id"])
    return add_features(train), add_features(test), y, test_ids


def smoothed_map(keys: pd.Series, labels: pd.Series, prior: float, alpha: float):
    stats = pd.DataFrame({"key": keys.to_numpy(), "y": labels.to_numpy()}).groupby("key").y.agg(
        ["sum", "count"]
    )
    return stats, (stats["sum"] + alpha * prior) / (stats["count"] + alpha)


def add_route_features(
    x_fit: pd.DataFrame,
    y_fit: pd.Series,
    x_query: pd.DataFrame,
    oof: bool = False,
    n_splits: int = 5,
    alpha: float = 25.0,
) -> pd.DataFrame:
    """Add leakage-safe target encoding and label-free route profile features."""
    fit = x_fit.copy()
    query = x_query.copy()
    prior = float(y_fit.mean())
    # Label-free route frequency and profile features are available at prediction time.
    route_all = pd.concat([fit, query], axis=0, ignore_index=True)
    route_counts = route_all["Flight Distance"].value_counts()
    fit["route_count"] = fit["Flight Distance"].map(route_counts).fillna(0).astype("float32")
    query["route_count"] = query["Flight Distance"].map(route_counts).fillna(0).astype("float32")
    profile_cols = ["Flight Distance"] + RATING_COLS + ["Age", "Flight Distance", "Departure Delay in Minutes"]
    profile_cols = list(dict.fromkeys(profile_cols))
    profile = fit[profile_cols].groupby("Flight Distance").mean(numeric_only=True)
    for col in [c for c in profile.columns if c != "Flight Distance"]:
        name = f"route_mean_{col.lower().replace(' ', '_').replace('/', '_')[:35]}"
        fit[name] = fit["Flight Distance"].map(profile[col])
        query[name] = query["Flight Distance"].map(profile[col])

    # Out-of-fold target encoding for fit rows; full-fit mapping for query rows.
    if oof:
        enc = pd.Series(index=fit.index, dtype="float64")
        kfold = StratifiedKFold(n_splits=n_splits, shuffle=True, random_state=42)
        for tr_idx, va_idx in kfold.split(fit, y_fit):
            tr_keys = fit.iloc[tr_idx]["Flight Distance"]
            tr_y = y_fit.iloc[tr_idx]
            _, mapping = smoothed_map(tr_keys, tr_y, prior, alpha)
            enc.iloc[va_idx] = fit.iloc[va_idx]["Flight Distance"].map(mapping).fillna(prior).to_numpy()
        fit["route_target_mean"] = enc.astype("float32")
    else:
        fit["route_target_mean"] = fit["Flight Distance"].map(
            smoothed_map(fit["Flight Distance"], y_fit, prior, alpha)[1]
        ).fillna(prior).astype("float32")
    query["route_target_mean"] = query["Flight Distance"].map(
        smoothed_map(x_fit["Flight Distance"], y_fit, prior, alpha)[1]
    ).fillna(prior).astype("float32")

    return fit, query


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("--drop-distance", action="store_true")
    parser.add_argument("--alpha", type=float, default=25.0)
    args = parser.parse_args()
    ARTIFACTS.mkdir(exist_ok=True)
    train, test, y, test_ids = load_data()
    tr_idx, va_idx = train_test_split(
        np.arange(len(train)), test_size=0.2, stratify=y, random_state=42
    )
    x_train, x_valid = train.iloc[tr_idx].copy(), train.iloc[va_idx].copy()
    y_train, y_valid = y.iloc[tr_idx], y.iloc[va_idx]
    x_train, x_valid = add_route_features(x_train, y_train, x_valid, oof=True, alpha=args.alpha)
    _, x_test = add_route_features(train.iloc[tr_idx].copy(), y_train, test, oof=False, alpha=args.alpha)
    if args.drop_distance:
        x_train = x_train.drop(columns=["Flight Distance"])
        x_valid = x_valid.drop(columns=["Flight Distance"])
        x_test = x_test.drop(columns=["Flight Distance"])

    categorical = x_train.select_dtypes(include=["object", "string", "category"]).columns.tolist()
    for c in categorical:
        x_train[c] = x_train[c].astype("category")
        x_valid[c] = x_valid[c].astype("category")
        x_test[c] = x_test[c].astype("category")

    started = time.time()
    model = LGBMClassifier(
        objective="binary",
        n_estimators=1800,
        learning_rate=0.035,
        num_leaves=63,
        min_child_samples=40,
        colsample_bytree=0.9,
        reg_alpha=0.1,
        reg_lambda=2.0,
        random_state=42,
        n_jobs=-1,
        verbosity=-1,
    )
    model.fit(
        x_train,
        y_train,
        categorical_feature=categorical,
        eval_set=[(x_valid, y_valid)],
        callbacks=[early_stopping(100, verbose=False), log_evaluation(0)],
    )
    valid_pred = model.predict_proba(x_valid)[:, 1]
    test_pred = model.predict_proba(x_test)[:, 1]
    valid_auc = roc_auc_score(y_valid, valid_pred)
    base = "route_lgb_no_distance" if args.drop_distance else "route_lgb"
    suffix = base if args.alpha == 25.0 else f"{base}_a{str(args.alpha).replace('.', 'p')}"
    pd.DataFrame({"id": test_ids, "satisfaction": test_pred}).to_csv(
        ARTIFACTS / f"submission_{suffix}.csv", index=False
    )
    pd.DataFrame({"row_index": va_idx, "satisfaction": valid_pred}).to_csv(
        ARTIFACTS / f"validation_{suffix}.csv", index=False
    )
    metrics = {
        "model": suffix,
        "valid_auc": float(valid_auc),
        "best_iteration": int(model.best_iteration_ or model.n_estimators),
        "elapsed_seconds": round(time.time() - started, 2),
    }
    (ARTIFACTS / f"metrics_{suffix}.json").write_text(
        json.dumps(metrics, indent=2), encoding="utf-8"
    )
    print(json.dumps(metrics, indent=2))


if __name__ == "__main__":
    main()

src/train_route_cat.py:路线 CatBoost 与 39 个类别交叉特征

python 复制代码
from __future__ import annotations

import json
import time
from pathlib import Path

import numpy as np
import pandas as pd
from catboost import CatBoostClassifier
from sklearn.metrics import roc_auc_score
from sklearn.model_selection import train_test_split

from train_baseline import add_features
from train_route_lgb import RATING_COLS, add_route_features


ROOT = Path(__file__).resolve().parents[1]
DATA = ROOT / "data"
ARTIFACTS = ROOT / "artifacts"


def add_crosses(df: pd.DataFrame) -> pd.DataFrame:
    out = df.copy()
    for rating in RATING_COLS:
        for context in ["Class", "Type of Travel", "Customer Type"]:
            name = f"{rating[:18]}__x__{context}"
            out[name] = out[rating].astype(str) + "__" + out[context].astype(str)
    return out


def load_data():
    train = pd.read_csv(DATA / "train.csv")
    test = pd.read_csv(DATA / "test.csv")
    y = train.pop("satisfaction").astype(int)
    test_ids = test.pop("id")
    train = train.drop(columns=["id"])
    return add_features(train), add_features(test), y, test_ids


def main() -> None:
    ARTIFACTS.mkdir(exist_ok=True)
    train, test, y, test_ids = load_data()
    tr_idx, va_idx = train_test_split(
        np.arange(len(train)), test_size=0.2, stratify=y, random_state=42
    )
    x_train, x_valid = train.iloc[tr_idx].copy(), train.iloc[va_idx].copy()
    y_train, y_valid = y.iloc[tr_idx], y.iloc[va_idx]
    x_train, x_valid = add_route_features(x_train, y_train, x_valid, oof=True)
    _, x_test = add_route_features(train.iloc[tr_idx].copy(), y_train, test, oof=False)
    x_train, x_valid, x_test = map(add_crosses, [x_train, x_valid, x_test])

    categorical = x_train.select_dtypes(include=["object", "string", "category"]).columns.tolist()
    for c in categorical:
        x_train[c] = x_train[c].fillna("__MISSING__").astype(str)
        x_valid[c] = x_valid[c].fillna("__MISSING__").astype(str)
        x_test[c] = x_test[c].fillna("__MISSING__").astype(str)

    started = time.time()
    model = CatBoostClassifier(
        iterations=1800,
        depth=8,
        learning_rate=0.05,
        loss_function="Logloss",
        eval_metric="AUC",
        l2_leaf_reg=5.0,
        random_strength=0.5,
        random_seed=42,
        thread_count=-1,
        verbose=False,
        allow_writing_files=False,
    )
    model.fit(
        x_train,
        y_train,
        cat_features=categorical,
        eval_set=(x_valid, y_valid),
        early_stopping_rounds=100,
        verbose=False,
    )
    valid_pred = model.predict_proba(x_valid)[:, 1]
    test_pred = model.predict_proba(x_test)[:, 1]
    valid_auc = roc_auc_score(y_valid, valid_pred)
    pd.DataFrame({"id": test_ids, "satisfaction": test_pred}).to_csv(
        ARTIFACTS / "submission_route_cat.csv", index=False
    )
    pd.DataFrame({"row_index": va_idx, "satisfaction": valid_pred}).to_csv(
        ARTIFACTS / "validation_route_cat.csv", index=False
    )
    metrics = {
        "model": "route_cat",
        "valid_auc": float(valid_auc),
        "best_iteration": int(model.get_best_iteration() + 1),
        "elapsed_seconds": round(time.time() - started, 2),
    }
    (ARTIFACTS / "metrics_route_cat.json").write_text(
        json.dumps(metrics, indent=2), encoding="utf-8"
    )
    print(json.dumps(metrics, indent=2))


if __name__ == "__main__":
    main()

src/train_route_lgb_full.py:全量路线 LightGBM

python 复制代码
"""Fit the best route-target-encoded LightGBM on all rows using OOF encodings."""
from __future__ import annotations

import json
import time
from pathlib import Path

import pandas as pd
from lightgbm import LGBMClassifier

from train_baseline import add_features
from train_route_lgb import add_route_features

ROOT = Path(__file__).resolve().parents[1]
DATA = ROOT / "data"
ARTIFACTS = ROOT / "artifacts"


def main() -> None:
    train = pd.read_csv(DATA / "train.csv")
    test = pd.read_csv(DATA / "test.csv")
    y = train.pop("satisfaction").astype(int)
    test_ids = test.pop("id")
    train = add_features(train.drop(columns=["id"]))
    test = add_features(test)

    # OOF route TE prevents the booster from memorizing each training label;
    # the test mapping uses all available labels.
    x_train, x_test = add_route_features(train, y, test, oof=True, alpha=25.0)
    categorical = x_train.select_dtypes(include=["object", "string", "category"]).columns.tolist()
    for c in categorical:
        x_train[c] = x_train[c].astype("category")
        x_test[c] = x_test[c].astype("category")

    started = time.time()
    model = LGBMClassifier(
        objective="binary", n_estimators=860, learning_rate=0.035,
        num_leaves=63, min_child_samples=40, colsample_bytree=0.9,
        reg_alpha=0.1, reg_lambda=2.0, random_state=42, n_jobs=-1, verbosity=-1,
    )
    model.fit(x_train, y, categorical_feature=categorical)
    pred = model.predict_proba(x_test)[:, 1]
    out = ARTIFACTS / "submission_route_lgb_full.csv"
    pd.DataFrame({"id": test_ids, "satisfaction": pred}).to_csv(out, index=False)
    metrics = {"model": "route_lgb_full", "n_estimators": 860,
               "elapsed_seconds": round(time.time() - started, 2), "submission": str(out)}
    (ARTIFACTS / "metrics_route_lgb_full.json").write_text(json.dumps(metrics, indent=2), encoding="utf-8")
    print(json.dumps(metrics, indent=2))


if __name__ == "__main__":
    main()

这些脚本依赖 pandas、numpy、scikit-learn、lightgbm 和 catboost。数据文件需要放在项目 data/ 目录,训练脚本会把预测文件和指标写入 artifacts/。

博客中的秩融合代码位于前面的"我们是怎样应用秩融合的"一节;它读取 submission_route_lgb_full.csv 和 submission_route_cat.csv,不需要再次训练模型。

结语

秩融合的核心不是"把两个概率相加",而是先承认不同模型的分数刻度可能不可比,再把每个模型转换为同一批样本中的相对顺序。对于以 ROC-AUC 为目标的任务,这种处理既贴合指标,又能低成本利用模型互补性。

在本次航空满意度预测中,真正有效的路线是:先用路线级 OOF 目标编码和统计特征提升单模型,再用 LightGBM 与 CatBoost 的差异化排序做融合。最终从基线 HGB 的 public AUC 0.95728 提升到秩融合的 0.96053。分数提升不大,却说明在强模型阶段,正确的融合方式往往比继续盲目增加特征更有价值。


关键词: 机器学习、集成学习、模型融合、秩融合、Rank Averaging、ROC-AUC、LightGBM、CatBoost、Kaggle、航空公司满意度预测

相关推荐
moonsims3 小时前
面向高危现场作业空间安全智能节点AiBrainBox-S:多模态空间融合、动态风险区、身份-视觉轨迹关联、设备级实时安全推理
人工智能·数码相机·无人机
code2cat3 小时前
【随笔】LLM裁判如何打分:先写评分标准,再用人工案例校准
java·人工智能·llm·agent·评估·langsmith
别乱装3 小时前
Claude Code安装使用教程:终端AI编程助手入门与实操详解
人工智能
guslegend3 小时前
模型调用失败分类、重试与 Model Adapter
人工智能
华普微HOPERF3 小时前
BLE信道探测方案,开启物联网设备的“距离感知”时代
人工智能·物联网
晨间晓3 小时前
图片到底有多亮?用华为云码道开发 OpenCV 亮度分析工具
人工智能·opencv·华为云
CAD老兵3 小时前
中国 3D CAD 的突围之路:当 AI 开始挑战几何内核
人工智能·开源·github
Ai应用那点事3 小时前
开发者视角:无限画布工具选型需要关注哪些指标
人工智能
零基础1233 小时前
为什么 Agent 用 SQLite 数据库?
前端·人工智能·sqlite·开源