AI大赛-资金流入流出预测-树模型回归版
# 赛题
蚂蚁金服拥有上亿会员并且业务场景中每天都涉及大量的资金流入和流出,面对如此庞大的用户群,资金管理压力会非常大。在既保证资金流动性风险最小,又满足日常业务运转的情况下,精准地预测资金的流入流出情况变得尤为重要。此届大赛以《资金流入流出预测》为题,期望参赛者能够通过对例如余额宝用户的申购赎回数据的把握,精准预测未来每日的资金流入流出情况。对货币基金而言,资金流入意味着申购行为,资金流出为赎回行为 。
竞赛中使用的数据主要包含四个部分,分别为用户基本信息数据、用户申购赎回数据、收益率表和银行间拆借利率表。下面分别介绍四组数据。
- 用户信息表
用户信息表: user_profile_table 。 我们总共随机抽取了约 3 万用户,其中部分用户在 2014 年 9 月份第一次出现,这部分用户只在测试数据中 。因此用户信息表是约 2.8 万 个用户的基本数据,在原始数据的基础上处理后,主要包含了用户的性别、城市和星座。具体的字段如下表 1 :
表1用户信息表
| 列名 | 类型 | 含义 | 示例 |
|---|---|---|---|
| user_id | bigint | 用户 ID | 1234 |
| Sex | bigint | 用户性别( 1 :男, 0 :女 ) | 0 |
| City | bigint | 所在城市 | 6081949 |
| constellation | string | 星座 | 射手座 |
- 用户申购赎回数据表
用户申购赎回数据表: user_balance_table 。里面有 20130701 至 20140831 申购和赎回信息、以及所有的子类目信息, 数据经过脱敏处理。脱敏之后的数据,基本保持了原数据趋势。数据主要包括用户操作时间和操作记录,其中操作记录包括申购和赎回两个部分。金额的单位是分,即 0.01 元人民币。 如果用户今日消费总量为0,即consume_amt=0,则四个字类目为空。
表格 2 :用户申购赎回数据
| 列名 | 类型 | 含义 | 示例 |
|---|---|---|---|
| user_id | bigint | 用户 id | 1234 |
| report_date | string | 日期 | 20140407 |
| tBalance | bigint | 今日余额 | 109004 |
| yBalance | bigint | 昨日余额 | 97389 |
| total_purchase_amt | bigint | 今日总购买量 = 直接购买 + 收益 | 21876 |
| direct_purchase_amt | bigint | 今日直接购买量 | 21863 |
| purchase_bal_amt | bigint | 今日支付宝余额购买量 | 0 |
| purchase_bank_amt | bigint | 今日银行卡购买量 | 21863 |
| total_redeem_amt | bigint | 今日总赎回量 = 消费 + 转出 | 10261 |
| consume_amt | bigint | 今日消费总量 | 0 |
| transfer_amt | bigint | 今日转出总量 | 10261 |
| tftobal_amt | bigint | 今日转出到支付宝余额总量 | 0 |
| tftocard_amt | bigint | 今日转出到银行卡总量 | 10261 |
| share_amt | bigint | 今日收益 | 13 |
| category1 | bigint | 今日类目 1 消费总额 | 0 |
| category2 | bigint | 今日类目 2 消费总额 | 0 |
| category3 | bigint | 今日类目 3 消费总额 | 0 |
| category4 | bigint | 今日类目 4 消费总额 | 0 |
注 1 :上述的数据都是经过脱敏处理的,收益为重新计算得到的,计算方法按照简化后的计算方式处理,具体计算方式在下节余额宝收益计算方式中描述。
注 2 :脱敏后的数据保证了今日余额 = 昨日余额 + 今日申购 - 今日赎回,不会出现负值。
- 收益率表
收益表为余额宝在 14 个月内的收益率表: mfd_day_share_interest 。具体字段如表格 3 中所示
| 列名 | 类型 | 含义 | 示例 |
|---|---|---|---|
| mfd_date | string | 日期 | 20140102 |
| mfd_daily_yield | double | 万份收益,即 1 万块钱的收益。 | 1.5787 |
| mfd_7daily_yield | double | 七日年化收益率( % ) | 6.307 |
- 上海银行间同业拆放利率(Shibor)表
银行间拆借利率表是 14 个月期间银行之间的拆借利率(皆为年化利率): mfd_bank_shibor 。具体字段如下表格 4 所示:
表格 4 银行间拆借利率表
| 列名 | 类型 | 含义 | 示例 |
|---|---|---|---|
| mfd_date | String | 日期 | 20140102 |
| Interest_O_N | Double | 隔夜利率(%) | 2.8 |
| Interest_1_W | Double | 1周利率(%) | 4.25 |
| Interest_2_W | Double | 2周利率(%) | 4.9 |
| Interest_1_M | Double | 1个月利率(%) | 5.04 |
| Interest_3_M | Double | 3个月利率(%) | 4.91 |
| Interest_6_M | Double | 6个月利率(%) | 4.79 |
| Interest_9_M | Double | 9个月利率(%) | 4.76 |
| Interest_1_Y | Double | 1年利率(%) | 4.78 |
- 收益计算方式
本赛题的余额宝收益方式,主要基于实际余额宝收益计算方法,但是进行了一定的简化,此处计算简化的地方如下:
首先,收益计算的时间不再是会计日,而是自然日,以 0 点为分隔,如果是 0 点之前转入或者转出的金额算作昨天的,如果是 0 点以后转入或者转出的金额则算作今天的。
然后,收益的显示时间,即实际将第一份收益打入用户账户的时间为如下表格,以周一转入周三显示为例,如果用户在周一存入 10000 元,即 1000000 分,那么这笔金额是周一确认,周二是开始产生收益,用户的余额还是 10000 元,在周三将周二产生的收益打入到用户的账户中,此时用户的账户中显示的是 10001.1 元,即 1000110 分。其他时间的计算按照表格中的时间来计算得到。
表格 5 : 简化后余额宝收益计算表
| 转入时间 | 首次显示收益时间 |
|---|---|
| 周一 | 周三 |
| 周二 | 周四 |
| 周三 | 周五 |
| 周四 | 周六 |
| 周五 | 下周二 |
| 周六 | 下周三 |
| 周天 | 下周三 |
- 选手需要提交的结果表:
表 格 6 选手提交结果表: tc_comp_predict_table
|字段|类型|含义|示例| |report_date|bigint|日期|20140901| |purchase|bigint|申购总额|40000000| |redeem|bigint|赎回总额|30000000|
每一行数据是一天对申购、赎回总额的预测值, 2014 年 9 月每天一行数据,共 30 行数据。 Purchase 和 redeem 都是金额数据,精确到分,而不是精确到元。
评分数据格式要求与“选手结果数据样例文件”一致,结果表命名为:tc_comp_predict_table, 字段之间以逗号为分隔符
- 评估指标
评估指标的设计主要期望选手对未来 30 天内每一天申购和赎回的总量数据预测的越准越好,同时考虑到可能存在的多种情况。譬如有些选手在 30 天中 29 天预测都是非常精准的但是某一天预测的结果可能误差很大,而有些选手在 30 天中每天的预测都不是很精准误差较大,如果采用绝对误差则可能导致前者的成绩比后者差,而在实际业务中可能更倾向于前者。所以最终选用积分式的计算方法:每天的误差选用相对误差来计算,然后根据用户预测申购和赎回的相对误差,通过得分函数映射得到一个每天预测结果的得分,将 30 天内的得分汇总,然后结合实际业务的倾向,对申购赎回总量预测的得分情况进行加权求和,得到最终评分。具体的操作如下:
计算所有用户在测试集上每天的申购及赎回总额与实际情况总额的误差。

