AI特征工程折腾手记

项目是这样的:根据用户最近30天的行为数据(点击、收藏、购买、停留时长等),预测接下来7天的购买概率。

数据大概长这样:

user_idclick_countfav_countbuy_countavg_durationlast_activedevice
10011562351203天前mobile
1002810451小时前web
1003000030天前mobile

问题背景

遇到的坑

项目是这样的:根据用户最近30天的行为数据(点击、收藏、购买、停留时长等),预测接下来7天的购买概率。数据大概长这样:

user_idclick_countfav_countbuy_countavg_durationlast_activedevice
10011562351203天前mobile
1002810451小时前web
1003000030天前mobile

表面上看数据挺完整,但实际问题一大堆:

  • 极端值:有的用户每天点击几千次,有的一个月只有1次,直接用原始数值会把模型带偏
  • 缺失值:有些用户从来没有收藏或购买,这些0到底是"没做过"还是"数据缺失"?
  • 时间含义丢失:用"3天前"、“1小时前"这样的相对时间,模型学不到真正的衰减模式
  • 高基数类别特征:user_id、设备类型这种字段直接丢进去会让模型过拟合或维度爆炸

最明显的结果是,模型对高频用户预测准确,对低频用户基本瞎猜。这不叫学习,这叫记忆。

整体思路

特征工程不是一蹴而就的,更像是一个迭代过程:

graph TD A[原始数据] --> B[数据清洗] B --> C[特征构造] C --> D[特征选择] D --> E[特征变换] E --> F[特征验证] F --> G{效果提升?} G -->|是| H[上线] G -->|否| B

图里看着挺清晰,实际做的时候每个环节都能踩几个坑。下面按顺序展开说。

数据清洗

处理极端值

先用统计学方法识别极端值,然后决定是截断、缩放还是丢弃。

import numpy as np
import pandas as pd

# 简单的3-sigma法识别极端值
def detect_outliers(df, column):
    mean = df[column].mean()
    std = df[column].std()
    return (df[column] > mean + 3*std) | (df[column] < mean - 3*std)

outliers = detect_outliers(df, 'click_count')
print(f"异常点击数量: {outliers.sum()}")

对数变换前后低频用户准确率对比:40% 提升到 65%

这张图解释了3-sigma法的核心思想:大部分数据会落在3个标准差内,超出这个范围就可能需要处理。

处理方式有几种:

  1. 截断:把超出范围的值固定在边界值上

    def cap_values(df, column, upper=None, lower=None):
        upper = upper or df[column].quantile(0.99)
        lower = lower or df[column].quantile(0.01)
        df[column] = df[column].clip(lower=lower, upper=upper)
    
  2. 对数变换:对偏态数据用对数压缩

    df['log_click_count'] = np.log1p(df['click_count'])
    
  3. 分桶:直接离散化,适合数值差异特别大的场景

    df['click_level'] = pd.cut(df['click_count'],
                               bins=[-1, 0, 10, 50, 1000],
                               labels=['无', '低', '中', '高'])
    

实际效果:对数变换后,高频用户的权重被压缩了,模型对低频用户的预测准确率从40%提升到了65%。

处理缺失值

这次遇到的缺失值有两类:真正的空值(字段没填)和语义上的0值(比如用户从来没买过东西)。处理方式完全不同:

  • 真正的空值:用均值、中位数、众数填充,或者直接删除

    df['avg_duration'] = df['avg_duration'].fillna(df['avg_duration'].median())
    
  • 语义0值:需要单独造特征

    df['has_buy_history'] = (df['buy_count'] > 0).astype(int)
    df['is_new_user'] = (df['click_count'] == 0).astype(int)
    

这点很关键:很多时候0不是没有信息,而是"没有正面信息”。单独造个特征把这种状态标出来,模型更容易学习。

特征构造

时间特征

原始数据里的相对时间(“3天前”)没法直接用。我把它转换成几个更直观的特征:

from datetime import datetime, timedelta

def parse_relative_time(time_str):
    if '小时前' in time_str:
        hours = int(time_str.replace('小时前', ''))
        return datetime.now() - timedelta(hours=hours)
    elif '天前' in time_str:
        days = int(time_str.replace('天前', ''))
        return datetime.now() - timedelta(days=days)
    return None

df['last_active_time'] = df['last_active'].apply(parse_relative_time)
df['days_since_last_active'] = (datetime.now() - df['last_active_time']).dt.days
df['is_recent_active'] = (df['days_since_last_active'] <= 3).astype(int)

这样处理后,时间特征就有了明确的数值含义,模型能学到"越近越重要"的衰减规律。

交互特征

单个特征往往信息量有限,组合起来才有意思:

# 点击转化率
df['click_to_buy_rate'] = df['buy_count'] / (df['click_count'] + 1)

# 活跃度评分(自定义规则)
df['activity_score'] = (
    df['click_count'] * 0.1 +
    df['fav_count'] * 0.3 +
    df['buy_count'] * 0.6
)

# 设备 × 活跃等级组合
df['device_activity'] = df['device'] + '_' + df['click_level'].astype(str)

特征工程优化前后:测试准确率、低频用户准确率与训练时间对比

这张图展示了交互特征的构建思路:两个原始特征通过某种运算生成新特征,往往能捕捉到单个特征看不到的模式。

特征选择

造完特征不代表都要用上。这次实际构造了47个特征,但最后只用了21个。选特征的方法有几种:

