AI大赛-资金流入流出-时间序列法

更新时间: 2026-06-17 09:03:58

# 赛题

蚂蚁金服拥有上亿会员并且业务场景中每天都涉及大量的资金流入和流出,面对如此庞大的用户群,资金管理压力会非常大。在既保证资金流动性风险最小,又满足日常业务运转的情况下,精准地预测资金的流入流出情况变得尤为重要。此届大赛以《资金流入流出预测》为题,期望参赛者能够通过对例如余额宝用户的申购赎回数据的把握,精准预测未来每日的资金流入流出情况。对货币基金而言,资金流入意味着申购行为,资金流出为赎回行为 。

竞赛中使用的数据主要包含四个部分,分别为用户基本信息数据、用户申购赎回数据、收益率表和银行间拆借利率表。下面分别介绍四组数据。

  1. 用户信息表
    用户信息表: 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 星座 射手座
  1. 用户申购赎回数据表
    用户申购赎回数据表: 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 :脱敏后的数据保证了今日余额 = 昨日余额 + 今日申购 - 今日赎回,不会出现负值。

  1. 收益率表
    收益表为余额宝在 14 个月内的收益率表: mfd_day_share_interest 。具体字段如表格 3 中所示
列名 类型 含义 示例
mfd_date string 日期 20140102
mfd_daily_yield double 万份收益,即 1 万块钱的收益。 1.5787
mfd_7daily_yield double 七日年化收益率( % ) 6.307
  1. 上海银行间同业拆放利率(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
  1. 收益计算方式
    本赛题的余额宝收益方式,主要基于实际余额宝收益计算方法,但是进行了一定的简化,此处计算简化的地方如下:

首先,收益计算的时间不再是会计日,而是自然日,以 0 点为分隔,如果是 0 点之前转入或者转出的金额算作昨天的,如果是 0 点以后转入或者转出的金额则算作今天的。

然后,收益的显示时间,即实际将第一份收益打入用户账户的时间为如下表格,以周一转入周三显示为例,如果用户在周一存入 10000 元,即 1000000 分,那么这笔金额是周一确认,周二是开始产生收益,用户的余额还是 10000 元,在周三将周二产生的收益打入到用户的账户中,此时用户的账户中显示的是 10001.1 元,即 1000110 分。其他时间的计算按照表格中的时间来计算得到。

表格 5 : 简化后余额宝收益计算表

转入时间 首次显示收益时间
周一 周三
周二 周四
周三 周五
周四 周六
周五 下周二
周六 下周三
周天 下周三
  1. 选手需要提交的结果表:
    表 格 6 选手提交结果表: tc_comp_predict_table

|字段|类型|含义|示例| |report_date|bigint|日期|20140901| |purchase|bigint|申购总额|40000000| |redeem|bigint|赎回总额|30000000|

每一行数据是一天对申购、赎回总额的预测值, 2014 年 9 月每天一行数据,共 30 行数据。 Purchase 和 redeem 都是金额数据,精确到分,而不是精确到元。
评分数据格式要求与“选手结果数据样例文件”一致,结果表命名为:tc_comp_predict_table, 字段之间以逗号为分隔符

  1. 评估指标
    评估指标的设计主要期望选手对未来 30 天内每一天申购和赎回的总量数据预测的越准越好,同时考虑到可能存在的多种情况。譬如有些选手在 30 天中 29 天预测都是非常精准的但是某一天预测的结果可能误差很大,而有些选手在 30 天中每天的预测都不是很精准误差较大,如果采用绝对误差则可能导致前者的成绩比后者差,而在实际业务中可能更倾向于前者。所以最终选用积分式的计算方法:每天的误差选用相对误差来计算,然后根据用户预测申购和赎回的相对误差,通过得分函数映射得到一个每天预测结果的得分,将 30 天内的得分汇总,然后结合实际业务的倾向,对申购赎回总量预测的得分情况进行加权求和,得到最终评分。具体的操作如下:
  1. 计算所有用户在测试集上每天的申购及赎回总额与实际情况总额的误差。

  2. 申购预测得分与 Purchasei 相关,赎回预测得分与 Redeemi 相关 , 误差与得分之间的计算公式不公布,但保证该计算公式为单调递减的,即误差越小,得分越高,误差与大,得分越低。当第 i 天的申购误差 Purchasei =0 ,这一天的得分为 10 分;当 Purchasei > 0.3 ,其得分为 0 。

  3. 最后公布总积分 = 申购预测得分 *45%+ 赎回预测得分 *55% 。

# 赛题要点提炼

项目 内容
训练数据 2013.07.01 ~ 2014.08.31(14个月)
预测目标 2014.09.01 ~ 09.30 每天的申购总额和赎回总额(共30行)
评估方式 相对误差
权重 申购45% + 赎回55%
数据粒度 用户×天级别,但只需预测每日汇总

几个关键洞察:

  • 相对误差——意味着预测小额日子的误差容忍度极低(比如某天总额小,同样的绝对偏差误差率就大)。要特别关注低金额日期的预测精度
  • 赎回权重更大(55%)——赎回预测比申购更重要,精力分配上要偏向赎回
  • 2014年9月——包含中秋节(9月8日),节假日效应会很明显
  • 收益率和Shibor是外部特征——利率高→申购多,利率变化→赎回冲动

# 整体路线

原始数据(284万行)→ 按天聚合(427行)→ 选稳定期(184行)
→ log压缩 → auto_arima找最优参数 → 训练SARIMA → 预测30天
→ 反log还原 → 提交
1
2
3

一句话概括:把乱七八糟的原始数据整理成干净的单变量时间序列,压缩方差,让算法自己找最优参数,预测完还原。

# 思路:拿到一个时间序列问题怎么分析

第一步:先问自己——这是时间序列问题吗?

判断标准很简单:今天和昨天有关系吗?

股价:有,今天的价格很大程度上取决于昨天 → 是
抛硬币:没有,上次正面不影响的下次 → 不是
你的项目:今天申购额和昨天高度相关 → 是

如果是,就该考虑时间序列模型。

# 第二步:看数据长什么样

不要急着建模,先画图。画完问自己三个问题:

① 有趋势吗? → 数据整体在涨还是跌?

有趋势 → 不平稳,ARIMA需要先差分处理
没趋势 → 平稳,直接建模

② 有周期/季节性吗? → 数据有没有固定间隔的重复模式?

有星期效应 → 用SARIMA,m=7
有月度效应 → 用SARIMA,m=30
有年度效应 → 用SARIMA,m=365
都有 → 选最强的那个,或者后面用Prophet处理多周期

③ 方差均匀吗? → 大值的时候波动大,小值的时候波动小?

不均匀 → log变换(金融数据几乎都不均匀)
均匀 → 不用变换

你的项目:有趋势(不平稳)✓ 有星期效应 ✓ 方差不均匀 ✓ → 所以需要差分 + SARIMA(m=7) + log变换

什么是方差

方差衡量的是数据有多分散。
举个例子,两个班平均分都是70:

表格
A班 65 68 70 72 75
B班 30 50 70 90 110

A班分数很集中,B班分数很散。A班方差小,B班方差大。
计算方式:每个数和平均值的差,平方后求平均

方差 = [(65-70)² + (68-70)² + (70-70)² + (72-70)² + (75-70)²] / 5
     = [25 + 4 + 0 + 4 + 25] / 5
     = 11.6
1
2
3

为什么时间序列要关心方差?
因为SARIMA有个假设:波动幅度应该差不多。但金融数据不是这样:

工作日:申购3亿,波动±5000万(波动相对于值,比例小)
周末:  申购3000万,波动±1000万(波动相对于值,比例大)
1
2

大值波动大,小值波动小——方差不均匀。SARIMA处理不好这种情况,所以要用log变换把它压匀。

# 第三步:数据要不要截取?

看看数据有没有"不正常"的阶段:

产品刚上线的冷启动期
一次性事件的异常飙升
数据采集方式变了

这些异常段会误导模型。ARIMA的假设是"历史规律延续到未来",如果历史里有异常,模型会学歪。

处理方式:去掉异常段,只用正常期训练。你项目里去掉了2013年冷启动和2014年春节爆发,只用了3-8月稳定期,就是这个道理。

# 第四步:要不要变换?

最常见的变换就是log,判断标准:

画图看:波动幅度随值增大 → log
直觉判断:金融数据、销售数据 → 几乎都要log
算一下:大值的方差 > 小值的方差 → log

什么是log变换

log就是对数,你高中学过的那个:log₁₀(100) = 2,因为10² = 100。
在时间序列里,我们用的是自然对数ln(底数e≈2.718)。
它做了什么
把大数压缩得多,小数压缩得少:

原始值 log1p(x) 压缩了多少
10 2.40 4倍
100 4.62 22倍
1000 6.91 145倍
10000 9.21 1086倍

10000被压成了9,10只被压成了2.4。大数被狠狠压缩,小数几乎没变。
为什么金融数据要log
你的申购数据:

工作日:2亿~4亿,波动幅度几千万  
周末:  几百万,波动幅度几百万  
1
2

不做log:模型看到工作日波动几千万,觉得周末几百万的波动可以忽略 → 周末预测很差
做了log:大值被压缩,小值相对放大 → 模型会认真对待每一天的波动 → 周末也预测得准

还原 log1p的逆运算是expm1:

原始值 → log1p → 模型处理 → expm1 → 还原值
  100  →  4.62  →  预测4.80  →  121  → 差不多
1
2

log的本质是压缩大的波动,放大小值的细节,让模型不会只盯着大值学。

# 第五步:选参数——交给auto_arima

SARIMA有6个参数(p,d,q)(P,D,Q,s),手动试太痛苦。auto_arima的逻辑:

s:你自己定(你观察到星期效应就填7)
d和D:算法通过ADF检验自动判断要不要差分
p,q,P,Q:算法遍历搜索,选AIC最低的组合

AIC = 拟合质量 - 复杂度惩罚,越低越好。不要选AIC最低但参数特别多的模型,那可能过拟合。

什么是AIC

AIC = Akaike Information Criterion(赤池信息量准则)
它衡量的是:这个模型好不好,值不值得用。

好的方向 坏的方向
参数多 拟合得更准 太复杂,可能过拟合
参数少 简洁,不容易过拟合 拟合不够好

AIC同时考虑这两个因素:拟合越好AIC越低,但每多加一个参数AIC会惩罚性升高。

生活类比
你选房子:
100平米,50万 → AIC低,划算
200平米,50万 → AIC更低,更划算
200平米,200万 → AIC高,不划算(虽然大但太贵了)
参数就像面积,拟合就像价格。AIC帮你找"性价比最高的"。
所以:AIC越低越好,但不是越复杂越好,因为复杂会被惩罚。

SARIMA的6个参数

SARIMA(p, d, q)(P, D, Q, s),分两组:
非季节部分 (p, d, q)——处理日常规律

参数 名称 含义 大白话
p AR阶数 用前p天的值预测今天 看几天历史走势
d 差分阶数 做几次差分变平稳 去掉几次趋势
q MA阶数 用前q天的意外预测今天 看几天突发事件余波

季节部分 (P, D, Q, s)——处理周期规律

参数 名称 含义 大白话
P 季节AR 用前P个周期的值预测 看几周前同一天的走势
D 季节差分 做几次季节差分 去掉几次周期趋势
Q 季节MA 用前Q个周期的意外预测 看几周前的突发事件
s 周期长度 一个周期有多长 7=按周,12=按月,365=按年

用你的项目举例:SARIMA(1,1,1)(1,0,2)[7]

非季节:看1天走势惯性 + 差分1次 + 看1天意外余波
季节:看1周前同一天的走势 + 不做季节差分 + 看2周前的意外余波
s=7:周期是7天(星期)
1
2
3

auto_arima是什么

auto_arima就是自动帮你在众多参数组合里挑出最优的那个。

# 为什么需要它

SARIMA有6个参数(p,d,q)(P,D,Q,s),其中s是你定的(比如7),剩下5个要你选。

组合有多少?假设搜索范围 p=0~3, q=0~3, P=0~2, Q=0~2, d=0~2, D=0~2:

4 × 4 × 3 × 3 × 3 × 3 = 1296 种组合
1

手动试?你要跑1296次模型,每次还得看AIC对比,疯了。

# auto_arima怎么做的

它不是暴力遍历1296种,而是像爬山一样找最低点:

起点:最简单的参数 (0,0,0)(0,0,0)
  ↓ 试一下往p+1走,AIC降低了?继续
  ↓ 试一下往q+1走,AIC升高了?不走
  ↓ 试一下P+1,AIC降低了?继续
  ↓ ...各方向都试过了,AIC没法再降了
  ↓ 停!当前参数就是最优的
1
2
3
4
5
6

就像你站在山上找谷底:往每个方向迈一步,哪边更低就往哪走,走到四周都比当前位置高,就到谷底了。

这就是代码里stepwise=True的意思——逐步搜索,不是蛮力遍历。

# 代码里每个参数的含义

auto_arima(
    series,              # 你的时间序列数据
    seasonal=True,       # 开启季节性搜索
    m=7,                 # 周期长度,7=星期
    d=None,              # 自动确定差分阶数(None=让算法自己决定)
    D=None,              # 自动确定季节差分阶数
    start_p=0, start_q=0,# p和q从0开始搜
    max_p=3, max_q=3,    # p和q最多搜到3
    start_P=0, start_Q=0,# 季节P和Q从0开始搜
    max_P=2, max_Q=2,    # 季节P和Q最多搜到2
    trace=True,          # 打印搜索过程(你看的那些AIC输出就是它打的)
    stepwise=True,       # 逐步搜索,快
    suppress_warnings=True,  # 屏蔽警告
    error_action='ignore'    # 某组参数报错就跳过
)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

你需要关心的就两个:

m:你观察到周期是几天就填几,这个必须你自己定
max_p/max_q/max_P/max_Q:搜索范围上限。数据量小就设小点(2~3),数据量大可以设大点(5~6)。设太大跑得慢,还容易过拟合

# 它和手动选参数的区别

手动选 auto_arima
速度 慢,每种组合都要自己跑 快,智能搜索
可靠性 靠经验,容易漏 系统遍历,不遗漏
灵活性 你完全掌控 搜完你可以微调
适合谁 熟手 新手友好

你跑的时候看到的那一堆输出:

ARIMA(0,1,0)(0,0,0)[7] : AIC=120.529
ARIMA(1,1,0)(1,0,0)[7] : AIC=70.575
...
Best model: ARIMA(1,1,1)(1,0,2)[7]  
1
2
3
4

就是它在一步步试,AIC从120一路降到-41,最后锁定最优组合。

# 一句话总结

auto_arima就是一个"参数自动调优器",你告诉它搜索范围和周期,它自己爬到AIC最低点,把最优参数还给你。

# 第六步:预测完记得还原

如果你做了log变换,预测完要expm1变回来。如果你做了差分,SARIMA内部会自动还原。容易忘的是log那步。

# 第七步:看看预测结果合不合理

不要直接提交,先肉眼检查:

预测值的量级对不对?(应该和训练期差不多) 有没有负数?(金融数据不能为负) 星期模式有没有?(周一应该比周六高) 趋势方向对不对?

# 总结成口诀

先画图,看三样:趋势、周期、方差  
有趋势 → 差分  
有周期 → SARIMA设m  
方差不齐 → log  
参数不会选 → auto_arima  
预测完 → 反变换 + 截断负值  
提交前 → 肉眼检查  
1
2
3
4
5
6
7

# 代码实现

"""
蚂蚁金服资金预测 - SARIMA方案 v2(auto_arima + log变换)
"""
import pandas as pd
import numpy as np
from pmdarima import auto_arima
import warnings
warnings.filterwarnings('ignore')

# ========== 1. 加载并聚合数据 ==========
print("Step1: 加载数据...")

try:
    data = pd.read_csv('./data/user_balance_table.csv')
except FileNotFoundError:
    print("错误:找不到文件,请检查路径")
    exit()

data['report_date'] = pd.to_datetime(data['report_date'], format='%Y%m%d')

daily = data.groupby('report_date').agg({
    'total_purchase_amt': 'sum',
    'total_redeem_amt': 'sum'
}).reset_index()

daily = daily.sort_values('report_date').reset_index(drop=True)
daily = daily.set_index('report_date')
daily = daily.asfreq('D')
daily = daily.fillna(method='ffill')

print(f"数据范围: {daily.index[0]} ~ {daily.index[-1]}")

# ========== 2. 选取稳定期数据 ==========
print("\nStep2: 选取训练集 (2014-03-01 至 2014-08-31)...")

train_start = '2014-03-01'
train_end = '2014-08-31'
daily_stable = daily.loc[train_start:train_end]

purchase_series = daily_stable['total_purchase_amt']
redeem_series = daily_stable['total_redeem_amt']

print(f"训练天数: {len(daily_stable)}")

# ========== 3. log变换 ==========
print("\nStep3: log变换(稳定方差)...")

purchase_log = np.log1p(purchase_series)
redeem_log = np.log1p(redeem_series)

print("log变换完成")

# ========== 4. auto_arima自动搜索最优参数 ==========
print("\nStep4: auto_arima搜索最优参数...")
print("(这可能需要几分钟,请耐心等待)")

def find_best_model(series, name):
    """用auto_arima搜索最优SARIMA参数"""
    print(f"\n  搜索 {name} 最优参数...")
    model = auto_arima(
        series,
        seasonal=True,
        m=7,
        d=None,
        D=None,
        start_p=0, start_q=0,
        max_p=3, max_q=3,
        start_P=0, start_Q=0,
        max_P=2, max_Q=2,
        trace=True,
        stepwise=True,
        suppress_warnings=True,
        error_action='ignore'
    )
    print(f"  {name} 最优参数: order={model.order} seasonal_order={model.seasonal_order}")
    print(f"  AIC: {model.aic():.2f}")
    return model

purchase_model = find_best_model(purchase_log, '申购')
redeem_model = find_best_model(redeem_log, '赎回')

# ========== 5. 预测 ==========
print("\nStep5: 预测9月...")

steps = 30
pred_purchase_log = purchase_model.predict(n_periods=steps)
pred_redeem_log = redeem_model.predict(n_periods=steps)

# 反log变换
pred_purchase = np.expm1(pred_purchase_log)
pred_redeem = np.expm1(pred_redeem_log)

# 负值截断
pred_purchase = np.maximum(pred_purchase, 0)
pred_redeem = np.maximum(pred_redeem, 0)

print("  预测结果预览(前5天):")
for i in range(5):
    date_str = pd.date_range('2014-09-01', periods=steps)[i].strftime('%m-%d')
    print(f"    {date_str}: 申购={pred_purchase[i]/1e8:.4f}亿, 赎回={pred_redeem[i]/1e8:.4f}亿")

# ========== 6. 生成提交文件 ==========
print("\nStep6: 生成提交文件...")

submission = pd.DataFrame({
    'report_date': pd.date_range('2014-09-01', periods=steps).strftime('%Y%m%d'),
    'total_purchase_amt': pred_purchase.astype(int),
    'total_redeem_amt': pred_redeem.astype(int)
})

submission.to_csv('submission_arima.csv', sep=',', header=False, index=False)

print("完成!文件已保存为 submission_arima.csv")
print("\n前5行预览:")
print(submission.head())
1
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

# 导入部分

import pandas as pd
1

pandas是Python最常用的数据处理库。你可以把它理解成"代码版Excel",读表格、筛选、分组、聚合都能做。缩写成pd是约定俗成的,所有人都这么写。

import numpy as np
1

numpy是数学计算库,提供数组运算和数学函数。我们用的log1p、expm1、maximum都在里面。缩写成np也是约定俗成。

from pmdarima import auto_arima
1

从pmdarima库里导入auto_arima函数。from ... import ...的意思是"从这个库里只拿这一个函数用",不用把整个库都导入。

import warnings
warnings.filterwarnings('ignore')
1
2

warnings是Python的警告系统。模型训练时会弹出一些不影响运行的警告(比如"模型可能不收敛"),filterwarnings('ignore')把它们全部屏蔽掉,让输出更干净。不影响结果,只是让你看得更清爽。

# Step 1:加载并聚合数据

try:
    data = pd.read_csv('./data/user_balance_table.csv')
except FileNotFoundError:
    print("错误:找不到文件,请检查路径")
    exit()
1
2
3
4
5

try...except:异常处理。先尝试执行try里的代码,如果报了FileNotFoundError(文件不存在),就执行except里的代码,打印提示然后退出(exit()) 知识点:如果不用try-except,文件不存在时Python会直接报一大堆红色错误,程序崩溃。加了之后可以优雅地提示用户哪里出了问题 pd.read_csv:读CSV文件,返回一个DataFrame(就是pandas的表格)

data['report_date'] = pd.to_datetime(data['report_date'], format='%Y%m%d')
1

data['report_date']:取表格里的report_date列
pd.to_datetime:把字符串转成datetime类型
format='%Y%m%d':告诉pandas原始数据的格式。%Y=4位年份,%m=2位月份,%d=2位日期。所以20140701就会被识别为2014年7月1日
为什么要转? 字符串"20140701"只是一个文字,pandas不知道它是日期。转成datetime后,才能按日期排序、切片、算时间差

daily = data.groupby('report_date').agg({
    'total_purchase_amt': 'sum',
    'total_redeem_amt': 'sum'
}).reset_index()
1
2
3
4

这是整个Step1最核心的一行,拆成三步理解:

groupby('report_date'):按日期分组。原始数据每用户每天一行,同一天可能有几千行。groupby把同一天的所有行归到一组 .agg({...: 'sum'}):对每组做聚合操作。'sum'表示求和——把同一天所有用户的申购加起来得到当天总申购,赎回同理 .reset_index():groupby之后日期变成了索引(行号),reset_index把它变回普通列,方便后面操作

类比:就像Excel里按日期列做数据透视表,值汇总方式选"求和"

daily = daily.sort_values('report_date').reset_index(drop=True)  
1

sort_values('report_date'):按日期从早到晚排序。时间序列必须按时间排好序,否则模型会把未来的数据当成过去的
reset_index(drop=True):排序后行号乱了,重新编。drop=True表示不要保留旧的行号列

daily = daily.set_index('report_date')
1

把日期列设为索引。SARIMA要求索引是时间类型,这样它才知道数据是时间序列。设好之后,你可以用daily.loc['2014-03-01':'2014-08-31']直接按日期范围切片。

daily = daily.asfreq('D')  
1

asfreq('D'):把频率设为按天(D=Day)
作用:检查有没有缺日期。如果某天没数据(比如银行系统维护),这行会自动插入一行NaN
为什么必须做? SARIMA要求时间序列是连续的,中间不能有断档。缺了一天,模型会认为第二天和前天是相邻的,时间关系就乱了

daily = daily.fillna(method='ffill')  
1

fillna:填充缺失值 method='ffill':forward fill,用前一天的值填充。比如周日没数据,就用周六的值
为什么不用0? 如果填0,模型会学到"某天突然变成0了"这个错误信号。用前值填充更合理——周末的行为模式接近周五,不会突然归零

# Step 2:选取稳定期数据

train_start = '2014-03-01'
train_end = '2014-08-31'
daily_stable = daily.loc[train_start:train_end]
1
2
3

daily.loc['2014-03-01':'2014-08-31']:利用datetime索引做时间切片,只取3月1日到8月31日的数据
为什么不用全部数据? 因为你之前分析过,2013年7月是产品冷启动(数据偏低),2014年1-2月是春节爆发(数据异常高)。ARIMA的假设是"历史规律延续到未来",如果历史里有异常,模型会学歪。只用稳定期,模型学到的是"正常"的规律

purchase_series = daily_stable['total_purchase_amt']
redeem_series = daily_stable['total_redeem_amt']
1
2

把申购和赎回分别提取出来,变成单独的Series(pandas的一维数据,可以理解为一列)。

知识点:SARIMA是单变量模型,一次只能预测一个指标。所以申购和赎回要分别建两个模型。

# Step 3:log变换

purchase_log = np.log1p(purchase_series)
redeem_log = np.log1p(redeem_series)
1
2

np.log1p(x):计算log(1+x)
为什么加1? 因为log(0)是负无穷会报错。加1之后,即使原始值是0,log1p(0)=log(1)=0,不会出错
为什么做log? 金融数据"大值波动大,小值波动小"。工作日申购几亿波动几千万,周末几千万波动几百万。SARIMA假设波动幅度恒定,log变换把大波动压缩、小波动相对放大,让数据更"均匀"
预测完怎么还原? 用np.expm1()(Step5里会用到)

# Step 4:auto_arima自动搜索

def find_best_model(series, name):
1

定义一个函数,封装auto_arima的调用。这样申购和赎回都能复用,不用写两遍。

    model = auto_arima(
        series,
        seasonal=True,        # 开启季节性搜索
        m=7,                  # 周期长度=7天(星期)
        d=None,               # 自动确定差分阶数
        D=None,               # 自动确定季节差分阶数
        start_p=0, start_q=0, # p和q从0开始搜
        max_p=3, max_q=3,     # p和q最多搜到3
        start_P=0, start_Q=0, # 季节P和Q从0开始搜
        max_P=2, max_Q=2,     # 季节P和Q最多搜到2
        trace=True,           # 打印搜索过程
        stepwise=True,        # 逐步搜索
        suppress_warnings=True,
        error_action='ignore' # 报错就跳过
    )
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

逐个参数讲:

series:传入的时间序列数据(log变换后的)
seasonal=True:告诉auto_arima要考虑季节性。不设的话它只会搜普通ARIMA
m=7:季节周期=7。这是唯一需要你自己判断的参数——你观察到数据有星期效应,就填7。如果是月度数据填12,年度填365
d=None, D=None:差分阶数让算法自己决定。None不等于0,None是"你来算",0是"我确定不用差分"
start_p=0, max_p=3:p的搜索范围是0到3。p=0表示不用AR部分,p=3表示看前3天
max_P=2, max_Q=2:季节部分搜索范围0到2。季节部分参数太大容易过拟合,所以范围比非季节小
trace=True:打印每一步尝试的参数和AIC值,就是你在控制台看到的那一堆输出
stepwise=True:逐步搜索,像爬山找谷底,不是暴力遍历所有组合。快很多,结果通常差不多
error_action='ignore':某些参数组合可能导致训练失败(比如数据不满足模型假设),遇到就跳过,不要停下来

    print(f"  {name} 最优参数: order={model.order} seasonal_order={model.seasonal_order}")
    print(f"  AIC: {model.aic():.2f}")
    return model
1
2
3

model.order:找到的最优(p,d,q),比如(1,1,1)
model.seasonal_order:找到的最优(P,D,Q,s),比如(1,0,2,7)
model.aic():对应的AIC值
return model:把训练好的模型返回,后面用它预测

purchase_model = find_best_model(purchase_log, '申购')
redeem_model = find_best_model(redeem_log, '赎回')
1
2

分别对申购和赎回调用函数,得到两个训练好的模型。

# Step 5:预测

steps = 30
pred_purchase_log = purchase_model.predict(n_periods=steps)
pred_redeem_log = redeem_model.predict(n_periods=steps)
1
2
3

n_periods=30:预测未来30步(9月1日到30日)
注意:预测出来的是log空间的值,不是原始金额

pred_purchase = np.expm1(pred_purchase_log)
pred_redeem = np.expm1(pred_redeem_log)
1
2

np.expm1(x) = exp(x) - 1,是log1p的逆运算
之前用log1p压缩,现在用expm1还原。配套使用,不能混(如果你用了np.log,还原就要用np.exp)
知识点:如果你忘了还原,提交的预测值会非常小(因为log把几亿压缩到了个位数),分数会极差

pred_purchase = np.maximum(pred_purchase, 0)
pred_redeem = np.maximum(pred_redeem, 0)
1
2

np.maximum(a, 0):把a中所有负数替换成0
为什么要做? 模型可能预测出负数(特别是周末金额小的时候,反log后可能有微小负值),但申购赎回金额不可能为负

for i in range(5):
    date_str = pd.date_range('2014-09-01', periods=steps)[i].strftime('%m-%d')
    print(f"    {date_str}: 申购={pred_purchase[i]/1e8:.4f}亿, 赎回={pred_redeem[i]/1e8:.4f}亿")
1
2
3

打印前5天预测值,方便你快速检查结果合不合理:

pd.date_range('2014-09-01', periods=30):生成9月1日到30日的日期序列
[i].strftime('%m-%d'):取第i个日期,格式化为"09-01"这种
/1e8:除以1亿,把"分"转成"亿"方便看。1e8就是1×10⁸=1亿
:.4f:保留4位小数

# Step 6:生成提交文件

submission = pd.DataFrame({
    'report_date': pd.date_range('2014-09-01', periods=steps).strftime('%Y%m%d'),
    'total_purchase_amt': pred_purchase.astype(int),
    'total_redeem_amt': pred_redeem.astype(int)
})
1
2
3
4
5

pd.DataFrame({...}):用字典创建表格。字典的key是列名,value是列数据
strftime('%Y%m%d'):日期格式化为20140901这种纯数字格式,天池平台要求
.astype(int):转成整数。竞赛要求提交整数(单位是分),小数部分直接截掉

submission.to_csv('submission_arima.csv', sep=',', header=False, index=False)
1

sep=',':逗号分隔列。天池平台的要求
header=False:不写列名行。天池不要列名
index=False:不写行号。天池不要行号

最终文件内容长这样:

20140901 357482180 313838390
20140902 298316147 271645385
...
1
2
3

# 整体数据流

284万行原始数据
    ↓ groupby按天聚合
427行日度数据
    ↓ 切片选稳定期
184行训练数据
    ↓ log1p压缩
log空间的时间序列
    ↓ auto_arima搜索+训练
SARIMA模型
    ↓ predict 30步
log空间的预测值
    ↓ expm1还原 + 截断负值 + 转整数
原始空间的预测值
    ↓ to_csv
提交文件
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

每一步都是为了让下一步能正确执行。漏了任何一步,结果都会出问题。

最后我把结果提交了,98分~恭喜又提高20分