申购预测得分与 Purchasei 相关,赎回预测得分与 Redeemi 相关 , 误差与得分之间的计算公式不公布,但保证该计算公式为单调递减的,即误差越小,得分越高,误差与大,得分越低。当第 i 天的申购误差 Purchasei =0 ,这一天的得分为 10 分;当 Purchasei > 0.3 ,其得分为 0 。
最后公布总积分 = 申购预测得分 *45%+ 赎回预测得分 *55% 。
# 赛题要点提炼
| 项目 | 内容 |
|---|---|
| 训练数据 | 2013.07.01 ~ 2014.08.31(14个月) |
| 预测目标 | 2014.09.01 ~ 09.30 每天的申购总额和赎回总额(共30行) |
| 评估方式 | 相对误差 |
| 权重 | 申购45% + 赎回55% |
| 数据粒度 | 用户×天级别,但只需预测每日汇总 |
几个关键洞察:
- 相对误差——意味着预测小额日子的误差容忍度极低(比如某天总额小,同样的绝对偏差误差率就大)。要特别关注低金额日期的预测精度
- 赎回权重更大(55%)——赎回预测比申购更重要,精力分配上要偏向赎回
- 2014年9月——包含中秋节(9月8日),节假日效应会很明显
- 收益率和Shibor是外部特征——利率高→申购多,利率变化→赎回冲动
# 整体路线
Step1 数据探索 → 看分布、缺失、趋势
Step2 聚合+可视化 → 每日总申购/赎回的时间序列图,找周期性
Step3 基准模型 → 简单移动平均/同期均值,知道baseline
Step4 特征工程 → 时间特征+滞后+窗口统计+外部利率
Step5 模型训练 → LightGBM/XGBoost
Step6 调优+生成提交
# 第一步:数据探索
""" Step1: 数据探索 - 资金流入流出预测 """
import pandas as pd
import numpy as np
import os
DATA_DIR = "./data/"
# ============ 1. 逐表加载 + 基本信息查看 ============
files = {
"用户申购赎回": "user_balance_table.csv",
"用户信息": "user_profile_table.csv",
"收益率": "mfd_day_share_interest.csv",
"银行间拆借利率": "mfd_bank_shibor.csv",
"提交样例": "comp_predict_table.csv",
}
for name, fname in files.items():
path = os.path.join(DATA_DIR, fname)
df = pd.read_csv(path)
print(f"\n{'='*50}")
print(f"表名: {name} ({fname})")
print(f"形状: {df.shape}")
print(f"字段: {list(df.columns)}")
print(f"数据类型:\n{df.dtypes}")
print(f"\n前5行:")
print(df.head())
print(f"\n缺失统计:")
print(df.isnull().sum()[df.isnull().sum() > 0])
print(f"\n数值统计:")
print(df.describe())
# ============ 2. 日期范围 ============
print(f"\n{'='*50}")
print("日期范围分析:")
df_bal = pd.read_csv(os.path.join(DATA_DIR, "user_balance_table.csv"))
df_bal['report_date'] = pd.to_datetime(df_bal['report_date'], format='%Y%m%d')
print(f"申购赎回数据日期范围: {df_bal['report_date'].min()} ~ {df_bal['report_date'].max()}")
print(f"唯一日期数: {df_bal['report_date'].nunique()}")
print(f"唯一用户数: {df_bal['user_id'].nunique()}")
# ============ 3. 每日汇总 ============
daily = df_bal.groupby('report_date').agg({
'total_purchase_amt': 'sum',
'total_redeem_amt': 'sum',
'user_id': 'count'
}).rename(columns={'user_id': 'active_users'})
print(f"\n每日汇总统计:")
print(daily.describe())
# ============ 4. 按月看趋势 ============
daily['month'] = daily.index.to_period('M')
monthly = daily.groupby('month').agg({
'total_purchase_amt': ['mean', 'sum'],
'total_redeem_amt': ['mean', 'sum'],
'active_users': 'mean'
})
print(f"\n月度趋势:")
print(monthly)
# ============ 5. 星期效应 ============
daily['weekday'] = daily.index.dayofweek # 0=周一, 6=周日
weekday_avg = daily.groupby('weekday').agg({
'total_purchase_amt': 'mean',
'total_redeem_amt': 'mean'
})
weekday_avg.index = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
print(f"\n星期效应(日均申购/赎回):")
print(weekday_avg)
# ============ 6. 收益率和Shibor日期范围 ============
df_yield = pd.read_csv(os.path.join(DATA_DIR, "mfd_day_share_interest.csv"))
df_shibor = pd.read_csv(os.path.join(DATA_DIR, "mfd_bank_shibor.csv"))
df_yield['mfd_date'] = pd.to_datetime(df_yield['mfd_date'], format='%Y%m%d')
df_shibor['mfd_date'] = pd.to_datetime(df_shibor['mfd_date'], format='%Y%m%d')
print(f"\n收益率表日期: {df_yield['mfd_date'].min()} ~ {df_yield['mfd_date'].max()}")
print(f"Shibor表日期: {df_shibor['mfd_date'].min()} ~ {df_shibor['mfd_date'].max()}")
# ============ 7. 提交样例 ============
df_sub = pd.read_csv(os.path.join(DATA_DIR, "comp_predict_table.csv"))
print(f"\n提交样例:")
print(df_sub)
print(f"需要预测的日期数: {len(df_sub)}")
print("\n✅ Step1 数据探索完成")
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
# 为什么要做数据探索?
做机器学习比赛就像"做饭"——你得先看看冰箱里有什么食材,是新鲜还是过期,有多少量,才能决定做什么菜。
数据探索回答的就是这些问题:
| 我想知道的 | 为什么重要 |
|---|---|
| 数据有多少行、多少列 | 了解数据规模,决定用什么方法 |
| 每列是什么类型 | 数字还是文字?决定能不能直接喂给模型 |
| 有没有缺失值 | 空数据会导致模型报错或预测偏 |
| 日期范围 | 训练数据覆盖多久?预测的是哪段时间? |
| 每日汇总的趋势 | 申购赎回有没有周期性?节假日有没有特殊规律? |
| 星期效应 | 工作日和周末的申购赎回可能差别很大 |
如果跳过这步直接建模,你都不知道数据长什么样,出了问题完全不知道从哪查。
# 逐行讲解
# 第一部分:准备工作
import pandas as pd
知识点:import
- import 就是"引入工具箱"。Python 本身只提供基础功能,想处理数据表就得引入 pandas 这个工具箱
- as pd 是给 pandas 起个短名字,以后写 pd 就等于写 pandas,省事
- 类比:你有个朋友叫"王小明",你叫他"小明",一个意思
import numpy as np
- numpy 是数值计算工具箱,提供数组、数学运算等
- as np 同理,简称
import os
- os 是操作系统工具箱,用来处理文件路径等
- 不管你代码在 Windows 还是 Mac 上跑,os.path.join 都能正确拼接路径
DATA_DIR = "./data/"
知识点:变量
- DATA_DIR 是一个变量,存着数据文件夹的路径
- "./data/" 中 . 表示当前目录,./data/ 就是当前目录下的 data 文件夹
- 用大写命名是编程惯例,表示"这是个不会变的常量"
# 第二部分:逐表加载
files = {
"用户申购赎回": "user_balance_table.csv",
"用户信息": "user_profile_table.csv",
"收益率": "mfd_day_share_interest.csv",
"银行间拆借利率": "mfd_bank_shibor.csv",
"提交样例": "comp_predict_table.csv",
}
2
3
4
5
6
7
知识点:字典(dict)
- {} 包裹的就是字典,格式是 键: 值
- 这里"用户申购赎回"是键,"user_balance_table.csv" 是值
- 用字典是为了循环的时候,既能拿到文件名,又能拿到中文说明
for name, fname in files.items():
知识点:for循环 + 字典遍历
- for ... in ... 是循环,把后面那个集合里的东西挨个取出来
- files.items() 把字典拆成一对一对的,每次循环 name 拿到键(中文名),fname 拿到值(文件名)
- 比如第一次循环:name="用户申购赎回",fname="user_balance_table.csv"
path = os.path.join(DATA_DIR, fname)
- 把文件夹路径和文件名拼起来,比如 ./data/ + user_balance_table.csv = ./data/user_balance_table.csv
- 为什么不直接写 "./data/user_balance_table.csv"?用 os.path.join 能自动处理不同操作系统的路径分隔符(Windows用\,Mac用/)
df = pd.read_csv(path)
知识点:DataFrame
- pd.read_csv() 读取 CSV 文件,返回一个 DataFrame(数据框)
- DataFrame 就像一张 Excel 表格,有行有列
- df 是 DataFrame 的常用缩写
print(f"\n{'='*50}")
知识点:f-string
- f"..." 是格式化字符串,里面的 {} 会被替换成变量的值
- {'='*50} → = 乘以50 → 输出50个等号,当分隔线用
- \n 是换行符,让输出前面空一行,更清晰
print(f"表名: {name} ({fname})")
print(f"形状: {df.shape}")
2
- df.shape 返回 (行数, 列数),比如 (100000, 17) 表示10万行17列
print(f"字段: {list(df.columns)}")
- df.columns 是所有列名,list() 转成列表格式输出更清晰
print(f"数据类型:\n{df.dtypes}")
- df.dtypes 显示每列的数据类型,常见类型:
- int64:整数(如 user_id)
- float64:小数(如收益率)
- object:通常是字符串(如星座"射手座")
print(f"\n前5行:")
print(df.head())
2
- df.head() 默认显示前5行,快速看一下数据长什么样
print(f"\n缺失统计:")
print(df.isnull().sum()[df.isnull().sum() > 0])
2
知识点:链式操作
拆开看:
df.isnull() → 每个格子是否为空,返回 True/False 表
.sum() → True=1, False=0,求和就是空值个数
[df.isnull().sum() > 0] → 只保留有空值的列(筛选)
这步很重要:如果某列有大量空值,后续要决定是填充还是丢弃
print(f"\n数值统计:")
print(df.describe())
2
- df.describe() 自动算出数值列的:数量、均值、标准差、最小值、25%/50%/75%分位数、最大值
- 一行代码就能看到数据的整体分布,非常实用
# 第三部分:日期范围分析
df_bal = pd.read_csv(os.path.join(DATA_DIR, "user_balance_table.csv"))
单独再读一遍申购赎回表(最重要的那张表),赋值给 df_bal
df_bal['report_date'] = pd.to_datetime(df_bal['report_date'], format='%Y%m%d')
知识点:类型转换
- CSV读进来的日期是字符串,比如 "20140407",不能直接按日期运算
- pd.to_datetime() 把字符串转成真正的日期类型
- format='%Y%m%d' 告诉它:你的日期格式是4位年+2位月+2位日
- %Y = 年(4位),%m = 月(2位),%d = 日(2位)
- 转成日期类型后,就能做"取月份""算星期几""按月分组"等操作
print(f"申购赎回数据日期范围: {df_bal['report_date'].min()} ~ {df_bal['report_date'].max()}")
- .min() 最早日期,.max() 最晚日期
- 确认数据覆盖的时间段是否完整
print(f"唯一日期数: {df_bal['report_date'].nunique()}")
print(f"唯一用户数: {df_bal['user_id'].nunique()}")
2
知识点:nunique()
- nunique() = number of unique,统计"有多少个不同的值"
- 和 len(set(...)) 类似,但 pandas 里用 nunique() 更快更方便
# 第四部分:每日汇总
这是最关键的一步——因为赛题要预测的是每天的总申购和总赎回,而不是单个用户的。所以需要把用户×天的数据"压缩"成天×1的数据。
daily = df_bal.groupby('report_date').agg({
'total_purchase_amt': 'sum',
'total_redeem_amt': 'sum',
'user_id': 'count'
}).rename(columns={'user_id': 'active_users'})
2
3
4
5
知识点:groupby + agg
拆开看:
- df_bal.groupby('report_date') → 按日期分组,同一天的数据放在一起
- .agg({...}) → 对每组做什么计算:
- 'total_purchase_amt': 'sum' → 每天的申购额求和
- 'total_redeem_amt': 'sum' → 每天的赎回额求和
- 'user_id': 'count' → 每天有多少条记录(活跃用户数)
- .rename(columns={'user_id': 'active_users'}) → count完列名还是user_id,改成active_users更直观
类比:groupby 就像"按班级分组",agg 就像"算每个班的平均分、最高分"
daily['month'] = daily.index.to_period('M')
- daily.index 因为 groupby 后日期变成了索引
- .to_period('M') 把精确日期转成"月份",比如 2014-04-07 → 2014-04
- 这样就能按月汇总看趋势
monthly = daily.groupby('month').agg({
'total_purchase_amt': ['mean', 'sum'],
'total_redeem_amt': ['mean', 'sum'],
'active_users': 'mean'
})
print(f"\n月度趋势:")
print(monthly)
2
3
4
5
6
7
按月分组,看每月的日均申购、月总申购、日均赎回等
目的:看有没有逐月增长的趋势,有没有某个月特别异常
# 第五部分:星期效应
daily['weekday'] = daily.index.dayofweek
知识点:dayofweek
- dayofweek 是日期的属性,返回 0~6:0=周一,6=周日
- 余额宝的申购赎回大概率有星期效应——工作日发工资了可能申购多,周末可能赎回多
weekday_avg = daily.groupby('weekday').agg({
'total_purchase_amt': 'mean',
'total_redeem_amt': 'mean'
})
weekday_avg.index = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
print(f"\n星期效应(日均申购/赎回):")
print(weekday_avg)
2
3
4
5
6
7
- 按星期分组求均值,看每周七天申购赎回有没有规律
- 这个规律后面会直接用来做特征
# 第六部分:外部数据日期范围
df_yield = pd.read_csv(os.path.join(DATA_DIR, "mfd_day_share_interest.csv"))
df_shibor = pd.read_csv(os.path.join(DATA_DIR, "mfd_bank_shibor.csv"))
df_yield['mfd_date'] = pd.to_datetime(df_yield['mfd_date'], format='%Y%m%d')
df_shibor['mfd_date'] = pd.to_datetime(df_shibor['mfd_date'], format='%Y%m%d')
print(f"\n收益率表日期: {df_yield['mfd_date'].min()} ~ {df_yield['mfd_date'].max()}")
print(f"Shibor表日期: {df_shibor['mfd_date'].min()} ~ {df_shibor['mfd_date'].max()}")
2
3
4
5
6
看收益率和Shibor的日期范围是否覆盖了预测期(2014年9月)
如果9月的利率数据没有,那就没法直接用,得另想办法
# 第七部分:提交样例
df_sub = pd.read_csv(os.path.join(DATA_DIR, "comp_predict_table.csv"))
print(f"\n提交样例:")
print(df_sub)
print(f"需要预测的日期数: {len(df_sub)}")
2
3
4
- 看看提交格式要求,确保最终输出格式正确
- len(df_sub) 就是行数,应该等于30(9月30天)
# 总结:数据探索的逻辑链
有什么表?每张表多大?有哪些字段?
↓
日期范围对不对?有没有缺失?
↓
按天汇总 → 看趋势(是在涨还是在跌?)
↓
按月看 → 有没有月度规律?
↓
按星期看 → 有没有周期性?
↓
外部数据能不能覆盖预测期?
↓
提交格式是什么?
2
3
4
5
6
7
8
9
10
11
12
13
数据很清晰,来分析结果。
# 数据探索结果分析
- 核心发现
数据规模:284万行×18列,2.8万用户,427天(2013.07~2014.08)
月度趋势很明显:
申购:2014年1-2月是巅峰(春节前后资金涌入),之后一路下降
赎回:3月是巅峰(春节后资金撤出),之后也下降
活跃用户:逐月递增(从733涨到12352)
规律:先涨后稳,有明显的生命周期特征
星期效应极强:
周一申购最高(2.6亿),周六最低(1.48亿),差了将近一倍
赎回也是周一最高,周六最低
结论:星期几是最重要的特征之一
category1-4 缺失94%:因为只有消费那天才有值(consume_amt=0时为空),大部分天用户没消费,这是正常的,后面填充0即可
# 第二步:可视化
Step2 可视化的目标很明确:把Step1发现的模式用图"看"出来,为后面特征工程指路。
# 总量趋势(最核心)
把 user_balance_table 按 report_date 聚合,画出每日的:
申购总额曲线 total_purchase_amt
赎回总额曲线 total_redeem_amt
一张图两条线,14个月的趋势一眼就能看到:
整体走势(上升/下降/平稳)
周期性波动(肉眼可见的锯齿 = 星期效应)
异常尖刺(春节、双十一、节假日)
# 星期效应
按 weekday 分组,画:
周一~周日平均申购额(柱状图)
周一~周日平均赎回额(柱状图)
Step1已经发现周一最高周六最低,图要直观展示这个落差有多大,后面特征工程直接用 weekday 作为特征。
# 月度规律
按 month 分组,画:
各月平均日申购 / 赎回(柱状图)
重点看1-2月申购巅峰、3月赎回巅峰的幅度,以及有没有其他月份的规律。
# 收益率 vs 申购赎回
把 mfd_day_share_interest 的收益率和上面的申购赎回曲线画在同一张双Y轴图上:
看收益率上升时,申购是否跟着涨(正相关?)
赎回对收益率的反应是否滞后
这一步直接决定后面要不要用收益率做特征、用多少天滞后。
# Shibor利率 vs 申购赎回(同理)
同上方式画 Shibor,看银行间利率对资金流动的影响。如果跟收益率高度共线,后面只留一个就行。
# 代码
""" Step2: 每日汇总 + 可视化 """
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import os
DATA_DIR = "./data/"
# ============ 1. 加载并汇总 ============
df_bal = pd.read_csv(os.path.join(DATA_DIR, "user_balance_table.csv"))
df_bal['report_date'] = pd.to_datetime(df_bal['report_date'], format='%Y%m%d')
# 按天汇总
daily = df_bal.groupby('report_date').agg({
'total_purchase_amt': 'sum',
'total_redeem_amt': 'sum',
'user_id': 'count'
}).rename(columns={'user_id': 'active_users'})
# 金额从分转成万元,方便看
daily['purchase_wan'] = daily['total_purchase_amt'] / 10000 / 10000 # 分→元→万元
daily['redeem_wan'] = daily['total_redeem_amt'] / 10000 / 10000
# ============ 2. 申购赎回趋势图 ============
plt.rcParams['font.sans-serif'] = ['SimHei'] # 中文显示
plt.rcParams['axes.unicode_minus'] = False
fig, axes = plt.subplots(2, 1, figsize=(15, 10))
# 申购趋势
axes[0].plot(daily.index, daily['purchase_wan'], color='steelblue', linewidth=0.8)
axes[0].set_title('每日申购总额(万元)')
axes[0].set_ylabel('申购(万元)')
axes[0].grid(True, alpha=0.3)
# 赎回趋势
axes[1].plot(daily.index, daily['redeem_wan'], color='coral', linewidth=0.8)
axes[1].set_title('每日赎回总额(万元)')
axes[1].set_ylabel('赎回(万元)')
axes[1].grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig('./step2_trend.png', dpi=150)
plt.close()
print("趋势图已保存: step2_trend.png")
# ============ 3. 星期效应箱线图 ============
daily['weekday'] = daily.index.dayofweek
weekday_names = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
# 申购按星期
purchase_groups = [daily[daily['weekday'] == i]['purchase_wan'].values for i in range(7)]
axes[0].boxplot(purchase_groups, labels=weekday_names)
axes[0].set_title('申购 - 星期效应')
axes[0].set_ylabel('申购(万元)')
axes[0].grid(True, alpha=0.3)
# 赎回按星期
redeem_groups = [daily[daily['weekday'] == i]['redeem_wan'].values for i in range(7)]
axes[1].boxplot(redeem_groups, labels=weekday_names)
axes[1].set_title('赎回 - 星期效应')
axes[1].set_ylabel('赎回(万元)')
axes[1].grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig('./step2_weekday.png', dpi=150)
plt.close()
print("星期效应图已保存: step2_weekday.png")
# ============ 4. 月度趋势柱状图 ============
daily['month'] = daily.index.to_period('M')
monthly = daily.groupby('month').agg({
'purchase_wan': 'sum',
'redeem_wan': 'sum'
})
fig, ax = plt.subplots(figsize=(14, 5))
x = range(len(monthly))
width = 0.35
ax.bar([i - width/2 for i in x], monthly['purchase_wan'], width, label='申购', color='steelblue')
ax.bar([i + width/2 for i in x], monthly['redeem_wan'], width, label='赎回', color='coral')
ax.set_xticks(x)
ax.set_xticklabels([str(m) for m in monthly.index], rotation=45)
ax.set_title('月度申购赎回总额(万元)')
ax.set_ylabel('金额(万元)')
ax.legend()
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig('./step2_monthly.png', dpi=150)
plt.close()
print("月度趋势图已保存: step2_monthly.png")
# ============ 5. 查看极端日和异常日 ============
# 日申购/赎回TOP10
print("\n申购TOP10天:")
top_purchase = daily.nlargest(10, 'total_purchase_amt')[['purchase_wan', 'redeem_wan', 'weekday']]
top_purchase.index = top_purchase.index.strftime('%Y-%m-%d(%a)')
print(top_purchase)
print("\n赎回TOP10天:")
top_redeem = daily.nlargest(10, 'total_redeem_amt')[['purchase_wan', 'redeem_wan', 'weekday']]
top_redeem.index = top_redeem.index.strftime('%Y-%m-%d(%a)')
print(top_redeem)
# 日申购/赎回BOTTOM10
print("\n申购BOTTOM10天:")
bot_purchase = daily.nsmallest(10, 'total_purchase_amt')[['purchase_wan', 'redeem_wan', 'weekday']]
bot_purchase.index = bot_purchase.index.strftime('%Y-%m-%d(%a)')
print(bot_purchase)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
# 整体思路:为什么这么做?
你手上有一张284万行的表,每行是一个用户某天的申购赎回记录。但我们要预测的是9月份每天的总申购和总赎回,不关心某个用户。
所以思路就是:
先把284万行压成400多行(每天一行,汇总所有人的金额)——这就是"汇总"
画趋势图——看14个月整体走势
画星期效应图——用箱线图比周一到周日的差异(比柱状图信息更多,后面解释为什么)
画月度图——看各月总量差异
找极端日——哪天暴涨暴跌?是节假日?促销?搞清楚后面才能处理
# 第1段:加载并汇总
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import os
2
3
4
import pandas as pd → 把pandas库"请"进来,起个短名叫pd。pandas是Python里处理表格数据的工具,就像Excel一样,但能用代码批量操作
import numpy as np → 数值计算库。pandas底层就是靠numpy算的,有些数学操作需要它。numpy = "Numerical Python"的缩写
import matplotlib.pyplot as plt → 把matplotlib的画图模块请进来,起短名叫plt。matplotlib是Python最基础的画图库
import os → 操作系统接口库,用来拼文件路径
知识点:为什么用os.path.join而不是直接写字符串?
Windows用\当路径分隔符,Mac用/。os.path.join会自动选对的,代码换个电脑也能跑。不过你用的Windows,直接写字符串也行,这只是个更规范的习惯。
DATA_DIR = "./data/"
DATA_DIR → 一个变量,存着数据文件夹的路径。./表示当前目录
好处:如果以后数据换了位置,只改这一行就行,不用满代码找路径
df_bal = pd.read_csv(os.path.join(DATA_DIR, "user_balance_table.csv"))
os.path.join(DATA_DIR, "user_balance_table.csv") → 把./data/和文件名拼成./data/user_balance_table.csv
pd.read_csv(...) → 读CSV文件,返回DataFrame
知识点:变量命名df_bal
df是DataFrame的缩写,bal是balance的缩写。这是编程的命名习惯——变量名要让人一看就知道它是什么。
df_bal['report_date'] = pd.to_datetime(df_bal['report_date'], format='%Y%m%d')
把report_date列从数字(20140101)转成日期类型
- daily['report_date'] → 取出report_date这一列。DataFrame用方括号取列,类似字典取值
- pd.to_datetime(...) → 把这一列从数字(比如20140101)转成日期类型(Python能理解的日期对象)
- format='%Y%m%d' → 告诉Python这个数字的格式是"年年年年月月日日"。%Y=4位年份,%m=2位月份,%d=2位日期
为什么要转? 因为数字20140101对Python来说就是个两千万的数字,它不知道这是日期。转成datetime类型后,你才能提取"这是周几""这是几月"这些信息。
daily = df_bal.groupby('report_date').agg({
'total_purchase_amt': 'sum',
'total_redeem_amt': 'sum',
'user_id': 'count'
}).rename(columns={'user_id': 'active_users'})
2
3
4
5
逐层拆解:
groupby('report_date') → 按日期分组,同一天的用户捆一堆
.agg({...}) → 对每组做运算:
'total_purchase_amt': 'sum' → 同一天所有申购额求和
'total_redeem_amt': 'sum' → 同一天所有赎回额求和
'user_id': 'count' → 数一下这一天有多少条记录(也就是有多少活跃用户)
.rename(columns={'user_id': 'active_users'}) → 把列名从user_id改成active_users。因为agg之后这列不再是用户ID了,是计数,原来的名字会误导人
知识点:为什么数user_id就是活跃用户数?
因为每个用户每天有一条记录。count就是数这一列有多少个非空值,等于数了多少行,等于多少个用户。
daily['purchase_wan'] = daily['total_purchase_amt'] / 10000 / 10000
daily['redeem_wan'] = daily['total_redeem_amt'] / 10000 / 10000
2
/ 10000 / 10000 → 先除10000把分转成元,再除10000把元转成万元
原始单位是"分",一天的总申购可能是几十亿分,数字太大看着费劲,转成万元好读
知识点:数据单位转换是数据可视化的基本操作
图上标"23,456,789,000分"人看不懂,标"234,567万元"一眼就明白。
# 第2段:趋势图
fig, axes = plt.subplots(2, 1, figsize=(15, 10))
plt.subplots(2, 1) → 创建一个2行1列的画布,也就是上下两个子图。上面画申购,下面画赎回
figsize=(15, 10) → 画布尺寸,宽15英寸高10英寸。数字越大图越大
返回值:
fig → 整张画布(大框)
axes → 子图数组。axes[0]是上面那个图,axes[1]是下面那个图
知识点:为什么分两张图而不是画在一张上?
申购和赎回的量级接近,画一起会互相遮挡。分开更清晰。
axes[0].plot(daily.index, daily['purchase_wan'], color='steelblue', linewidth=0.8)
axes[0].plot(x, y, ...) → 在第一个子图上画折线图
daily.index → x轴,是日期(groupby之后日期变成了索引)
daily['purchase_wan'] → y轴,申购金额
color='steelblue' → 线的颜色,钢蓝色
linewidth=0.8 → 线宽,0.8比较细,14个月400多个点太粗会糊成一片
axes[0].set_title('每日申购总额(万元)')
axes[0].set_ylabel('申购(万元)')
axes[0].grid(True, alpha=0.3)
2
3
set_title → 设置标题
set_ylabel → 设置y轴标签
grid(True, alpha=0.3) → 显示网格线。alpha=0.3是透明度,0=完全透明,1=完全不透明,0.3是很淡的网格,不抢主线
赎回图同理,就是换了个子图axes[1]和颜色coral(珊瑚红),不再重复。
plt.tight_layout()
plt.savefig('./step2_trend.png', dpi=150)
plt.close()
2
3
tight_layout() → 自动调整子图间距,防止标题和标签重叠
savefig(..., dpi=150) → 保存图片。dpi=150表示每英寸150像素,比默认的100更清晰
close() → 关闭当前画布,释放内存。画完就关是好习惯,不然画多了内存会涨
# 第3段:星期效应箱线图
这段是信息量最大的,也是最值得学的。
daily['weekday'] = daily.index.dayofweek
daily.index.dayofweek → 因为索引已经是datetime类型了,直接.dayofweek就能取星期几,返回0-6
跟之前讲的.dt.weekday效果一样,区别是:当列是索引时用.dayofweek,当列是普通列时用.dt.weekday
weekday_names = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
就是0-6对应的中文标签,后面画图时当x轴标签用
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
1, 2 → 1行2列,左右并排两个子图
figsize=(14, 5) → 宽14高5,横向铺开
purchase_groups = [daily[daily['weekday'] == i]['purchase_wan'].values for i in range(7)]
这行要仔细拆:
daily[daily['weekday'] == i] → 筛选出"星期几=i"的所有行。比如i=0就是所有周一的行
['purchase_wan'].values → 取出申购金额列,转成numpy数组。.values把pandas列变成纯数字数组,boxplot需要这种格式
for i in range(7) → i从0到6循环7次,每次取一个星期几的数据
外面套[...] → 列表推导式,把7次循环的结果收集成一个列表
最终结果:一个长度7的列表,每个元素是一个数组,里面是那个星期几的所有申购金额
知识点:什么是列表推导式?
普通写法:
result = []
for i in range(7):
result.append(某个值)
2
3
列表推导式一行搞定:
result = [某个值 for i in range(7)]
效果完全一样,只是更简洁。Python里很常见。
axes[0].boxplot(purchase_groups, labels=weekday_names)
boxplot → 画箱线图
purchase_groups → 7组数据,每组画一个箱
labels=weekday_names → x轴标签用周一~周日
知识点:箱线图为什么比柱状图好?
柱状图只显示平均数,一个柱子一个数字。但箱线图一次展示5个信息:
─── 最大值(非异常)
│
┌───┤ 上四分位(75%的数据在这以下)
│ │
│ ──┤ 中位数(50%的数据在这以下)
│ │
└───┤ 下四分位(25%的数据在这以下)
│
─── 最小值(非异常)
○ 异常值(离群点)
2
3
4
5
6
7
8
9
10
箱子越矮 → 数据越集中,那个星期几的金额很稳定
箱子很高 → 数据波动大,有时多有时少
中位数线偏上 → 大多数日子偏低,但偶尔很高
所以箱线图不仅告诉你"周一比周六多",还告诉你"周一的波动有多大"。这对后面判断"weekday特征靠不靠谱"很关键。
axes[0].set_title('申购 - 星期效应')
axes[0].set_ylabel('申购(万元)')
axes[0].grid(True, alpha=0.3)
2
3
设置标题、y轴标签、网格——跟之前一样。
赎回的箱线图同理,不再重复。
# 第4段:月度柱状图
daily['month'] = daily.index.to_period('M')
to_period('M') → 把日期转成"月度周期",比如2014-01-15变成2014-01
这样同一个月的所有天会归到同一个周期
知识点:为什么不用.dt.month?
.dt.month只返回月份数字1-12,那2013年7月和2014年7月会被当成同一个月。to_period('M')保留了年份,2013-07和2014-07是两个不同的月。
monthly = daily.groupby('month').agg({
'purchase_wan': 'sum',
'redeem_wan': 'sum'
})
2
3
4
按月分组,对申购和赎回求和(一个月的总金额)
fig, ax = plt.subplots(figsize=(14, 5))
这次只有一个图,所以用ax(单个),不是axes(数组)
x = range(len(monthly))
width = 0.35
2
x = range(len(monthly)) → x轴位置,0, 1, 2, ...,每个月一个位置
width = 0.35 → 柱子宽度
ax.bar([i - width/2 for i in x], monthly['purchase_wan'], width, label='申购', color='steelblue')
ax.bar([i + width/2 for i in x], monthly['redeem_wan'], width, label='赎回', color='coral')
2
这是并排柱状图的关键:
申购柱子的x位置 = i - width/2(往左挪半个柱宽)
赎回柱子的x位置 = i + width/2(往右挪半个柱宽)
这样同一月的两根柱子就并排了
知识点:为什么不能直接画两遍bar?
如果两次都画在i的位置,第二根柱子会完全盖住第一根。错开半个柱宽,才能并排显示。
ax.set_xticks(x)
ax.set_xticklabels([str(m) for m in monthly.index], rotation=45)
2
set_xticks(x) → 告诉matplotlib x轴刻度放在哪些位置
set_xticklabels(...) → 刻度上显示什么文字。把月份周期对象转成字符串
rotation=45 → 标签旋转45度,因为月份文字比较长,水平放会重叠
ax.legend()
显示图例(就是图上那个小框,告诉你蓝色是申购、红色是赎回)
只有在bar()里加了label=参数,legend()才能显示
# 第5段:找极端日
top_purchase = daily.nlargest(10, 'total_purchase_amt')[['purchase_wan', 'redeem_wan', 'weekday']]
nlargest(10, 'total_purchase_amt') → 取申购额最大的10行
[['purchase_wan', 'redeem_wan', 'weekday']] → 只显示这3列
top_purchase.index = top_purchase.index.strftime('%Y-%m-%d(%a)')
strftime → 把日期格式化成字符串
%Y-%m-%d → 2014-01-15
(%a) → 星期几的缩写(Mon/Tue/...)
合起来就是2014-01-15(Wed)
知识点:strftime = string format time
把日期对象变成你想要的字符串格式。%Y年、%m月、%d日、%a星期缩写、%A星期全称。
bot_purchase = daily.nsmallest(10, 'total_purchase_amt')[['purchase_wan', 'redeem_wan', 'weekday']]
nsmallest → 取最小的10行,和nlargest相反
# 总结
5张图+1个统计表,每一步都在回答一个具体问题:
| 产出 | 回答的问题 |
|---|---|
| 趋势图 | 整体走势和周期性 |
| 星期箱线图 | 星期效应多强、多稳定 |
| 月度柱状图 | 哪几个月特殊 |
| TOP/BOTTOM 10 | 哪些天异常,为什么 |
# 结果



