ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

DFM模型实战:解析学生消费行为与特征工程落地

DFM模型实战:解析学生消费行为与特征工程落地 简介这是一份基于DFM模型的学生消费行为分析Python项目资源适合校园数据分析、后勤管理工作者以及学习聚类分析与综合评价方法的数据科学初学者。项目综合运用K-Means聚类与层次分析法围绕早餐、午餐、晚餐以及工作日/休息日等维度分析校园消费数据刻画不同学生群体的消费特征进而为食堂运营调整、档口备餐和学生经济状况判定提供量化参考。压缩包共26个文件约20.78MB包含3个Python脚本、5份CSV数据集、8张结果可视化PNG图、docx/md说明文档和项目配置文件其中model.py与analysis.py覆盖数据预处理、聚类建模与细分模型构建requirements.txt便于快速复现运行环境目录结构清晰适合按步骤对照学习。目前已有553人学习可在作者提供的完整代码和分析文档基础上理解DFM建模、K-Means聚类和层次分析法的落地流程并迁移至其他校园或用户消费场景是一份可上手的项目级参考资料。1. DFM模型分析学生消费行为不是让模型学会花钱而是看懂花钱背后的规律校园一卡通每天产生几十万条流水刷卡时间、商户窗口、消费金额、余额全部落在数据库里。传统的统计报表能算出人均月消费但算不出谁最近突然报复性消费或者谁几个月都在吃最便宜的套餐。DFMDeep Factorization Machine深度因子分解机是从推荐系统里杀出来的模型擅长在稀疏特征上做自动交叉把它搬过来分析学生消费行为能发现很多规则引擎发现不了的东西。这篇文章不是科普而是一条完整的落地路径怎么把流水变成特征怎么用Python把DFM搭起来参数怎么调以及我实际踩坑之后留下的几个可复用经验。适合手里有一卡通数据、想做精准资助或消费预警的从业者。2. 拆开DFMFM和Deep如何联手处理消费特征2.1 FM部分低阶交叉解决商户类型 消费时段这类实际问题DFM不是独门秘籍它本质上是传统因子分解机Factorization Machine和深度神经网络的融合。FM的看家本领是捕捉二阶特征交叉比如早餐时段_包子窗口同时出现或者女生_奶茶店这种组合放在LR里需要人工手写交叉特征而FM通过给每个特征学习一个低维向量embedding让交叉权重由两个向量点积算出这样即使某个组合在训练样本里只出现几次也能通过向量的相似度泛化。FM公式里的二阶交叉项形如sum_i sum_j v_i, v_j * x_i * x_j。在学生消费行为里这对应着卡号_商户类别、宿舍楼_消费时间这些组合。比如一个住在图书馆附近的学生深夜消费可能更多是便利店而不是食堂。如果只靠一阶特征模型看到的是宿舍楼东区时间23:00商户便利店它学不到三者关系。FM让每个特征有向量两个特征的交叉贡献就是它们向量内积模型瞬间就能表达东区宿舍和便利店的相关性。实现时FM层并不复杂。把每个离散特征做embedding后先算所有embedding的求和向量和平方和向量再套用公式二阶交叉部分等于0.5 * (sum_sq - sq_sum)。这里的sum_sq是每维embedding求和后取平方sq_sum是每维embedding平方后求和。这个技巧能让复杂度从O(n^2)降到O(n)是工程上的关键。我一般把这个逻辑写成一个自定义Layer免得在训练时重复计算。2.2 Deep部分高阶组合让模型自动挖掘消费降级信号光有FM还不够消费行为中有些模式是超过二阶的比如连续三天中午消费金额低于10元 晚归 频繁去打印店这种复合信号。FM只做两两交叉要表达三个以上特征的组合只能靠特征工程自己拼。DFM的Deep部分是一个普通的多层全连接网络输入是每个特征embedding拼接后的向量经过几层ReLU后自动学出高阶非线性组合。Deep部分的存在让DFM从线性内核 少量交叉升级成线性 二阶交叉 高阶交叉三合一。理论上LR是DC? 实际上Deep部分是黑匣子你不知道它学出了什么组合但它确实能捕捉到类似家庭困难低月消费 食堂而非外卖 打折窗口的潜在模式。Deep部分的输入维度是所有特征embedding维数之和所以embedding维度不能太大否则第一层全连接参数量会爆炸。在代码层面Deep部分就是几行Dense BatchNormalization Dropout。很多人误以为DFM就是FM加了个神经网络其实关键在embedding要共享同一个特征在FM和Deep中必须使用同一份embedding向量否则相当于两个模型各学各的破坏了特征表示的统一性。这也是我最早踩的坑——在实现里开了两个embedding表结果训练完精度差得离谱。2.3 为什么是DFM而不是LR、GBDT或纯DNN面对学生消费数据最常见的方案是逻辑回归和GBDT。LR训练快、可解释但对特征交叉极其依赖流水里有几十个原始特征想人工构造有效的交叉至少需要上百个组合维护成本高。GBDT能自动做特征分裂但面对高基数的类别特征比如每个学生卡号一个特征每个商户窗口一个特征树模型要么选择不做切分要么过拟合而且树模型没法直接输出有泛化意义的特征向量。纯DNN没有FM的低阶交叉约束直接输入高维稀疏特征训练时会因为参数太多而学不动。你可以用一个多层神经网络从零开始训练但在一卡通数据这种极度稀疏的输入上第一层往往无法收敛到理想的embedding。DFM的实际收益是FM部分像个稳定的抓手让模型在低阶交叉上不迷路Deep部分则负责探索高阶组合。两者共享embedding训练效率高离线AUC比LR高3到5个百分点在行业内是常见的幅度。所以选DFM不是因为它酷而是因为它同时满足了三个条件能处理千万级稀疏编码、能自动交叉、在单机GPU上能训练。学生消费流水没有商品推荐那么大的数据量DFM实际上还有点大材小用但换来的效果确实值得。3. 把一卡通流水变成训练样本特征工程是成败关键3.1 样本定义预测下月消费异常还是资助等级模型训练之前先要回答一个问题到底预测什么。学生消费行为分析至少可以拆成三个任务一是预测下个月会不会出现短期透支余额低于警戒线二是预测消费金额等级低、中、高三是判断某个学生是否属于消费明显低于同类标准。DFM是监督学习模型需要标签。我一般建议做二分类——下月是否会出现连续三天日均消费低于10元这种硬指标原因是可获得性强一卡通有明确的消费流水这个标签能从下个月数据里自动生成不需要人工标注。假设我们按自然月生成样本每个学生每个月是一行。特征用当月的消费统计标签看下月是否出现异常消费。这样训练样本数大概是学生数乘以月数一个一万人的学校一年就是12万条足够DFM跑了。样本里要注意时间顺序切分不能用未来数据预测过去这会在第5章单独讲。在代码里我用pandas做过流水聚合import pandas as pd # 流水表: student_id, trans_time, merchant_type, amount flow pd.read_csv(card_flow.csv, parse_dates[trans_time]) flow[month] flow[trans_time].dt.to_period(M) # 当月聚合特征 monthly flow.groupby([student_id, month]).agg( total_amount(amount, sum), total_cnt(amount, count), avg_amount(amount, mean), max_amount(amount, max), late_cnt(trans_time, lambda x: ((x.dt.hour 22) (x.dt.hour 23)).sum()), merchant_uniq(merchant_type, nunique) ).reset_index() # 构造标签: 下月是否连续三天日均消费 10元 next_month flow.groupby([student_id, month]).apply( lambda g: (g.groupby(g[trans_time].dt.date)[amount].sum() 10).rolling(3).sum().max() 0 ).rename(label)这段代码有两个设计点。merchant_uniq统计学生本月去过多少种不同商户这个特征在DFM里会被当作类别特征嵌入实际上编码了生活丰富度late_cnt是深夜频次和消费习惯有关。label的定义里用了rolling(3).sum().max()意思是三天窗口内是否存在至少一天满足条件由于rolling后结果非0即1这里判断其大于0就是连续三日低消费。要注意lambda函数在跨月份时会有边界效应我一般会在聚合前用reset_index()清理索引否则apply的结果会变成多层索引。3.2 类别特征编码卡号、商户、楼栋如何进入embeddingDFM要求所有离散特征先编号成整数ID再查embedding表。学生卡号、商户类型、宿舍楼、窗口编号、消费时段早中晚这五类最常见。不能直接标量编码比如把卡号编码成0、1、2这样输入Dense层那会让模型误以为卡号之间存在数值大小关系破坏语义。正确做法是给每个卡号一个唯一index然后用tf.keras.layers.Embedding查向量。这里有个容量问题卡号基数等于全校人数假设5万人商户类型可能只有几十个时段只有4个。卡号的嵌入维度可以给16或32商户类型给8时段给4。维度过大一是参数量大二是容易过拟合。我一般用一个经验公式min(256, 基数^0.25)比如5万卡号的0.25次方约等于15取16合适。构建输入时要用tf.keras.Input(shape(1,), dtypetf.int64)作为占位最后把每个embedding输出Flatten()后拼接。注意字符串不能直接输入需要先手工map成整数。我习惯把映射表存在一个Python字典里训练和预测用同一个映射表预测阶段遇到新学生时映射不到需要给一个unk_id0兜底。3.3 数值特征归一化金额、频次、消费间隔的分布陷阱月消费金额、日均消费次数、单笔最大金额这类连续特征最容易被直接扔进模型。但如果用了ReLU激活大数值会压得梯度不稳定即使不用ReLU金额分布一长尾少数人月消费几千多数人几百模型也容易被极端值带偏。解决办法不是简单min-max而是先做对数变换再标准化。比如total_amount先log1p再减均值除标准差。消费次数是整数有时更像泊松分布sqrt变换比log更能稳定方差。实操时我在特征列里标注is_continuousTrue训练前统一做变换from sklearn.preprocessing import StandardScaler import numpy as np # 对连续特征做log1p后标准化 for col in [total_amount, avg_amount, max_amount]: monthly[col] np.log1p(monthly[col]) scaler StandardScaler() monthly[[total_amount, avg_amount, max_amount]] scaler.fit_transform( monthly[[total_amount, avg_amount, max_amount]] )log1p处理了金额为0的情况比如当月没消费某类窗口。标准化很重要因为后续Deep部分的全连接层默认初始化都假设输入是0均值、单位方差。如果不做标准化第一层梯度可能在某个维度特别大导致整个网络的learning rate不敢调高。你也可以不用StandardScaler而用MinMaxScaler但在金额这种无界特征上StandardScaler的表现更稳。特征工程里还有一个隐藏问题时段的划分。不要直接把23:15作为数值输入最好离散成宵夜时段、晚自习后等类别让FM做交叉。另外月份本身也要作为类别特征因为开学季、考试周、寒暑假前的消费模式完全不同不放进模型里模型就学不到季节性。4. 用Python搭一个DFMTensorFlow 2实现与训练4.1 构建DFM模型核心类共享embedding与FM层我用TensorFlow 2的Keras Functional API来写清晰且容易调试。核心是自定义FM Layer和共享embedding的逻辑。下面的代码结构可以直接用在学生消费数据上注意所有离散特征都先输入稀疏ID连续特征直接输入。import tensorflow as tf from tensorflow.keras import layers, Model class FMLayer(layers.Layer): def __init__(self, **kwargs): super().__init__(**kwargs) def call(self, inputs): # inputs: (batch_size, num_of_fields, embedding_dim) sum_square tf.square(tf.reduce_sum(inputs, axis1)) square_sum tf.reduce_sum(tf.square(inputs), axis1) second_order 0.5 * (sum_square - square_sum) return tf.reduce_sum(second_order, axis1, keepdimsTrue)这个FMLayer每次传进来的是一个三维张量[batch, 字段数, embedding维度]它就是FM二阶交叉的完整计算。为什么是0.5 * (sum_square - square_sum)因为(ab)^2 - (a^2b^2) 2ab它等价于所有两两向量的点积之和后再乘以0.5。注意两个向量点积mod? 具体推导可以看FM原论文这里只需要知道它让二阶交叉的计算复杂度从O(n^2)降到O(n)。共享embedding的做法是FM和Deep不能各自新建embedding层而要用同一个层。Keras里可以把这个embedding层先定义出来然后FM侧和Deep侧都调用它。注意一个输入特征对应一个embedding层不能对两个不同特征用同一个embedding层除非像物品类别那样想共享语义。4.2 组装模型输入定义到输出预测下面是完整组装代码我尽量保留了可直接运行的细节# 离散特征配置: (名称, 基数, embedding维数) cat_cols { student_id: (50000, 16), # 每个学生一个ID merchant_type: (50, 8), dorm_area: (20, 4), time_slot: (4, 3), } # 连续特征列表 num_cols [total_amount, total_cnt, avg_amount, late_cnt] # 输入层 inputs {} cat_inputs [] for name, (vocab, dim) in cat_cols.items(): inp tf.keras.Input(shape(1,), namename, dtypetf.int64) inputs[name] inp cat_inputs.append(inp) num_inputs [] for name in num_cols: inp tf.keras.Input(shape(1,), namename, dtypetf.float32) inputs[name] inp num_inputs.append(inp) # 共享embedding embedding_outs [] for name, (vocab, dim) in cat_cols.items(): emb_layer layers.Embedding(input_dimvocab, output_dimdim, namefemb_{name}) emb emb_layer(inputs[name]) # (batch, 1, dim) embedding_outs.append(emb) # 拼接所有离散特征embedding用于FM和Deep emb_concat layers.Concatenate(axis1)(embedding_outs) # (batch, num_fields, dim) # FM部分 fm_out FMLayer()(emb_concat) # (batch, 1) # Deep部分 deep_input layers.Flatten()(emb_concat) deep_input layers.Concatenate()([deep_input] num_inputs) # 数值特征拼进来 d1 layers.Dense(256, activationrelu)(deep_input) d1 layers.BatchNormalization()(d1) d1 layers.Dropout(0.3)(d1) d2 layers.Dense(128, activationrelu)(d1) d2 layers.Dropout(0.3)(d2) deep_out layers.Dense(1, activationNone)(d2) # 联合预测: sigmoid作用于加和 concat_o layers.Concatenate()([fm_out, deep_out]) # 拼成两列 outputs layers.Dense(1, activationsigmoid)(concat_o) model Model(inputsinputs, outputsoutputs) model.compile(optimizeradam, lossbinary_crossentropy, metrics[auc])这里有几个关键决定。第一Deep部分的输入不仅包括离散特征的embedding还包括连续特征本身。连续特征不embedding直接拼接在Flatten后的向量尾部。第二FM和Deep最后加和之前我是把它们拼成一个两维向量再过一个Dense这比直接相加多了一层可学习权重效果通常更好可解释性也不差。第三BatchNormalization放在ReLU之前稳定深层网络的分布Dropout取0.3稀疏特征下不容易丢信息。4.3 关键参数设置与推荐范围DFM的训练参数直接影响最终效果。embedding维度上面提过了学习率是另一个大头。我用Adam默认lr0.001时Deep部分收敛快FM部分有些滞后因为FM的梯度计算依赖特征共现频率。推荐先把学习率设到0.0005到0.001之间训练5个epoch后看验证集AUC不再提升再降一半。Batch size我一般取256或512。一卡通数据样本不大但每个样本是一个学生一个月的聚合batch太大反而对稀疏ID的embedding更新不充分太小则训练震荡。还有一个有争议的参数是num_fields学生卡号、商户类型、宿舍区、时段一共4个字段如果加入更多离散特征比如是否周末、食堂窗口标志FM层会学到更多交叉但也要警惕字段噪声。模型结构上Deep部分从128到256宽度都常见。不要动辄512因为输入维度本身才几十第一层太宽容易过拟合。调试时可以把隐藏层节点数从64开始递增观察验证集AUC变化超过200后收益递减。最后FM层和Deep层共享embedding后embedding的生效学习率可以独立设置用layers.Embedding的name区分想精细控制可以通过model.get_layer(emb_student_id)单独设置学习率但一般没必要。5. 避坑与常见问题训练翻车后我做了什么5.1 现象AUC 卡在0.5训练loss一点不降原因往往是特征映射顺序错乱。比如训练集里student_id从1编码到50000预测时却新出现了一个ID模型查不到对应embedding直接报错更隐蔽的是如果某个ID在训练集里没出现过映射表里根本不会有它模型默认填充0。但AUC 0.5通常是更基础的错误我在早期直接把字符串输入模型或者把类别特征当作连续特征标准化了。解决方法是先打印model.predict的输入形状确认每个离散输入都是整数ID再用np.unique(np.diff(sorted(df[student_id])))检查编码是否有断层。5.2 现象显存爆掉batch size调小也没用有一回模型参数量莫名其妙超过2亿查后发现是embedding维度设成了300。我当时想基数大就应该用更大维度实际上5万ID的嵌入维度设为300参数就是1500万Deep第一层再乘上256参数量直接爆炸。后来我固定了经验公式min(64, int(vocab ** 0.25))并把连续特征从Deep输入中分离只保留必要维度。另外卡号这列如果不做频次过滤把那种只出现过一次的一卡通ID也保留进embedding表不仅浪费内存还会让模型记住无意义的个体ID。先做filter(min_count5)即频次小于5的ID统一归到unk能显著缩表。5.3 现象训练loss下降但线上统计完全没效果这是最典型的离线拟合、线上翻车。原因在于我当年前使用了随机切分数据集同一个学生的不同月份数据同时出现在训练集和验证集模型记住了学生ID验证时靠看到过这个人来拿高分。可线上对一个从来没见过的学生做预测成绩立刻崩溃。解决方式是人为构造留出学生验证法把全部学生按9:1分训练/验证确保验证集里的学生从未出现在训练集。同理如果目标是跨学期预测训练集只取前8个月验证集取第9个月。这种时间切分是DFM消费行为分析的底线操作。5.4 现象正负样本比例1比100预测全是负类学生消费异常本来就少见如果标签定义太苛刻正样本比例极低。模型为了降低loss会把所有样本都预测成负类。AUC看着还行但实际召回率是0。解决乱? 一是改标签阈值比如把连续三天低于10元放宽为连续两天低于15元二是在训练时做负样本下采样把负样本随机抽到正样本的3倍同时在评估时用原始全量数据算AUC。也可以在loss里给正样本加权model.compile里没有直接的class_weight需要手动构造样本权重传入fit的sample_weight参数。我试过正样本权重设3效果不错但如果正样本噪声大太高的权重会让模型输出极端。6. 一个让模型落地可信的验证技巧按时间切分做消费分层最后分享一个我现在每次都用的小技巧不要只报告整体AUC而是按消费水平把学生分层分层看每个类别的Recall和Precision。做法是先用全体学生的月均消费金额算出分位数把验证集拆成低消费组、中等消费组、高消费组然后分别计算模型在每个组上的召回率。DFM可能整体AUC是0.82但仔细观察会发现高消费组里的异常行为召回率只有0.3而低消费组召回率却高达0.7。这说明模型其实更擅长识别低消费学生的进一步恶化对高消费学生的突发异常不敏感。针对这个现象我的习惯是再衍生一个特征该生当月消费金额相对自身前三个月均值的偏离度。这个特征能帮助模型在任意消费水平上捕捉突变效果比增加embedding维度立竿见影。验证时也不要用单月验证至少要滚动验证三个月份训练用第1到第8个月验证第9个月再训练第1到第9个月验证第10个月。三次验证结果取平均值避免某个月的特殊事件比如疫情封校造成误判。最后要提醒的是DFM不是能解释因果的模型它只给相关性。在学生消费行为分析这种偏隐私敏感的领域别直接用单个模型结果做定性结论我一般把DFM的预测分数当作排序依据再让辅导员人工复核排名前50的学生。从工程角度看这套方案一小时能训练完几万条样本预测单个学生耗时不到一毫秒完全够日常跑批。希望这篇落地笔记里的代码和避坑经验能帮到正在折腾这类数据的人。本文还有配套的精品资源点击获取
返回列表