Bony1029

导航

⚡ S6E9 电动汽车购买:用数据配方做可视化 EDA

⚡ S6E9 电动汽车购买:用数据配方做可视化 EDA

大家好!👋

在本 notebook 中,我将一步一步地梳理 Predicting Electric Vehicle Purchases 的数据。

计划如下:

  1. 初步查看各列(比赛数据 vs. 原始数据集)
  2. 看看哪些特征真正影响购买率
  3. 检查原始数据背后的两个隐藏公式,以及一个零训练的公式能达到什么水平
  4. 看看数值列中一些树模型喜欢的怪异之处

评估指标是 Will_Buy_EV 上的 ROC-AUC。

import glob
import warnings

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
from sklearn.metrics import roc_auc_score

warnings.filterwarnings("ignore")
sns.set_theme(style="whitegrid", palette="Set2")

0. 加载数据

除了比赛文件之外,我还附上了生成比赛数据所用的原始数据集。

# Find the competition files wherever Kaggle mounted them
train_path = [p for p in glob.glob("/kaggle/input/**/train.csv", recursive=True) if "s6e9" in p or "playground" in p][0]
test_path = train_path.replace("train.csv", "test.csv")

# The original (small) dataset
orig_path = glob.glob("/kaggle/input/**/EV_Adoption_and_Range_Anxiety_Dataset.csv", recursive=True)[0]

train = pd.read_csv(train_path)
test = pd.read_csv(test_path)
orig = pd.read_csv(orig_path)

# Numeric target: 1 = will buy an EV
train["y"] = (train["Will_Buy_EV"] == "Yes").astype(int)
orig["y"] = (orig["Will_Buy_EV"] == "Yes").astype(int)

print(f"train    : {train.shape}")
print(f"test     : {test.shape}")
print(f"original : {orig.shape}")
print()
print(f"purchase rate in train    : {train['y'].mean():.2%}")
print(f"purchase rate in original : {orig['y'].mean():.2%}")
train    : (668665, 16)
test     : (286571, 14)
original : (10000, 16)

purchase rate in train    : 17.46%
purchase rate in original : 17.50%
train.head()
id  Age  Annual_Income_USD  Daily_Commute_km  Number_of_Cars_Owned  \
0   0   66            92887.0              23.4                     2   
1   1   38            30000.0               5.0                     1   
2   2   26            94389.0              36.8                     1   
3   3   66            73580.0              23.7                     2   
4   4   54            57898.0              50.8                     1   

   Charging_Stations_Near_Home  Charging_Stations_Near_Work  \
0                            3                            7   
1                            2                            2   
2                            8                           15   
3                            6                            9   
4                            2                            3   

   Environmental_Concern_Level  Gender City_Type Current_Car_Type  \
0                          1.0    Male  Suburban            Sedan   
1                          4.0    Male     Rural              SUV   
2                          5.0  Female     Urban            Sedan   
3                          3.0    Male  Suburban        Hatchback   
4                          3.0    Male  Suburban        Hatchback   

  Home_Charging_Possible Subsidy_Available Range_Anxiety_Level Will_Buy_EV  y  
0                    Yes                No                 Low          No  0  
1                    Yes                No                 Low          No  0  
2                     No               Yes                 Low         Yes  1  
3                    Yes                No                 Low          No  0  
4                    Yes                No                 Low          No  0

1. 数据概览

比赛数据没有缺失值。

原始数据集在 income、commute 和 environmental concern 上约有 2% 的缺失。

共有 7 个数值列和 6 个类别列。

numeric_cols = [
    "Age",
    "Annual_Income_USD",
    "Daily_Commute_km",
    "Number_of_Cars_Owned",
    "Charging_Stations_Near_Home",
    "Charging_Stations_Near_Work",
    "Environmental_Concern_Level",
]

categorical_cols = [
    "Gender",
    "City_Type",
    "Current_Car_Type",
    "Home_Charging_Possible",
    "Subsidy_Available",
    "Range_Anxiety_Level",
]

print("missing values in train :", int(train.isna().sum().sum()))