申购TOP10天:
| report_date | purchase_wan | redeem_wan | weekday |
|---|---|---|---|
| 2014-01-29(Wed) | 9.524797 | 2.151647 | 2 |
| 2014-01-28(Tue) | 8.434247 | 2.483343 | 1 |
| 2014-02-11(Tue) | 8.188032 | 2.430582 | 1 |
| 2014-02-12(Wed) | 7.630098 | 3.545859 | 2 |
| 2014-02-17(Mon) | 7.061181 | 4.058093 | 0 |
| 2014-02-26(Wed) | 6.808011 | 2.848197 | 2 |
| 2014-02-27(Thu) | 6.729093 | 4.927860 | 3 |
| 2014-01-27(Mon) | 6.571995 | 2.806459 | 0 |
| 2014-02-24(Mon) | 6.563170 | 4.734702 | 0 |
| 2014-02-10(Mon) | 6.474651 | 4.317534 | 0 |
赎回TOP10天:
| report_date | purchase_wan | redeem_wan | weekday |
|---|---|---|---|
| 2014-06-25(Wed) | 2.646632 | 5.472959 | 2 |
| 2014-03-03(Mon) | 5.053059 | 5.130174 | 0 |
| 2014-06-17(Tue) | 2.703507 | 5.025602 | 1 |
| 2014-03-11(Tue) | 4.305008 | 4.960399 | 1 |
| 2014-02-27(Thu) | 6.729093 | 4.927860 | 3 |
| 2014-06-16(Mon) | 3.873085 | 4.923495 | 0 |
| 2014-02-24(Mon) | 6.563170 | 4.734702 | 0 |
| 2014-08-27(Wed) | 3.021948 | 4.681641 | 2 |
| 2014-06-30(Mon) | 3.340541 | 4.565478 | 0 |
| 2014-03-26(Wed) | 2.729355 | 4.502542 | 2 |
申购BOTTOM10天:
| report_date | purchase_wan | redeem_wan | weekday |
|---|---|---|---|
| 2013-07-07(Sun) | 0.089622 | 0.039827 | 6 |
| 2013-10-03(Thu) | 0.101012 | 0.062233 | 3 |
| 2013-10-02(Wed) | 0.115627 | 0.072339 | 2 |
| 2013-07-05(Fri) | 0.116487 | 0.027636 | 4 |
| 2013-07-13(Sat) | 0.151647 | 0.034828 | 5 |
| 2013-07-27(Sat) | 0.171945 | 0.150589 | 5 |
| 2013-08-17(Sat) | 0.176705 | 0.046750 | 5 |
| 2013-07-04(Thu) | 0.183212 | 0.064107 | 3 |
| 2013-10-01(Tue) | 0.191375 | 0.128133 | 1 |
| 2013-10-04(Fri) | 0.199077 | 0.234124 | 4 |
- 趋势图——三个阶段清晰可见
第一阶段(2013.07-10):低位运行,申购0-2万,赎回0-1万。产品刚上线,用户还少。
第二阶段(2013.11-2014.02):爆发式增长,1-2月申购冲到9万+。这就是春节效应——大家把年终奖、过节钱存进余额宝。
第三阶段(2014.03-08):回落但稳定在2-4万,比2013年高一个台阶。产品进入稳定期。
注意2月中旬那个"深坑"——申购赎回都接近0。那是春节假期(2014年春节是1月31日),银行不上班,资金进不来出不去。这个规律后面做特征一定要捕捉到。
- 星期效应——非常强且稳定
工作日 >> 周末,周六最低,周一最高
申购的中位数:周一大约是周六的3-4倍
箱线图还能看到:周三的异常值特别多(上限到了9万),说明周三偶尔会有大额申购
结论:weekday特征必用,而且效果会很显著。
- 月度图——1-2月申购巅峰,3月赎回巅峰
这个太明显了:
1月2月申购暴涨(年终奖+春节存钱)
3月赎回暴涨(春节后取钱消费/还信用卡)
之后逐渐平稳
赎回比申购滞后1个月,这个"时滞关系"后面做特征时可以试试用"上个月申购额"预测"这个月赎回额"。
TOP/BOTTOM数据验证了上面的发现
申购TOP10全部集中在1月27日-2月27日——就是春节前后那一个月。
申购BOTTOM10:
7月的几天 → 产品刚上线,用户少
10月1-4日 → 国庆假期,跟春节深坑同一个道理
赎回TOP10分散在3月和6月——3月是春节后集中赎回,6月可能是年中效应(半年末资金需求)。
- 7月数据可能要特殊处理——2013年7月数据明显偏低,不是正常的市场波动,是产品还在冷启动。后面训练模型时,7月的数据可能会拉低预测精度,需要考虑要不要剔除或降权。
到这里,Step2的结论已经足够指导特征工程了
核心发现就4条:
星期效应极强 → 必加weekday特征
节假日效应极强 → 必加is_holiday特征
月度季节性明显 → 加month特征,或更细的"距离春节天数"
产品有增长趋势 → 可能需要加趋势特征或只取近期数据
# 第三步:特征工程
先想清楚一件事:模型是个瞎子,它只能看到你喂给它的数字。
它不知道"9月1日是周一",不知道"10月1日是国庆",不知道"昨天的申购额很高"。这些对你是常识,对模型来说——你不告诉它,它就不知道。
特征工程就是把你脑子里的常识,翻译成模型能看懂的数字。
整体思路:5类特征,每类解决一个问题
| 特征类型 | 解决什么问题 | 举例 |
|---|---|---|
| ① 基础时间特征 | 周期性规律 | 星期几、几月份 |
| ② 节假日特征 | 特殊日子的突变 | 春节、国庆、调休上班 |
| ③ 滞后特征 | "昨天怎么样"对今天有影响 | 昨天申购额、上周同一天申购额 |
| ④ 滑动窗口特征 | 近期的平均水平 | 最近7天平均申购额 |
| ⑤ 特殊日期特征 | 月末月初效应 | 距离月末还有几天 |
为什么需要每类特征?
- ① 基础时间特征——最基本也是最有用的
Step2已经证明了:周一申购是周六的3-4倍,1-2月申购远超其他月。
这些信息怎么喂给模型?
weekday(0-6)→ 直接告诉模型"今天是周几"
month(1-12)→ 告诉模型"现在是几月"
但这里有个问题:模型会把1和2当成"相邻"的数字,觉得1月和2月很接近、1月和12月很远。实际上12月和1月才是相邻的!
所以month不能直接用数字,要用独热编码(One-Hot)——把1个月份变成12列,是1月就第1列=1其余=0,是2月就第2列=1其余=0。这样每个月都是独立的,不会因为数字大小产生误导。
weekday也有这个问题,但weekday只有7个值,而且从周一到周日确实有"递减"的趋势(申购量总体上工作日>周末),所以先直接用数字试试,效果不好再One-Hot。
- ② 节假日特征——突变日不处理就是噪音
Step2看到了:春节那几天申购赎回接近0,国庆也是。如果模型只看到"1月31日申购很低",它会以为是随机波动;但如果你告诉它"1月31日是春节假期",模型就能理解这个低值是有原因的,9月遇到中秋节也能做类似判断。
2014年9月有中秋节(9月8日),这是预测期唯一的节假日,很关键。
需要建的变量:
is_holiday → 1=假期,0=非假期
is_workday → 1=工作日(含调休上班的周末),0=非工作日
中国节假日比较复杂:有调休(周日要上班),需要手动建一个节假日表。
- ③ 滞后特征——时间序列最重要的特征
这是时间序列和普通预测最大的区别。
普通预测(比如二手车价格):每条数据是独立的,A车的价格不影响B车。
时间序列预测:今天的数据跟昨天强相关。如果昨天申购了4亿,今天大概率也在3-5亿这个量级,不太可能突然变成1亿或10亿。
所以我们要给模型"回头看"的能力:
purchase_lag_1 → 昨天的申购额
purchase_lag_7 → 上周同一天的申购额(因为星期效应很强,上周同一星期几比昨天更有参考价值)
purchase_lag_30 → 大约上个月同一天的申购额
赎回同理。
但注意:预测9月时,8月31日是已知的,9月1日可以用lag_1;但预测9月2日时,9月1日的值是你自己预测的,不是真实值。这就产生了"误差累积"的问题——后面会处理,现在先建特征。
- ④ 滑动窗口特征——近期趋势
lag只看一天,窗口看一段时间的平均水平:
purchase_rolling_mean_7 → 最近7天的平均申购额(平滑掉单日波动,看短期趋势)
purchase_rolling_std_7 → 最近7天的申购额标准差(波动大=不稳定,波动小=稳定)
打个比方: lag_1是"你昨天考了多少分",rolling_mean_7是"你这周平均考多少分"。后者更稳定,不容易被某一天的异常值带偏。
- ⑤ 特殊日期特征——月末月初效应
很多人工资月底/月初发,发工资后可能会存余额宝(申购增加)。月初也可能是信用卡还款日(赎回增加)。
day_of_month → 几号(1-30)
is_month_start → 是否月初1-3号
is_month_end → 是否月末28-31号
- 执行顺序
这5类特征不能一次全写完(代码太长,出错了不好排查),分步走:
Step3.1 → 基础时间特征 + 节假日特征(合并做,因为都跟日期有关)
Step3.2 → 滞后特征 + 滑动窗口特征(合并做,因为都依赖历史数据)
Step3.3 → 特殊日期特征 + 最终特征表整理
# 3.1 基础时间特征 + 节假日特征
完整代码
""" Step3.1: 基础时间特征 + 节假日特征 """
import pandas as pd
import numpy as np
# ============ 1. 加载数据并汇总 ============
df_bal = pd.read_csv('./data/user_balance_table.csv')
df_bal['report_date'] = pd.to_datetime(df_bal['report_date'], format='%Y%m%d')
daily = df_bal.groupby('report_date').agg({
'total_purchase_amt': 'sum',
'total_redeem_amt': 'sum'
}).reset_index()
daily['purchase_wan'] = daily['total_purchase_amt'] / 10000 / 10000
daily['redeem_wan'] = daily['total_redeem_amt'] / 10000 / 10000
# ============ 2. 基础时间特征 ============
daily['weekday'] = daily['report_date'].dt.weekday
daily['month'] = daily['report_date'].dt.month
daily['day_of_month'] = daily['report_date'].dt.day
month_dummies = pd.get_dummies(daily['month'], prefix='month', dtype=int)
daily = pd.concat([daily, month_dummies], axis=1)
# ============ 3. 节假日特征 ============
holidays = [
# 2013中秋
'2013-09-19', '2013-09-20', '2013-09-21',
# 2013国庆
'2013-10-01', '2013-10-02', '2013-10-03',
'2013-10-04', '2013-10-05', '2013-10-06', '2013-10-07',
# 2014元旦
'2014-01-01',
# 2014春节
'2014-01-31', '2014-02-01', '2014-02-02',
'2014-02-03', '2014-02-04', '2014-02-05', '2014-02-06',
# 2014清明
'2014-04-05', '2014-04-06', '2014-04-07',
# 2014五一
'2014-05-01', '2014-05-02', '2014-05-03',
# 2014端午
'2014-05-31', '2014-06-01', '2014-06-02',
# 2014中秋(9月8日,预测期唯一节假日!)
'2014-09-08',
]
holidays = pd.to_datetime(holidays)
extra_workdays = [
'2013-09-22', # 中秋调休
'2013-09-29', # 国庆调休
'2013-10-12', # 国庆调休
'2014-01-26', # 春节调休
'2014-02-08', # 春节调休
'2014-05-04', # 五一调休
]
extra_workdays = pd.to_datetime(extra_workdays)
daily['is_holiday'] = daily['report_date'].isin(holidays).astype(int)
daily['is_extra_workday'] = daily['report_date'].isin(extra_workdays).astype(int)
daily['is_workday'] = (
((daily['weekday'] < 5) & (daily['is_holiday'] == 0)) |
(daily['is_extra_workday'] == 1)
).astype(int)
# ============ 4. 查看结果 ============
print(daily.head(10))
print(f"\n总行数: {len(daily)}")
print(f"节假日天数: {daily['is_holiday'].sum()}")
print(f"调休上班天数: {daily['is_extra_workday'].sum()}")
# 验证:看看春节那几天的特征是否正确
spring_festival = daily[(daily['report_date'] >= '2014-01-30') & (daily['report_date'] <= '2014-02-10')]
print("\n春节前后验证:")
print(spring_festival[['report_date', 'weekday', 'is_holiday', 'is_extra_workday', 'is_workday', 'purchase_wan']])
# ============ 5. 保存 ============
daily.to_csv('./step3_features.csv', index=False)
print("\n特征表已保存: step3_features.csv")
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
# 第1段:加载数据并汇总
跟Step2开头一样,把284万行压成每天一行。不重复讲了,有一点要注意:
为什么Step3又重新跑了一遍汇总,而不是直接读Step2的daily?
因为Step2的代码没保存daily到文件,而且Step3后面还要继续加列,保持脚本自包含更不容易出错。重复这几行代码没关系,清晰可靠比省代码重要。
# 第2段:基础时间特征
daily['weekday'] = daily['report_date'].dt.weekday
daily['month'] = daily['report_date'].dt.month
daily['day_of_month'] = daily['report_date'].dt.day
2
3
weekday → 0=周一...6=周日,Step2讲过
month → 1-12
day → 这个月第几天(1-31),后面做"月末效应"要用
month_dummies = pd.get_dummies(daily['month'], prefix='month', dtype=int)
这行是关键,仔细拆:
pd.get_dummies(...) → 独热编码(One-Hot Encoding)。把一列变成多列,每列只有一个0或1
举个例子,假设month列长这样:
| 原来的month |
|---|
| 1 |
| 2 |
| 1 |
经过get_dummies后变成:
| month_1 | month_2 | month_3 | ... | month_12 |
|---|---|---|---|---|
| 1 | 0 | 0 | ... | 0 |
| 0 | 1 | 0 | ... | 0 |
| 1 | 0 | 0 | ... | 0 |
prefix='month' → 新列名的前缀。原来的值是1,新列名叫month_1;值是2,列名叫month_2
dtype=int → 默认返回True/False(布尔值),加这个变成0/1整数。模型需要数字,不能吃True/False
知识点:为什么要One-Hot?
模型只懂数学运算。如果你直接把month=1和month=12喂进去,模型会认为1和12差了11,1和2只差1。但实际上12月和1月是相邻的月份,1月和2月反而可能差异很大。
One-Hot之后,每个月份都是独立的一列,彼此之间没有"大小"关系,模型不会因为数字大小产生错误理解。
那weekday为什么不做One-Hot?
因为weekday有一个天然的"递减"趋势——从周一到周日,申购量整体递减。数字0-6恰好能捕捉这个趋势。month没有这种"1月比12月多"的规律,所以必须One-Hot。
当然,weekday做One-Hot效果可能更好,但先简单来,后面如果效果不好再升级。
daily = pd.concat([daily, month_dummies], axis=1)
pd.concat([...], axis=1) → 把两个表横向拼接(按列拼)
axis=1 → 沿着列方向拼(左右拼)。axis=0是上下拼(纵向),这里不适用
为什么要拼? get_dummies产生了一个新表(12列),但原来的daily还在。需要把它们合并成一张大表。
打个比方: daily是你原来的笔记本,month_dummies是你新写的补充页。concat就是把补充页贴到笔记本后面。
# 第3段:节假日特征
holidays = [
'2013-09-19', '2013-09-20', '2013-09-21',
...
]
holidays = pd.to_datetime(holidays)
2
3
4
5
先用列表把所有假期日期写出来
然后pd.to_datetime(holidays) → 把字符串列表转成日期数组
为什么要手动写?
中国节假日没有简单规律(调休每年不一样),官方每年单独发通知。没法用公式算,只能手动列。这是做中国金融数据最烦的地方之一。
不过好消息是:我们只需要2013.07到2014.09这15个月的节假日,手动列也就30来天,不算多。
daily['is_holiday'] = daily['report_date'].isin(holidays).astype(int)
逐层拆:
daily['report_date'].isin(holidays) → 检查每个日期是否在holidays列表里,返回True/False
.astype(int) → 把True/False转成1/0
结果:是假期的天=1,不是=0。模型看到1就知道"今天放假"。
知识点:isin()是什么?
就是"是否在其中"。类似你问"这个人在不在名单上?"在就True,不在就False。比写循环快得多,pandas底层用C语言优化过。
daily['is_extra_workday'] = daily['report_date'].isin(extra_workdays).astype(int)
调休上班日,逻辑跟is_holiday一样
daily['is_workday'] = (
((daily['weekday'] < 5) & (daily['is_holiday'] == 0)) |
(daily['is_extra_workday'] == 1)
).astype(int)
2
3
4
这行逻辑最复杂,仔细拆:
(daily['weekday'] < 5) → 周一到周五(0-4)
& (daily['is_holiday'] == 0) → 且不是假期
合起来:周一到周五且不放假 → 正常工作日
| → 或者
(daily['is_extra_workday'] == 1) → 调休上班日(虽然是周末但要上班)
整句话:是工作日 = (周一到周五且没放假)或者(调休上班)
知识点:& vs and,| vs or
Python的and/or只能处理单个True/False
pandas的&/|能处理整列(向量化运算)
比较pandas列时必须用&/|,不能用and/or
另外必须加括号,因为&的优先级比==高,不加括号会先算&再算==,逻辑就错了
# 第4段:验证
spring_festival = daily[(daily['report_date'] >= '2014-01-30') & (daily['report_date'] <= '2014-02-10')]
筛选春节前后的日期,检查is_holiday和is_workday标得对不对
为什么要验证?
节假日是手动写的,万一写错一天,模型就会学到错误信息。用已知的春节深坑(Step2看到的2月低谷)来对照——如果那些天is_holiday=1、purchase_wan很低,说明标注正确。
daily.to_csv('./step3_features.csv', index=False)
保存特征表,index=False → 不保存行索引(索引就是0,1,2,3...,没信息量,存了反而多一列干扰)
# 3.2 滞后特征 + 滑动窗口
为什么需要这些特征?
Step2的趋势图你看到了——每天的申购额不是随机跳的,而是"粘"着的:昨天4亿,今天大概率3-5亿。这种"粘性"就是时间序列的核心规律。
模型本身不知道"昨天是多少",它只看到当天的特征。所以我们要手动把"昨天的值"加进去,让模型能参考历史。
两类特征各解决一个角度:
| 特征 | 解决什么 | 比喻 |
|---|---|---|
| 滞后特征(lag) | 某一天的历史值是多少 | "昨天考了多少分" |
| 滑动窗口(rolling) | 近几天的平均水平/波动 | "这周平均考多少分" |
但有个关键问题:前几行会缺数据。
比如1月1日那天,没有"昨天的数据"(因为数据从1月1日开始),lag_1就是空值。7天窗口的前7行也是空的。这叫边界缺失,后面要处理。
""" Step3.2: 滞后特征 + 滑动窗口特征 """
import pandas as pd
import numpy as np
# ============ 1. 加载Step3.1的结果 ============
daily = pd.read_csv('./step3_features.csv')
daily['report_date'] = pd.to_datetime(daily['report_date'])
# 确保按日期排序(做lag和rolling必须有序!)
daily = daily.sort_values('report_date').reset_index(drop=True)
# ============ 2. 滞后特征 ============
# 要做lag的列
target_cols = ['total_purchase_amt', 'total_redeem_amt']
# 生成 lag_1, lag_7, lag_30
for col in target_cols:
for lag in [1, 7, 30]:
daily[f'{col}_lag_{lag}'] = daily[col].shift(lag)
# ============ 3. 滑动窗口特征 ============
for col in target_cols:
for window in [7, 30]:
# 近N天均值
daily[f'{col}_roll_mean_{window}'] = daily[col].rolling(window=window).mean()
# 近N天标准差
daily[f'{col}_roll_std_{window}'] = daily[col].rolling(window=window).std()
# ============ 4. 查看缺失情况 ============
new_cols = [c for c in daily.columns if '_lag_' in c or '_roll_' in c]
print("新增特征列:")
print(new_cols)
print(f"\n新增特征数量: {len(new_cols)}")
# 看看前35行的缺失
print("\n前35行缺失值统计:")
print(daily[new_cols].head(35).isnull().sum())
# ============ 5. 处理缺失值 ============
# 删除前30行(lag_30和roll_30需要至少30天历史)
daily = daily.iloc[30:].reset_index(drop=True)
print(f"\n删除前30行后剩余: {len(daily)} 行")
# 再次检查是否还有缺失
remaining_nulls = daily[new_cols].isnull().sum()
print("\n处理后缺失值:")
print(remaining_nulls[remaining_nulls > 0])
# ============ 6. 验证 ============
# 看一条具体数据,手动核对lag对不对
sample = daily[daily['report_date'] == '2014-02-10'].iloc[0]
print(f"\n验证 2014-02-10:")
print(f" 当天申购: {sample['total_purchase_amt']:,.0f}")
print(f" lag_1 (2月9日): {sample['total_purchase_amt_lag_1']:,.0f}")
print(f" lag_7 (2月3日): {sample['total_purchase_amt_lag_7']:,.0f}")
print(f" roll_mean_7 (近7天均值): {sample['total_purchase_amt_roll_mean_7']:,.0f}")
# ============ 7. 保存 ============
daily.to_csv('./step3_features.csv', index=False)
print("\n特征表已更新: step3_features.csv")
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
# 第1段:加载数据
daily = pd.read_csv('./step3_features.csv')
daily['report_date'] = pd.to_datetime(daily['report_date'])
2
- 读Step3.1保存的文件
- CSV存日期时会把datetime变成字符串,所以要重新转回来
daily = daily.sort_values('report_date').reset_index(drop=True)
这行极其重要!
sort_values('report_date') → 按日期排序
reset_index(drop=True) → 重新编号行索引,丢掉旧的。drop=True表示不要把旧索引存成新列
为什么要排序? 因为lag和rolling都是"往前数N行",如果数据不是按日期排的,shift(1)取到的不是昨天而是随便某天,整个特征就全废了。
知识点:为什么Step3.1不用排序?
因为Step3.1的groupby结果天然是按日期排的。但从CSV读回来后,顺序不保证,所以这里必须显式排序。这是做时间序列最基本的习惯:每次操作前先确认数据有序。
# 第2段:滞后特征
target_cols = ['total_purchase_amt', 'total_redeem_amt']
要做lag的列名列表,申购和赎回都要
for col in target_cols:
for lag in [1, 7, 30]:
daily[f'{col}_lag_{lag}'] = daily[col].shift(lag)
2
3
双重循环,拆开看:
外层循环 col 遍历两个目标列,内层循环 lag 遍历3个窗口大小。一共生成 2×3=6 列新特征。
daily[col].shift(lag) → 核心函数。把整列往下挪lag行
举个例子,假设数据长这样:
| 行号 | 日期 | 申购 |
|---|---|---|
| 0 | 1月1日 | 100 |
| 1 | 1月2日 | 120 |
| 2 | 1月3日 | 150 |
shift(1) 之后:
| 行号 | 日期 | 申购 | 申购_lag_1 |
|---|---|---|---|
| 0 | 1月1日 | 100 | NaN |
| 1 | 1月2日 | 120 | 100 |
| 2 | 1月3日 | 150 | 120 |
1月2日的lag_1 = 100,就是1月1日的值。往下挪1行,就等于"取昨天的值"。
shift(7) → 往下挪7行 = 取上周同一天的值
shift(30) → 往下挪30行 = 取大约上月同一天的值
知识点:为什么选1、7、30?
lag_1 → 昨天的值,最直接的"惯性"参考
lag_7 → 上周同一星期几,因为星期效应很强,上周一比昨天(周日)更有参考价值
lag_30 → 上月同日,捕捉月度周期
f'{col}lag{lag}' → f-string格式化字符串
f'...' 表示这是一个格式化字符串,花括号{}里的变量会被替换成实际值
比如col='total_purchase_amt',lag=7,结果就是'total_purchase_amt_lag_7'
这是Python生成列名的常用技巧,不用手动写6个赋值语句。
# 第3段:滑动窗口特征
for col in target_cols:
for window in [7, 30]:
daily[f'{col}_roll_mean_{window}'] = daily[col].rolling(window=window).mean()
daily[f'{col}_roll_std_{window}'] = daily[col].rolling(window=window).std()
2
3
4
2个目标列 × 2个窗口 × 2个统计量 = 8列新特征。
daily[col].rolling(window=7) → 创建一个"滑动窗口",每次看7行
.mean() → 对窗口内的7行求平均
.std() → 对窗口内的7行求标准差
滑动窗口的原理:
假设数据有10行,rolling(7).mean() 会这样算:
| 行号 | 窗口范围 | 结果 |
|---|---|---|
| 0 | 行0~6(前7行) | 这7行的均值 |
| 1 | 行1~7 | 这7行的均值 |
| 2 | 行2~8 | 这7行的均值 |
... ... ...
窗口像一把尺子,从数据上滑过去,每到一个位置就量一下"最近7天的平均水平"。
前6行不够7天,所以是NaN——这就是边界缺失。
知识点:mean vs std各有什么用?
roll_mean → 近期趋势,"最近一周平均每天申购多少"
roll_std → 近期波动,"最近一周申购额的波动有多大"。std大=不稳定,模型需要知道这一点
选7天和30天的理由:7天捕捉短期趋势(一周),30天捕捉中期趋势(一个月)。
# 第4段:查看缺失情况
new_cols = [c for c in daily.columns if '_lag_' in c or '_roll_' in c]
列表推导式,从所有列名里筛选出包含_lag_或_roll_的列名
if 'lag' in c → 检查字符串c里是否包含_lag_这个子串
daily[new_cols].head(35).isnull().sum()
daily[new_cols] → 只取新增特征列
.head(35) → 前35行
.isnull() → 每个格子是否为空,返回True/False的表
.sum() → True=1,加起来就是空值的个数
预期结果:lag_1最多1个空值,lag_7最多7个,lag_30和roll_30最多30个。
# 第5段:处理缺失值
daily = daily.iloc[30:].reset_index(drop=True)
.iloc[30:] → 从第30行开始取到末尾,丢弃前30行
前面说过,lag_30和roll_30需要30天历史,前30行一定有缺失,直接删掉最干净
知识点:为什么不用填空(比如用0或均值填充)?
填空等于给模型喂假数据。lag_30=0意思是"30天前申购额为0",这不是事实。删除虽然损失了30行数据,但剩下的数据都是真实的。
427行删掉30行还剩397行,完全够用。
# 第6段:验证
sample = daily[daily['report_date'] == '2014-02-10'].iloc[0]
print(f" 当天申购: {sample['total_purchase_amt']:,.0f}")
print(f" lag_1 (2月9日): {sample['total_purchase_amt_lag_1']:,.0f}")
2
3
取2月10日那一行,打印当天申购和lag_1
:,.0f → 格式化数字:逗号分隔千位,0位小数。比如3456789显示为3,456,789
你可以手动算一下:lag_1应该等于2月9日的申购额,lag_7应该等于2月3日的申购额。如果对得上,说明shift没问题。
# 3.3 特殊日期特征 + 整理最终特征表
还有几类特征没加,都是针对特定规律的:
| 特征 | 为什么需要 |
|---|---|
| is_month_start | 月初发工资→申购可能增加 |
| is_month_end | 月末还款→赎回可能增加 |
| days_to_monthend | 距月末几天,更细粒度地捕捉月末效应 |
| is_weekend | 简化版的星期区分,模型更容易学到 |
另外还需要做一件事:把最终特征表整理好,明确哪些列是特征(给模型看的)、哪些列是目标(模型要预测的),为下一步建模做好准备。
# 完整代码
""" Step3.3: 特殊日期特征 + 最终特征表整理 """
import pandas as pd
# ============ 1. 加载Step3.2的结果 ============
daily = pd.read_csv('./step3_features.csv')
daily['report_date'] = pd.to_datetime(daily['report_date'])
# ============ 2. 特殊日期特征 ============
# 月初标记(1-3号)
daily['is_month_start'] = daily['day_of_month'].apply(lambda x: 1 if x <= 3 else 0)
# 月末标记(28-31号)
daily['is_month_end'] = daily['day_of_month'].apply(lambda x: 1 if x >= 28 else 0)
# 距月末还有几天
daily['days_to_monthend'] = daily['report_date'].dt.days_in_month - daily['day_of_month']
# 是否周末
daily['is_weekend'] = (daily['weekday'] >= 5).astype(int)
# ============ 3. 整理特征列 ============
# 目标列(模型要预测的)
target_cols = ['total_purchase_amt', 'total_redeem_amt']
# 特征列(模型用来学习的)
feature_cols = [
# 时间特征
'weekday', 'day_of_month', 'is_weekend',
'month_1', 'month_2', 'month_3', 'month_4',
'month_5', 'month_6', 'month_7', 'month_8',
'month_9', 'month_10', 'month_11', 'month_12',
# 节假日特征
'is_holiday', 'is_extra_workday', 'is_workday',
# 特殊日期特征
'is_month_start', 'is_month_end', 'days_to_monthend',
# 滞后特征 - 申购
'total_purchase_amt_lag_1', 'total_purchase_amt_lag_7', 'total_purchase_amt_lag_30',
# 滞后特征 - 赎回
'total_redeem_amt_lag_1', 'total_redeem_amt_lag_7', 'total_redeem_amt_lag_30',
# 滑动窗口 - 申购
'total_purchase_amt_roll_mean_7', 'total_purchase_amt_roll_std_7',
'total_purchase_amt_roll_mean_30', 'total_purchase_amt_roll_std_30',
# 滑动窗口 - 赎回
'total_redeem_amt_roll_mean_7', 'total_redeem_amt_roll_std_7',
'total_redeem_amt_roll_mean_30', 'total_redeem_amt_roll_std_30',
]
print(f"特征数量: {len(feature_cols)}")
print(f"目标数量: {len(target_cols)}")
# ============ 4. 检查特征表完整性 ============
# 确认所有特征列都存在
missing_features = [c for c in feature_cols if c not in daily.columns]
if missing_features:
print(f"\n⚠️ 缺失的特征列: {missing_features}")
else:
print("\n✓ 所有特征列都存在")
# 检查缺失值
null_counts = daily[feature_cols + target_cols].isnull().sum()
null_counts = null_counts[null_counts > 0]
if len(null_counts) > 0:
print(f"\n⚠️ 还有缺失值:")
print(null_counts)
else:
print("✓ 无缺失值")
# ============ 5. 查看特征概览 ============
print(f"\n数据范围: {daily['report_date'].min().date()} ~ {daily['report_date'].max().date()}")
print(f"数据行数: {len(daily)}")
# 看看月初月末的申购赎回有没有差异
print("\n月初(1-3号)平均日申购:", daily[daily['is_month_start']==1]['total_purchase_amt'].mean() / 1e8, "亿")
print("非月初平均日申购: ", daily[daily['is_month_start']==0]['total_purchase_amt'].mean() / 1e8, "亿")
print("月末(28-31号)平均日赎回:", daily[daily['is_month_end']==1]['total_redeem_amt'].mean() / 1e8, "亿")
print("非月末平均日赎回: ", daily[daily['is_month_end']==0]['total_redeem_amt'].mean() / 1e8, "亿")
# ============ 6. 保存最终特征表 ============
output_cols = ['report_date'] + feature_cols + target_cols
daily[output_cols].to_csv('./step3_final_features.csv', index=False)
print(f"\n最终特征表已保存: step3_final_features.csv")
print(f"列数: {len(output_cols)} (日期 + {len(feature_cols)}特征 + {len(target_cols)}目标)")
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
# 第2段:特殊日期特征
daily['is_month_start'] = daily['day_of_month'].apply(lambda x: 1 if x <= 3 else 0)
apply(lambda x: ...) → 对这一列的每个值,执行一个匿名函数
lambda x: 1 if x <= 3 else 0 → 翻译成人话:如果x(几号)小于等于3,返回1;否则返回0
知识点:lambda是什么?
lambda就是"一次性小函数",不需要取名字,用完即走。完整写法是:
def is_start(x):
if x <= 3:
return 1
else:
return 0
daily['day_of_month'].apply(is_start)
2
3
4
5
6
lambda一行搞定,效果完全一样。当你只需要一个简单逻辑时,lambda更简洁。
知识点:1-3号算月初,为什么不是只算1号?
工资发放日不一定是1号,有的公司5号、10号发。1-3号都标成月初,给模型一个"窗口"去捕捉这个效应。如果标得太细(只标1号),数据太少模型学不到规律。
daily['is_month_end'] = daily['day_of_month'].apply(lambda x: 1 if x >= 28 else 0)
同理,28-31号标为月末
daily['days_to_monthend'] = daily['report_date'].dt.days_in_month - daily['day_of_month']
dt.days_in_month → 这个月有多少天(1月=31,2月=28或29...)
减去day_of_month → 距月末还有几天
知识点:为什么用"距月末几天"而不用"距月初几天"?
月末效应更常见——信用卡还款、工资发放都跟月末相关。距月末0天就是最后一天,距月末1天是倒数第二天,这个数字越小越接近月末,模型好理解。
daily['is_weekend'] = (daily['weekday'] >= 5).astype(int)
weekday >= 5 就是周六(5)和周日(6)
(条件表达式).astype(int) → 条件为True返回1,False返回0
知识点:已经有了weekday,为什么还要is_weekend?
weekday是7个值的细粒度特征,is_weekend是2个值的粗粒度特征。有时候模型从粗粒度更容易学到"工作日>周末"这个大规律,细粒度反而容易被噪声干扰。两个都给,让模型自己选。
# 第3段:整理特征列
把所有特征列名写成一个列表,明确告诉后面的模型"用这些列学习"。
为什么要手动列出来,而不是用所有列?
因为不是所有列都该给模型看:
report_date → 日期是索引,不是特征(模型不能从"20140115"这个数字里学到东西)
purchase_wan、redeem_wan → 只是单位转换后的展示列,跟原始列重复
month → 已经被One-Hot拆成了12列,原始列不能再用
如果把这些也喂给模型,会造成数据泄漏(重复信息让模型虚高)或者学习混乱(数字20140115没意义)。
# 第5段:验证月初月末效应
打印月初vs非月初的平均申购、月末vs非月末的平均赎回。
如果月初申购明显高于非月初,说明月初效应存在,is_month_start特征有用。
如果差异很小,说明这个特征可能没啥用,但留着也不碍事——模型会自动忽略没用的特征。
# 第6段:保存最终特征表
output_cols = ['report_date'] + feature_cols + target_cols
daily[output_cols].to_csv('./step3_final_features.csv', index=False)
2
只保留需要的列,按"日期→特征→目标"的顺序排列
这就是下一步建模的输入文件
# 特征工程完成,总结一下:
月初效应跟我们猜的相反——月初申购反而比非月初低(2.06亿 vs 2.33亿)。这个合理:很多人月初刚发工资还没来得及存,到月中才存进去。不过差异不算特别大,特征留着让模型自己判断。
月末赎回效应存在但偏弱——1.90亿 vs 1.81亿,高了约5%。不算强,但方向对。
Step3总结:我们造了35个特征
| 类别 | 特征 | 数量 |
|---|---|---|
| 时间 | weekday, day_of_month, is_weekend | 3 |
| 月份 | One-Hot month_1 ~ month_12 | 12 |
| 节假日 | is_holiday, is_extra_workday, is_workday | 3 |
| 特殊日期 | is_month_start, is_month_end, days_to_monthend | 3 |
| 滞后 | 申购lag_1/7/30 + 赎回lag_1/7/30 | 6 |
| 滑动窗口 | 申购roll_mean/std_7/30 + 赎回roll_mean/std_7/30 | 8 |
| 合计 | 35 |
397行数据、35个特征、2个目标,干干净净没有缺失。可以开始建模了。
# 3.4 加入收益率和Shibor特征
# step3.4.py - 加入收益率和Shibor特征
import pandas as pd
import numpy as np
# ========== 1. 加载已有特征 ==========
df = pd.read_csv('step3_final_features.csv', parse_dates=['report_date'])
print(f"原始特征: {df.shape[1]}列, {df.shape[0]}行")
# ========== 2. 处理收益率表 ==========
interest = pd.read_csv('mfd_day_share_interest.csv', parse_dates=['mfd_date'])
# 重命名方便理解
interest.rename(columns={
'mfd_daily_yield': 'daily_yield', # 每日万份收益
'mfd_7daily_yield': 'daily_yield_7d', # 7日年化收益率
}, inplace=True)
# 只保留需要的列,跟主表merge
interest_feat = interest[['mfd_date', 'daily_yield', 'daily_yield_7d']].copy()
# 收益率的lag和变化量
interest_feat['yield_lag_1'] = interest_feat['daily_yield'].shift(1)
interest_feat['yield_lag_7'] = interest_feat['daily_yield'].shift(7)
interest_feat['yield_change_1d'] = interest_feat['daily_yield'] - interest_feat['daily_yield'].shift(1)
interest_feat['yield_change_7d'] = interest_feat['daily_yield'] - interest_feat['daily_yield'].shift(7)
# 收益率的滑动均值
interest_feat['yield_roll_mean_7'] = interest_feat['daily_yield'].rolling(7).mean()
interest_feat['yield_roll_mean_30'] = interest_feat['daily_yield'].rolling(30).mean()
# ========== 3. 处理Shibor表 ==========
shibor = pd.read_csv('mfd_bank_shibor.csv', parse_dates=['mfd_date'])
# Shibor有7个期限,信息高度重叠,只选2个有代表性的
# Interest_O_N:隔夜利率,最敏感,反映银行短期流动性
# Interest_1_M:1个月利率,更稳定,反映中期预期
shibor_feat = shibor[['mfd_date', 'Interest_O_N', 'Interest_1_M']].copy()
# Shibor周末没数据(银行间市场不开),用前一个交易日的值填充
shibor_feat = shibor_feat.set_index('mfd_date').asfreq('D').ffill().reset_index()
# Shibor的lag和变化量
shibor_feat['shibor_on_lag_1'] = shibor_feat['Interest_O_N'].shift(1)
shibor_feat['shibor_on_change_1d'] = shibor_feat['Interest_O_N'] - shibor_feat['Interest_O_N'].shift(1)
shibor_feat['shibor_1m_lag_1'] = shibor_feat['Interest_1_M'].shift(1)
shibor_feat['shibor_1m_change_1d'] = shibor_feat['Interest_1_M'] - shibor_feat['Interest_1_M'].shift(1)
# ========== 4. 合并到主表 ==========
# 收益率按日期merge
df = df.merge(interest_feat, left_on='report_date', right_on='mfd_date', how='left')
df.drop('mfd_date', axis=1, inplace=True)
# Shibor按日期merge
df = df.merge(shibor_feat, left_on='report_date', right_on='mfd_date', how='left')
df.drop('mfd_date', axis=1, inplace=True)
# ========== 5. 检查缺失值 ==========
print(f"\n合并后: {df.shape[1]}列, {df.shape[0]}行")
missing = df.isnull().sum()
missing_cols = missing[missing > 0]
if len(missing_cols) > 0:
print("有缺失的列:")
print(missing_cols)
else:
print("无缺失值")
# ========== 6. 保存 ==========
df.to_csv('step3_final_features_v2.csv', index=False)
print(f"\n已保存: step3_final_features_v2.csv")
print(f"最终特征数: {df.shape[1] - 3}个(排除report_date和两个目标列)")
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
# 第一段:加载已有特征
import pandas as pd
import numpy as np
2
老朋友了,数据处理和数值计算。
# 第二段:处理收益率表
df = pd.read_csv('step3_final_features.csv', parse_dates=['report_date'])
print(f"原始特征: {df.shape[1]}列, {df.shape[0]}行")
2
parse_dates=['report_date']:读进来就把日期列转成datetime类型,后面merge时需要日期类型才能匹配
.shape[1]是列数,.shape[0]是行数,打印一下确认数据没变
interest = pd.read_csv('mfd_day_share_interest.csv', parse_dates=['mfd_date'])
收益率表的日期列叫mfd_date,不是report_date,所以parse_dates里写对应的列名
这张表427行3列:日期、每日万份收益、7日年化收益率
interest.rename(columns={
'mfd_daily_yield': 'daily_yield',
'mfd_7daily_yield': 'daily_yield_7d',
}, inplace=True)
2
3
4
rename:改列名。原始名太长带mfd_前缀,改成简短好认的名字
inplace=True:直接在原DataFrame上改,不创建新对象。等价于 interest = interest.rename(...)
interest_feat = interest[['mfd_date', 'daily_yield', 'daily_yield_7d']].copy()
双括号取多列(前面讲过了),只要日期和两个收益列
.copy():独立一份,后面加特征列不会污染原始interest
# 收益率的lag特征
interest_feat['yield_lag_1'] = interest_feat['daily_yield'].shift(1)
interest_feat['yield_lag_7'] = interest_feat['daily_yield'].shift(7)
2
shift(1):把整列往下移一行。第3行的值变成原来第2行的值,第1行变成NaN
为什么要移?因为"前1天的收益率"才是今天的特征。不移的话,你把今天的收益率当今天特征用——这叫数据泄漏,模型偷看到了"未来"信息
shift(7)同理,取7天前的值
画个图理解shift:
原始: [1.2, 1.3, 1.5, 1.4, 1.6] ← 每天的收益率
shift(1): [NaN, 1.2, 1.3, 1.5, 1.4] ← 每行变成"昨天的收益率"
shift(7): [NaN, NaN, NaN, NaN, NaN, NaN, NaN, 1.2, ...] ← 7天前的
2
3
# 收益率变化量
interest_feat['yield_change_1d'] = interest_feat['daily_yield'] - interest_feat['daily_yield'].shift(1)
interest_feat['yield_change_7d'] = interest_feat['daily_yield'] - interest_feat['daily_yield'].shift(7)
2
日变化:今天的收益 - 昨天的收益。正数=收益率在涨,负数=在跌
7日变化:今天的收益 - 7天前的收益,看一周趋势
为什么不直接用lag让模型自己减?可以,但显式提供变化量,等于帮模型省了一步计算,树模型分裂时更容易捕捉"收益率正在上升/下降"这个信号
# 收益率滑动均值
interest_feat['yield_roll_mean_7'] = interest_feat['daily_yield'].rolling(7).mean()
interest_feat['yield_roll_mean_30'] = interest_feat['daily_yield'].rolling(30).mean()
2
rolling(7):开一个7天的窗口,从当前行往前数7天
.mean():对这7天取平均
前6行不够7天,结果是NaN,这是正常的
和lag的区别:
lag_7:只看7天前那一天的值,一个点
roll_mean_7:看最近7天的平均值,一段趋势
类比:lag_7是"上周二你花了多少钱",roll_mean_7是"最近一周平均每天花多少钱"——后者更能反映趋势。
# 第三段:处理Shibor表
shibor = pd.read_csv('mfd_bank_shibor.csv', parse_dates=['mfd_date'])
shibor_feat = shibor[['mfd_date', 'Interest_O_N', 'Interest_1_M']].copy()
2
只选了隔夜(O_N)和1个月(1_M)两个期限,原因前面说了——7个期限高度相关,全加是冗余
shibor_feat = shibor_feat.set_index('mfd_date').asfreq('D').ffill().reset_index()
这行最复杂,拆开看:
set_index('mfd_date'):把日期列设为索引。pandas很多时间序列操作要求日期是索引
.asfreq('D'):把频率设为每天('D'=Day)。原来只有交易日有数据(294天),设了之后变成每天都有(427天),周末的日子会插入空行
.ffill():forward fill,用前面的值填充。周六周日的空行用周五的值填——周末银行间市场不开,利率不变,用周五的值是合理的
.reset_index():把日期从索引变回普通列,方便后面merge
流程示意:
原始: 2014-08-01 2.85
2014-08-04 2.90 ← 跳过了周末
asfreq('D'): 2014-08-01 2.85
2014-08-02 NaN ← 周六,插入空行
2014-08-03 NaN ← 周日
2014-08-04 2.90
ffill(): 2014-08-01 2.85
2014-08-02 2.85 ← 用周五的值填
2014-08-03 2.85
2014-08-04 2.90
2
3
4
5
6
7
8
9
10
11
Shibor的lag和变化量
shibor_feat['shibor_on_lag_1'] = shibor_feat['Interest_O_N'].shift(1)
shibor_feat['shibor_on_change_1d'] = shibor_feat['Interest_O_N'] - shibor_feat['Interest_O_N'].shift(1)
shibor_feat['shibor_1m_lag_1'] = shibor_feat['Interest_1_M'].shift(1)
shibor_feat['shibor_1m_change_1d'] = shibor_feat['Interest_1_M'] - shibor_feat['Interest_1_M'].shift(1)
2
3
4
跟收益率的lag和变化量逻辑完全一样
Shibor没加rolling,因为294天ffill到427天后周末都是重复值,rolling意义不大
# 第四段:合并到主表
df = df.merge(interest_feat, left_on='report_date', right_on='mfd_date', how='left')
df.drop('mfd_date', axis=1, inplace=True)
2
merge:pandas的表连接,类似SQL的JOIN
left_on='report_date':左表(df)用report_date列匹配
right_on='mfd_date':右表(interest_feat)用mfd_date列匹配
how='left':左连接,保留df的所有行,右表匹配不到的填NaN
为什么要drop? merge后两张表的日期列都保留了,mfd_date和report_date是同一个东西,留一列就够了
df = df.merge(shibor_feat, left_on='report_date', right_on='mfd_date', how='left')
df.drop('mfd_date', axis=1, inplace=True)
2
Shibor同理,再merge一次。
为什么用left join? 主表每天都有数据,附加表可能缺某些天的数据。left join保证主表不丢行,缺失的填NaN。如果用inner join,Shibor缺失的日子会把主表那行也删掉。
# 第五段:检查缺失值
missing = df.isnull().sum()
missing_cols = missing[missing > 0]
2
isnull():每个单元格是否为空,返回True/False的DataFrame .sum():True=1,False=0,按列求和就是每列的缺失数量 missing[missing > 0]:布尔索引,只显示有缺失的列
if len(missing_cols) > 0:
print("有缺失的列:")
print(missing_cols)
else:
print("无缺失值")
2
3
4
5
打印有缺失的列名和数量。Shibor在2013年7月之前没有数据,那几行会有NaN,但7月已经被我们跳过了,所以训练时不会有问题
# 第六段:保存
df.to_csv('step3_final_features_v2.csv', index=False)
index=False:不保存行索引(0,1,2...那列),加了没用还多一列
文件名带v2,跟v1区分开,不覆盖旧文件
print(f"最终特征数: {df.shape[1] - 3}个(排除report_date和两个目标列)")
减3是因为:report_date不算特征,total_purchase_amt和total_redeem_amt是目标列不算特征
52列 - 3 = 49个特征,跟Step4输出一致
# 第四步:建模
建模之前先想清楚一个关键问题:时间序列不能随机划分训练集和测试集。
二手车项目里,数据是独立的,A车和B车互不影响,随机抽80%训练、20%测试没问题。
但时间序列不一样:8月31日的数据依赖8月30日。如果你随机划分,可能拿8月30日训练、8月1日测试——模型在训练时已经"偷看"了未来,成绩虚高,真实预测时根本没这好处。
所以时间序列必须按时序切分:用前面的数据训练,后面的数据测试。具体来说:
训练集:2013.08 ~ 2014.07(约12个月)
测试集:2014.08(1个月,模拟预测9月)
2
为什么选8月做测试?因为8月跟9月相邻,用8月验证的效果最接近"真实预测9月"的效果。
模型方面,先用LightGBM跑一个baseline,原因:
已经用过,不陌生
速度快,调参方便
对时间序列特征效果好
35个特征不多,单模型先跑,后面再考虑融合
# 核心思路
切分方式:按时序,不能随机
训练集:2013-08-01 ~ 2014-07-31(12个月)
验证集:2014-08-01 ~ 2014-08-31(1个月)
跳过7月是因为冷启动数据偏低,可能干扰模型两个目标,两个模型
申购和赎回分开建模,它们规律不同(申购周一大,赎回没有)评估用竞赛指标
评估指标为什么用相对误差
竞赛评分是 |pred - actual| / actual,不是 MAE。
区别在哪?举个例子:
真实值100万,预测80万 → MAE=20万,相对误差=20%
真实值500万,预测480万 → MAE=20万,相对误差=4%
MAE一样,但第二个预测其实好得多。资金流动每天量级差很大(工作日vs周末、节假日vs平常),MAE会被大值主导,相对误差才是公平的。
最终得分 = 0.45 × 申购相对误差 + 0.55 × 赎回相对误差,赎回权重稍大。误差0得10分,误差>0.3得0分。lag特征的隐患先不管 训练时lag_1就是前一天的真实值,没问题。
但预测9月1号时,8月31号的值是真实的,没问题;预测9月2号时,9月1号的lag_1需要用9月1号的预测值;预测9月3号时,又依赖9月2号的预测值……误差会累积。
验证8月时不存在这个问题(lag值都是真实数据),所以验证集成绩会比实际预测9月好。这是正常的,后面Step5会专门处理。为什么先跑baseline不调参
第一次建模的目标不是拿最高分,是建立参照系:
当前特征组合大概什么水平
哪些特征重要哪些没用
误差集中在哪些天(周末?节假日?)
模型有没有明显过拟合
有了这些信息,才知道该加特征、调参数、还是换策略。盲目调参经常是在垃圾特征上浪费时间。
# 完整代码
# step4.py - LightGBM Baseline建模
import pandas as pd
import numpy as np
import lightgbm as lgb
from sklearn.metrics import mean_absolute_error
import matplotlib.pyplot as plt
import joblib
# ========== 1. 加载数据 ==========
df = pd.read_csv('step3_final_features_v2.csv', parse_dates=['report_date'])
print(df.columns.tolist())
print(f"数据形状: {df.shape}")
print(f"日期范围: {df['report_date'].min().date()} ~ {df['report_date'].max().date()}")
# ========== 2. 时序切分 ==========
# 跳过2013年7月(冷启动期),用8月开始训练
train_mask = (df['report_date'] >= '2013-08-01') & (df['report_date'] <= '2014-07-31')
val_mask = (df['report_date'] >= '2014-08-01') & (df['report_date'] <= '2014-08-31')
train = df[train_mask].copy()
val = df[val_mask].copy()
print(f"训练集: {len(train)}行, {train['report_date'].min().date()} ~ {train['report_date'].max().date()}")
print(f"验证集: {len(val)}行, {val['report_date'].min().date()} ~ {val['report_date'].max().date()}")
# ========== 3. 准备特征和目标 ==========
# 自动检测特征列:排除日期和目标
feature_cols = [c for c in df.columns if c not in ['report_date', 'total_purchase_amt', 'total_redeem_amt']]
print(f"特征数量: {len(feature_cols)}")
X_train = train[feature_cols]
X_val = val[feature_cols]
y_train_purchase = train['total_purchase_amt']
y_val_purchase = val['total_purchase_amt']
y_train_redeem = train['total_redeem_amt']
y_val_redeem = val['total_redeem_amt']
# ========== 4. 竞赛评估函数 ==========
def relative_error(y_true, y_pred):
"""单目标相对误差: mean(|pred-true|/true)"""
y_pred = np.maximum(y_pred, 0) # 预测值裁剪,不能为负
return np.mean(np.abs(y_pred - y_true) / y_true)
def competition_score(re_purchase, re_redeem):
"""
竞赛评分:综合相对误差 = 0.45*申购 + 0.55*赎回
误差0→10分,>0.3→0分
"""
total = 0.45 * re_purchase + 0.55 * re_redeem
score = max(0, 10 * (1 - total / 0.3))
return total, score
# ========== 5. LightGBM参数 ==========
# 先用保守默认参数跑baseline,不急着调参
lgb_params = {
'objective': 'regression',
'metric': 'mae',
'boosting_type': 'gbdt',
'num_leaves': 31, # 默认值,够用
'learning_rate': 0.05, # 小学习率+多轮数
'feature_fraction': 0.8, # 每棵树随机选80%特征
'bagging_fraction': 0.8, # 每棵树随机选80%样本
'bagging_freq': 5,
'verbose': -1,
'seed': 42,
}
# ========== 6. 训练申购模型 ==========
print("\n" + "="*50)
print("申购模型 (Purchase)")
print("="*50)
dtrain_p = lgb.Dataset(X_train, y_train_purchase)
dval_p = lgb.Dataset(X_val, y_val_purchase, reference=dtrain_p)
model_purchase = lgb.train(
lgb_params,
dtrain_p,
num_boost_round=1000, # 最多1000轮
valid_sets=[dtrain_p, dval_p],
valid_names=['train', 'val'],
callbacks=[
lgb.log_evaluation(period=200),
lgb.early_stopping(stopping_rounds=50), # 50轮不提升就停
]
)
print(f"最佳轮数: {model_purchase.best_iteration}")
# ========== 7. 训练赎回模型 ==========
print("\n" + "="*50)
print("赎回模型 (Redeem)")
print("="*50)
dtrain_r = lgb.Dataset(X_train, y_train_redeem)
dval_r = lgb.Dataset(X_val, y_val_redeem, reference=dtrain_r)
model_redeem = lgb.train(
lgb_params,
dtrain_r,
num_boost_round=1000,
valid_sets=[dtrain_r, dval_r],
valid_names=['train', 'val'],
callbacks=[
lgb.log_evaluation(period=200),
lgb.early_stopping(stopping_rounds=50),
]
)
print(f"最佳轮数: {model_redeem.best_iteration}")
# ========== 8. 验证集评估 ==========
pred_purchase = np.maximum(model_purchase.predict(X_val), 0)
pred_redeem = np.maximum(model_redeem.predict(X_val), 0)
mae_p = mean_absolute_error(y_val_purchase, pred_purchase)
mae_r = mean_absolute_error(y_val_redeem, pred_redeem)
re_p = relative_error(y_val_purchase.values, pred_purchase)
re_r = relative_error(y_val_redeem.values, pred_redeem)
total_error, score = competition_score(re_p, re_r)
print("\n" + "="*50)
print("验证集评估结果 (2014年8月)")
print("="*50)
print(f"申购 MAE: {mae_p:,.0f} 相对误差: {re_p:.4f} ({re_p*100:.2f}%)")
print(f"赎回 MAE: {mae_r:,.0f} 相对误差: {re_r:.4f} ({re_r*100:.2f}%)")
print(f"综合相对误差: {total_error:.4f} ({total_error*100:.2f}%)")
print(f"竞赛得分: {score:.2f} / 10")
# ========== 9. 特征重要性 ==========
plt.rcParams['font.sans-serif'] = ['SimHei'] # Windows黑体
plt.rcParams['axes.unicode_minus'] = False # 负号显示修复
fig, axes = plt.subplots(1, 2, figsize=(14, 6))
lgb.plot_importance(model_purchase, max_num_features=15, ax=axes[0], title='申购 Top15')
lgb.plot_importance(model_redeem, max_num_features=15, ax=axes[1], title='赎回 Top15')
plt.tight_layout()
plt.savefig('step4_feature_importance.png', dpi=150, bbox_inches='tight')
plt.show()
print("特征重要性图已保存: step4_feature_importance.png")
# ========== 10. 预测 vs 真实对比图 ==========
fig, axes = plt.subplots(2, 1, figsize=(12, 8))
dates_val = val['report_date']
axes[0].plot(dates_val, y_val_purchase.values, 'b-o', label='真实', markersize=4)
axes[0].plot(dates_val, pred_purchase, 'r--o', label='预测', markersize=4)
axes[0].set_title('申购:预测 vs 真实 (2014年8月)')
axes[0].legend()
axes[0].grid(True, alpha=0.3)
axes[1].plot(dates_val, y_val_redeem.values, 'b-o', label='真实', markersize=4)
axes[1].plot(dates_val, pred_redeem, 'r--o', label='预测', markersize=4)
axes[1].set_title('赎回:预测 vs 真实 (2014年8月)')
axes[1].legend()
axes[1].grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig('step4_pred_vs_actual.png', dpi=150, bbox_inches='tight')
plt.show()
print("预测对比图已保存: step4_pred_vs_actual.png")
# ========== 11. 逐日误差分析 ==========
val_result = val[['report_date', 'is_weekend']].copy()
val_result['purchase_true'] = y_val_purchase.values
val_result['purchase_pred'] = pred_purchase
val_result['purchase_re'] = np.abs(pred_purchase - y_val_purchase.values) / y_val_purchase.values
val_result['redeem_true'] = y_val_redeem.values
val_result['redeem_pred'] = pred_redeem
val_result['redeem_re'] = np.abs(pred_redeem - y_val_redeem.values) / y_val_redeem.values
print("\n申购误差最大5天:")
print(val_result.nlargest(5, 'purchase_re')[['report_date','purchase_true','purchase_pred','purchase_re']].to_string(index=False))
print("\n赎回误差最大5天:")
print(val_result.nlargest(5, 'redeem_re')[['report_date','redeem_true','redeem_pred','redeem_re']].to_string(index=False))
# 工作日 vs 周末
print("\n工作日 vs 周末平均相对误差:")
for col, name in [('purchase_re','申购'), ('redeem_re','赎回')]:
wd = val_result[~val_result['is_weekend'].astype(bool)][col].mean()
we = val_result[val_result['is_weekend'].astype(bool)][col].mean()
print(f" {name}: 工作日 {wd:.4f} | 周末 {we:.4f}")
# ========== 12. 保存模型 ==========
joblib.dump(model_purchase, 'model_purchase.pkl')
joblib.dump(model_redeem, 'model_redeem.pkl')
print("\n模型已保存: model_purchase.pkl, model_redeem.pkl")
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
# import部分
# step4.py - LightGBM Baseline建模
import pandas as pd
import numpy as np
import lightgbm as lgb
from sklearn.metrics import mean_absolute_error
import matplotlib.pyplot as plt
import joblib
2
3
4
5
6
7
- pandas / numpy:老朋友了,数据处理
- lightgbm as lgb:LightGBM的Python接口,用lgb简写是社区惯例
- mean_absolute_error:sklearn的MAE计算函数,方便对比;注意竞赛评分用相对误差,MAE只是辅助参考
- matplotlib.pyplot:画图
- joblib:保存模型文件。pickle也能存,但joblib对大numpy数组更高效
# 加载数据
# ========== 1. 加载数据 ==========
df = pd.read_csv('step3_final_features.csv', parse_dates=['report_date'])
print(f"数据形状: {df.shape}")
print(f"日期范围: {df['report_date'].min().date()} ~ {df['report_date'].max().date()}")
2
3
4
- parse_dates=['report_date']:读CSV时自动把这一列转成datetime类型,不用后续手动pd.to_datetime()
- .date():datetime对象只取日期部分(去掉时分秒),打印更干净
# 时序切分
# ========== 2. 时序切分 ==========
train_mask = (df['report_date'] >= '2013-08-01') & (df['report_date'] <= '2014-07-31')
val_mask = (df['report_date'] >= '2014-08-01') & (df['report_date'] <= '2014-08-31')
train = df[train_mask].copy()
val = df[val_mask].copy()
2
3
4
5
6
- 时序切分:用布尔掩码筛选行,跟二手车项目一样的手法
- .copy():新DataFrame独立于原df,修改不会互相影响。不copy的话是视图(view),改train可能意外改df
- 7月跳过了,8月开始训练,12个月训练 + 1个月验证
# 准备特征和目标
# ========== 3. 准备特征和目标 ==========
feature_cols = [c for c in df.columns if c not in ['report_date', 'total_purchase_amt', 'total_redeem_amt']]
print(f"特征数量: {len(feature_cols)}")
X_train = train[feature_cols]
X_val = val[feature_cols]
y_train_purchase = train['total_purchase_amt']
y_val_purchase = val['total_purchase_amt']
y_train_redeem = train['total_redeem_amt']
y_val_redeem = val['total_redeem_amt']
2
3
4
5
6
7
8
9
10
11
- 列表推导式自动筛选特征列:排除日期(不是数值特征)和两个目标列,剩下的全是特征
- 为什么要分开y? 申购和赎回各建一个模型,所以各取各的目标列
- X是大写(矩阵约定),y是小写(向量约定),这是机器学习的命名惯例
# 竞赛评估函数
# ========== 4. 竞赛评估函数 ==========
def relative_error(y_true, y_pred):
"""单目标相对误差: mean(|pred-true|/true)"""
y_pred = np.maximum(y_pred, 0) # 预测值裁剪,不能为负
return np.mean(np.abs(y_pred - y_true) / y_true)
2
3
4
5
- np.maximum(y_pred, 0):把负预测值裁剪成0。树模型理论上不会预测负数,但极端情况下可能出负值,保险起见裁掉
- np.abs(pred - true) / true:每天的相对误差,再.mean()取平均
def competition_score(re_purchase, re_redeem):
total = 0.45 * re_purchase + 0.55 * re_redeem
score = max(0, 10 * (1 - total / 0.3))
return total_error, score
2
3
4
- 竞赛公式:综合误差 = 0.45×申购 + 0.55×赎回
- 得分换算:误差0→10分,误差0.15→5分,误差0.3→0分。是线性映射
- max(0, ...):误差超过0.3时得分变成负数,裁成0
# LightGBM参数
# ========== 5. LightGBM参数 ==========
lgb_params = {
'objective': 'regression', # 回归任务
'metric': 'mae', # 评估指标用MAE
'boosting_type': 'gbdt', # 传统梯度提升
'num_leaves': 31, # 每棵树最多31个叶子
'learning_rate': 0.05, # 学习率0.05
'feature_fraction': 0.8, # 每棵树随机选80%特征
'bagging_fraction': 0.8, # 每棵树随机选80%样本
'bagging_freq': 5, # 每5轮做一次bagging
'verbose': -1, # 关闭训练日志
'seed': 42, # 随机种子,结果可复现
}
2
3
4
5
6
7
8
9
10
11
12
13
逐个参数解释:
- objective: regression:LightGBM支持分类、回归、排序等多种目标。我们预测连续数值,用回归。默认用L2损失(MSE),但我们指定metric=mae来评估
- metric: mae:只是评估用的指标,不影响训练损失函数。训练仍然用默认的MSE,但early_stopping和日志按MAE判断。如果你想训练也用MAE损失,要设objective: regression_l1。先用默认MSE跑baseline,后面可以试
- boosting_type: gbdt:最经典的梯度提升决策树。LightGBM还支持dart(加dropout防过拟合)和goss(基于梯度的采样),但gbdt最稳
- num_leaves: 31:控制树复杂度。31是默认值,大约对应深度5的树(2^5=32)。叶子越多模型越复杂,越容易过拟合
- learning_rate: 0.05:每棵树对最终预测的贡献比例。0.05偏小,意味着需要更多棵树才能收敛,但每一步走得更稳,不容易过拟合。配合early_stopping用很合适
- feature_fraction: 0.8:每棵树建的时候,随机挑80%的特征来用。20%不用,这样每棵树看到的信息不一样,增加多样性,降低过拟合风险
- bagging_fraction: 0.8:同理,每棵树只用80%的样本。和feature_fraction配合,双重随机
- bagging_freq: 5:每5轮重新采样一次。设0就不采样,设1每轮都采,5是折中
- verbose: -1:-1是静默模式,不打印训练过程。后面用log_evaluation控制输出节奏
- seed: 42:42是程序员传统吉祥数字,随便写的。固定种子保证每次跑结果一样
# 训练申购模型
# ========== 6. 训练申购模型 ==========
dtrain_p = lgb.Dataset(X_train, y_train_purchase)
dval_p = lgb.Dataset(X_val, y_val_purchase, reference=dtrain_p)
2
3
- lgb.Dataset:LightGBM自己的数据格式,比直接传numpy数组更高效(内部做了binning压缩)。sklearn接口用LGBMRegressor.fit(),原生接口用lgb.Dataset + lgb.train(),后者更灵活
- reference=dtrain_p:验证集要告诉LightGBM"参考训练集的特征分箱方式"。不设的话,验证集可能用自己的分箱,训练和验证的特征编码不一致
model_purchase = lgb.train(
lgb_params,
dtrain_p,
num_boost_round=1000,
valid_sets=[dtrain_p, dval_p],
valid_names=['train', 'val'],
callbacks=[
lgb.log_evaluation(period=200),
lgb.early_stopping(stopping_rounds=50),
]
)
print(f"最佳轮数: {model_purchase.best_iteration}")
2
3
4
5
6
7
8
9
10
11
12
- num_boost_round=1000:最多训练1000棵树。配合early_stopping,通常跑不到1000就停
- valid_sets:同时监控训练集和验证集的指标,方便看是否过拟合(训练集指标一直降,验证集开始升就是过拟合了)
- callbacks:回调函数,在训练过程中执行特定动作
- log_evaluation(period=200):每200轮打印一次指标。不设的话verbose=-1完全静默
- early_stopping(stopping_rounds=50):验证集指标连续50轮没改善就停。比如第300轮验证MAE最低,第301-350轮都没更低,就在350轮停,最终模型用第300轮的
- best_iteration:early_stopping记录的最佳轮数。后面预测时用predict(data, num_iteration=best_iteration),不过lgb.train返回的模型已经自动用了
# 验证集评估
# ========== 8. 验证集评估 ==========
pred_purchase = np.maximum(model_purchase.predict(X_val), 0)
pred_redeem = np.maximum(model_redeem.predict(X_val), 0)
2
3
- .predict(X_val):输出验证集每个样本的预测值,numpy数组
- np.maximum(..., 0):再裁一次负值,保险
mae_p = mean_absolute_error(y_val_purchase, pred_purchase)
mae_r = mean_absolute_error(y_val_redeem, pred_redeem)
re_p = relative_error(y_val_purchase.values, pred_purchase)
re_r = relative_error(y_val_redeem.values, pred_redeem)
total_error, score = competition_score(re_p, re_r)
2
3
4
5
- .values:把pandas Series转成numpy数组。我们的relative_error函数里用了np操作,传Series也能用,但.values更明确
- 同时算MAE和相对误差:MAE方便理解"差了多少钱",相对误差是竞赛评分依据
# 特征重要性
# ========== 9. 特征重要性 ==========
fig, axes = plt.subplots(1, 2, figsize=(14, 6))
lgb.plot_importance(model_purchase, max_num_features=15, ax=axes[0], title='申购 Top15')
lgb.plot_importance(model_redeem, max_num_features=15, ax=axes[1], title='赎回 Top15')
2
3
4
5
- lgb.plot_importance:LightGBM内置的特征重要性可视化,方便
- max_num_features=15:只画前15个最重要的,35个全画太挤
- 重要性怎么算的? 默认用split方式——某个特征在所有树中被用来分裂的次数。次数越多说明模型越依赖它。还有gain方式——按信息增益加权,更能反映特征的"贡献度"。split方式简单直观,baseline先用它
# 预测VS真实对比图
# ========== 10. 预测 vs 真实对比图 ==========
axes[0].plot(dates_val, y_val_purchase.values, 'b-o', label='真实', markersize=4)
axes[0].plot(dates_val, pred_purchase, 'r--o', label='预测', markersize=4)
2
3
- b-o':蓝色实线+圆点标记
- 'r--o':红色虚线+圆点标记
- 两条线叠在一起,一眼看出哪天偏了
# 逐日误差分析
# ========== 11. 逐日误差分析 ==========
val_result = val[['report_date', 'is_weekend']].copy()
val_result['purchase_re'] = np.abs(pred_purchase - y_val_purchase.values) / y_val_purchase.values
2
3
- 构建一个结果DataFrame,方便后续分析
- 逐日算相对误差,找出"哪几天预测最差"
print("\n申购误差最大5天:")
print(val_result.nlargest(5, 'purchase_re')[['report_date','purchase_true','purchase_pred','purchase_re']].to_string(index=False))
2
- .nlargest(5, 'purchase_re'):按purchase_re列取最大的5行,比sort_values().head()更直观
- .to_string(index=False):打印时不显示行索引,更干净
for col, name in [('purchase_re','申购'), ('redeem_re','赎回')]:
wd = val_result[~val_result['is_weekend'].astype(bool)][col].mean()
we = val_result[val_result['is_weekend'].astype(bool)][col].mean()
print(f" {name}: 工作日 {wd:.4f} | 周末 {we:.4f}")
2
3
4
- 按工作日/周末分组算平均误差,看模型在周末是不是更差
- .astype(bool):is_weekend列可能是0/1整数,转bool后~取反才正确
# 保存模型
# ========== 12. 保存模型 ==========
joblib.dump(model_purchase, 'model_purchase.pkl')
joblib.dump(model_redeem, 'model_redeem.pkl')
2
3
- 为什么要保存? 训练好的模型后面要反复用(调参对比、生成提交文件),不保存每次重新训练浪费时间
- .pkl是pickle/joblib的文件后缀,二进制格式
- 加载用joblib.load('model_purchase.pkl')
# 结果
# 申购模型 (Purchase)
Training until validation scores don't improve for 50 rounds
Early stopping, best iteration is:
[119] train's l1: 2.27321e+07 val's l1: 3.41002e+07
最佳轮数: 119
# 赎回模型 (Redeem)
Training until validation scores don't improve for 50 rounds
Early stopping, best iteration is:
[135] train's l1: 1.65537e+07 val's l1: 3.37583e+07
最佳轮数: 135
# 验证集评估结果 (2014年8月)
申购 MAE: 34,100,164 相对误差: 0.1250 (12.50%)
赎回 MAE: 33,758,307 相对误差: 0.1369 (13.69%)
综合相对误差: 0.1315 (13.15%)
竞赛得分: 5.62 / 10
特征重要性图已保存: 
预测对比图已保存: 
# 解读结果
特征重要性解读
# 申购模型 Top5
| 排名 | 特征 | 重要性 | 含义 |
|---|---|---|---|
| 1 | roll_mean_7 | 96 | 最近7天申购均值 |
| 2 | roll_std_7 | 90 | 最近7天申购波动 |
| 3 | weekday | 90 | 星期几 |
| 4 | lag_1 | 75 | 昨天申购额 |
| 5 | lag_7 | 63 | 上周同天申购额 |
核心发现:滑动窗口 > lag > 时间特征
roll_mean_7排第一说明:今天申购多少,跟"最近一周的平均水平"关系最大。这很合理——工作日申购多、周末少,7天均值把周期平滑了,是一个比单天lag更稳定的基准线。
roll_std_7排第二很有意思——波动性也有用。最近一周申购波动大的时候,今天也容易出极端值。模型用波动性来判断"今天要不要大胆预测"。
weekday排第三——星期效应确实强,我们Step1的发现被模型验证了。
# 赎回模型 Top3
| 排名 | 特征 | 重要性 | 含义 |
|---|---|---|---|
| 1 | redeem_roll_mean_7 | 130 | 最近7天赎回均值 |
| 2 | redeem_roll_std_7 | 122 | 最近7天赎回波动 |
| 3 | purchase_lag_1 | 76 | 昨天的申购额 |
第三条很关键:赎回模型的第三重要特征是"昨天申购了多少"。逻辑说得通——昨天大量买入,今天可能就有人卖出套利。赎回不仅看赎回自己的历史,还受申购影响。
收益率特征的表现
yield_change_1d排第6(申购)、yield_change_1d排第5(赎回),收益率变化量比收益率本身更有用。说明模型关心的是"收益率在涨还是跌"这个信号,而不是"收益率是多少"。涨的时候申购增加,跌的时候赎回增加——符合直觉。
Shibor特征没进Top15,说明对当前模型贡献不大,但也没害处(LightGBM会自动忽略低重要性特征)。
# 预测对比图解读
申购图(上):
蓝线(真实)在8月1号有个大尖峰(3.7亿),红线(预测)只有2.2亿,差了快一倍——这是误差最大的一天
整体走势贴合不错,红线基本跟着蓝线起伏,峰值偏低
8月下旬蓝线又有一个峰(8月25号),红线也跟上了但幅度不够
赎回图(下):
8月27号蓝线突然飙到4.7亿,红线只到3亿——模型完全没预料到这么大的赎回
8月14号也有一个异常峰值没捕捉到
其他日子两条线贴合得还行
共同规律:模型能跟趋势,但峰值预测偏保守。 这几乎是树模型的通病——它倾向于预测均值附近,对极端值天然保守。后面的优化方向之一就是让模型更敢预测峰值。
# 数字指标解读
得分5.62分,离10分还差4.38分。 换算成误差:当前综合误差13.15%,满分需要0%误差,0分是30%误差。我们大概在中间偏左的位置。
MAE的量级是3400万,意思是平均每天差3400万。听起来很大,但日均申购/赎回在2-3亿级别,相对误差13%左右,作为baseline可以接受。
赎回权重55% > 申购45%,而赎回误差(13.69%)比申购(12.50%)高,所以综合被赎回拖了后腿。优化赎回的收益更大。