相关性分析

import seaborn as sns
import matplotlib.pyplot as plt

# 计算特征与目标变量的相关性
correlations = df.corr()['target'].abs().sort_values(ascending=False)
print(correlations.head(10))

# 特征间相关性(避免共线性)
plt.figure(figsize=(12, 8))
sns.heatmap(df.corr(), cmap='coolwarm', center=0)
plt.title('特征相关性热力图')
plt.savefig('static/images/posts/270-ai-feature-engineering-raw-high-quality-features/correlation-heatmap.webp')

特征重要性

训练一个简单的模型(比如随机森林或XGBoost),看每个特征的重要性:

from sklearn.ensemble import RandomForestClassifier

rf = RandomForestClassifier(n_estimators=100, random_state=42)
rf.fit(X_train, y_train)

importance = pd.DataFrame({
    'feature': X_train.columns,
    'importance': rf.feature_importances_
}).sort_values('importance', ascending=False)

print(importance)

递归特征消除

逐步移除最不重要的特征,观察模型效果变化:

from sklearn.feature_selection import RFE
from sklearn.linear_model import LogisticRegression

selector = RFE(LogisticRegression(max_iter=1000), n_features_to_select=20)
selector.fit(X_train, y_train)

selected_features = X_train.columns[selector.support_]

实际效果:去掉相关性超过0.9的特征后,模型训练时间减少了40%,但准确率几乎没变化。

特征变换

标准化 vs 归一化

数值范围差异大的特征需要做缩放,否则会让梯度下降收敛变慢或某些算法失效:

from sklearn.preprocessing import StandardScaler, MinMaxScaler

# 标准化(均值0,方差1)
scaler = StandardScaler()
X_train_scaled = scaler.fit_transform(X_train)

# 归一化(映射到0-1)
minmax = MinMaxScaler()
X_train_normalized = minmax.fit_transform(X_train)

选择哪个?看数据和算法:

  • 有明确边界的特征用归一化(比如概率、百分比)
  • 正态分布或范围不固定的特征用标准化
  • 树模型(随机森林、XGBoost)通常不需要

类别特征编码

类别特征不能直接用数值编码(会引入顺序关系),常用方法:

# One-Hot编码(低基数类别)
df_encoded = pd.get_dummies(df, columns=['device'], drop_first=True)

# 目标编码(高基数类别)
from category_encoders import TargetEncoder

encoder = TargetEncoder()
df['user_id_encoded'] = encoder.fit_transform(df['user_id'], df['target'])

One-Hot、目标编码与嵌入在高基数场景下的取舍示意

这张图对比了几种编码方式的适用场景:低基数用One-Hot,高基数用目标编码或嵌入。

踩坑记录

坑1:数据泄露

最初我构造了"用户历史购买金额"作为特征,但发现训练集表现特别好,测试集很差。原因很简单:这个特征包含了未来信息(预测未来7天时,不应该用未来7天的数据)。

解决方式:严格按时间切分数据,确保所有特征都来自预测时间点之前。

# 正确的时间切分
train_data = df[df['date'] < '2026-06-01']
test_data = df[df['date'] >= '2026-06-01']

坑2:高基数类别

user_id这种高基数特征直接One-Hot编码会爆炸。尝试过几种方法:

  • 直接删除:效果很差,用户差异信息丢了
  • 统计聚合:比如"每个用户平均点击数",但会泄露信息
  • 目标编码:效果好,但容易过拟合

最后用了分层采样 + 目标编码的混合方案,在数据泄露和特征信息量间找平衡。

坑3:过拟合

特征太多、太复杂时容易过拟合,表现就是训练集准确率接近100%,测试集只有70%左右。

解决方式:

  • 正则化:L1/L2正则、Dropout
  • 特征选择:砍掉低重要性特征
  • 交叉验证:用验证集提前发现过拟合
from sklearn.model_selection import cross_val_score

scores = cross_val_score(model, X_train, y_train, cv=5, scoring='accuracy')
print(f"交叉验证准确率: {scores.mean():.3f} ± {scores.std():.3f}")

最终结果

折腾一圈下来,主要改进点:

指标优化前优化后提升
测试集准确率68%89%+21%
低频用户准确率40%82%+42%
模型训练时间45分钟27分钟-40%
特征数量12个21个+75%

最有意思的是,模型结构几乎没变,只是换了特征工程方式。这让我意识到:在数据质量很差的情况下,再高级的模型也救不回来。

小结

特征工程不是什么高深理论,更像是一门手艺。你需要理解数据的业务含义,知道哪些信息有用,哪些是噪音,然后用合适的方式表达出来。

这次踩坑也让我明白几个道理:

  1. 先看数据,再选算法:数据质量决定了上限,算法只是逼近这个上限
  2. 简单特征优先:复杂特征不一定更好,先试简单的,确实不够再加
  3. 严格切分数据:数据泄露会让所有努力白费,宁可牺牲一点信息量
  4. 持续验证:每一步改动都要用验证集检查效果,不要一口气改完

最后,如果你也在做类似的预测项目,建议先花时间把特征工程做好,而不是上来就调模型。毕竟,一把再好的菜刀,也切不出烂肉的味道。

版权声明: 本文首发于 指尖魔法屋-AI特征工程折腾手记https://blog.thinkmoon.cn/post/270-ai-feature-engineering-raw-high-quality-features/) 转载或引用必须申明原指尖魔法屋来源及源地址!