orig_missing = orig.isna().sum()
print("missing values in original :", orig_missing[orig_missing > 0].to_dict())
missing values in train : 0
missing values in original : {'Annual_Income_USD': 178, 'Daily_Commute_km': 181, 'Environmental_Concern_Level': 184}

我们来比较一下 train、test 和原始数据的数值分布。

fig, axes = plt.subplots(2, 4, figsize=(20, 8))

for ax, col in zip(axes.ravel(), numeric_cols):

    ax.hist(train[col], bins=60, density=True, alpha=0.6, label="train")
    ax.hist(test[col], bins=60, density=True, alpha=0.4, label="test")

    # Original data as a black outline
    ax.hist(orig[col].dropna(), bins=60, density=True, histtype="step", lw=2, color="black", label="original")

    ax.set_title(col)

axes[0, 0].legend()
axes.ravel()[-1].axis("off")

plt.tight_layout()
plt.show()

图1

train 和 test 几乎完全重叠,并且都紧密贴合原始分布。👍

2. 什么在影响购买率?

首先看类别列。

fig, axes = plt.subplots(2, 3, figsize=(18, 9))

for ax, col in zip(axes.ravel(), categorical_cols):

    # Purchase rate and group size for each category
    stats = train.groupby(col)["y"].agg(["mean", "size"]).sort_values("mean")

    ax.bar(stats.index.astype(str), stats["mean"], color=sns.color_palette("Set2")[: len(stats)])

    # Label each bar with its rate and row count
    for i, (rate, size) in enumerate(zip(stats["mean"], stats["size"])):
        ax.text(i, rate, f"{rate:.1%}\nn={size:,}", ha="center", va="bottom", fontsize=9)

    ax.set_title(f"Purchase rate by {col}")
    ax.set_ylim(0, stats["mean"].max() * 1.3)

plt.tight_layout()
plt.show()

图2

Subsidy 和 range anxiety 明显占主导地位。

Gender、city type 和 car type 单独来看几乎不重要。

接下来看数值侧:income、environmental concern 和 age。

fig, axes = plt.subplots(1, 3, figsize=(18, 4.5))

# (column, number of quantile bins) - None means "use the raw values"
plot_specs = [
    ("Annual_Income_USD", 40),
    ("Environmental_Concern_Level", None),
    ("Age", 15),
]

for ax, (col, n_bins) in zip(axes, plot_specs):

    if n_bins is None:
        groups = train[col]
    else:
        groups = pd.qcut(train[col], n_bins, duplicates="drop")

    rate = train.groupby(groups, observed=True)["y"].mean()

    ax.plot(range(len(rate)), rate.values, marker="o")
    ax.set_title(f"Purchase rate vs {col}")

    # Show only a handful of tick labels so they stay readable
    step = max(1, len(rate) // 8)
    ax.set_xticks(range(0, len(rate), step))
    ax.set_xticklabels([str(label)[:12] for label in rate.index[::step]], rotation=30)

plt.tight_layout()
plt.show()

图3

Income 和 environmental concern 稳步推高购买率。

Age 基本持平。

3. 数据背后的配方 🧪

原始数据集遵循两个简单的公式(Chris Deotte 的 EDA 中有完整推导):

  • worry score = commute − 5 × (chargers near home) − 5 × (chargers near work) − 150 × (home charging) + noise
    • 然后切分为 Low / Medium / High 三档 range anxiety
  • buy score = 1.2 × income / 100k + 0.6 × concern + 2 × subsidy − 1 × [Medium anxiety] − 3 × [High anxiety] + noise
    • 若分数高于 5.5,则该人会购买

我们把它们写成简单的函数。

def buy_score(df):
    """Purchase score from the original data recipe (without the random noise)."""
    score = (
        1.2 * df["Annual_Income_USD"] / 100_000
        + 0.6 * df["Environmental_Concern_Level"]
        + 2.0 * (df["Subsidy_Available"] == "Yes")
        - 1.0 * (df["Range_Anxiety_Level"] == "Medium")
        - 3.0 * (df["Range_Anxiety_Level"] == "High")
    )
    return score


def worry_score(df):
    """Range-anxiety score from the original data recipe (without the random noise)."""
    score = (
        df["Daily_Commute_km"]
        - 5 * df["Charging_Stations_Near_Home"]
        - 5 * df["Charging_Stations_Near_Work"]
        - 150 * (df["Home_Charging_Possible"] == "Yes")
    )
    return score


train["buy_score"] = buy_score(train)
train["worry_score"] = worry_score(train)

auc = roc_auc_score(train["y"], train["buy_score"])
print(f"AUC of the buy-score formula (no training at all): {auc:.5f}")
AUC of the buy-score formula (no training at all): 0.93769
fig, axes = plt.subplots(1, 2, figsize=(16, 4.5))

# Left: purchase rate along the buy score
score_bins = pd.cut(train["buy_score"], np.arange(0, 10.01, 0.25))
rate = train.groupby(score_bins, observed=True)["y"].mean()

axes[0].plot([interval.mid for interval in rate.index], rate.values, marker="o")
axes[0].axvline(5.5, ls="--", color="black")
axes[0].set_title("Purchase rate along the buy score")
axes[0].set_xlabel("buy score")

# Right: range anxiety levels along the worry score
for level in ["Low", "Medium", "High"]:
    values = train.loc[train["Range_Anxiety_Level"] == level, "worry_score"]
    axes[1].hist(values, bins=80, alpha=0.6, density=True, label=level)

for cut in (-25, 75):
    axes[1].axvline(cut, ls="--", color="black")

axes[1].set_title("Range anxiety along the worry score")
axes[1].legend()

plt.tight_layout()
plt.show()

图4

一个无需训练的公式就已经达到约 0.938 AUC。🤯

调优良好的 GBDT 大约落在 0.946 左右,所以我们的模型其实是在争夺最后千分之几的差距。

4. 数值列中的怪异之处

数据生成器复用了原始数据集中的 income 值:

  • 在恰好 $30,000 处有一个尖峰(原始数据中的收入下限)
  • train 中几乎每个 income 值都原封不动地出现在原始文件中
original_incomes = set(orig["Annual_Income_USD"].dropna())
share_in_orig = train["Annual_Income_USD"].isin(original_incomes).mean()

print(f"train incomes that appear verbatim in the original : {share_in_orig:.1%}")
print(f"rows with income == 30,000                          : {(train['Annual_Income_USD'] == 30_000).mean():.2%}")
print(f"rows with commute == 5.0 km                         : {(train['Daily_Commute_km'] == 5).mean():.2%}")
train incomes that appear verbatim in the original : 97.9%
rows with income == 30,000                          : 9.21%
rows with commute == 5.0 km                         : 21.58%
income_counts = train["Annual_Income_USD"].value_counts()

fig, axes = plt.subplots(1, 2, figsize=(16, 4.5))

# Left: how often does each exact income value repeat?
axes[0].hist(np.log10(income_counts.values), bins=50)
axes[0].set_title("How often each exact income value repeats (log10 count)")

# Right: does the repeat count relate to the purchase rate?
income_freq = train["Annual_Income_USD"].map(income_counts)
freq_bins = pd.qcut(income_freq, 10, duplicates="drop")
rate = train.groupby(freq_bins, observed=True)["y"].mean()

axes[1].bar(range(len(rate)), rate.values)
axes[1].set_xticks(range(len(rate)))
axes[1].set_xticklabels([str(interval) for interval in rate.index], rotation=30)
axes[1].set_title("Purchase rate by income-value frequency")

plt.tight_layout()
plt.show()

图5

精确的 income 值会重复多次,而重复的次数本身携带信息。

这就是为什么频率编码和对精确 income 值做 out-of-fold 目标编码在这场比赛中对 GBDT 有帮助。

要点总结

  • 正如配方所说,Subsidy、range anxiety、income 和 environmental concern 承载了几乎全部信号。
  • 仅凭这个配方就能得到约 0.938 AUC,因此它是一个很好的起始特征。
  • 把 income 既当作数值又当作类别来处理(精确值、取整分桶),并且始终用 out-of-fold 方式进行编码。

感谢阅读!如果对你有帮助,欢迎点赞(upvote)。🙌

posted on 2026-09-28 14:20  Bony-  阅读(5)  评论(0)    收藏  举报