做股票类App运营和数据的人,基本都会遇到同一个头疼点:客户明明没有销户,却连续几个月不登录、不交易,等行情一来,人已经悄悄跑到别家平台去了。我在参与这类项目时最常用的方案,就是基于交易行为数据训练一个逻辑回归模型,提前找出未来30天大概率“沉默不交易”的客户,把预警名单推给运营做挽留。整套思路用Python实现并不复杂,核心工作在于把流失定义、特征窗口、标签口径想清楚,模型本身反而是相对简单的一环。这篇内容适合刚入门机器学习、但已经不想只跑通Demo的读者,也适合在券商、互联网金融平台做数据分析或模型落地的人参考。
1. 股票流失的界定逻辑,以及逻辑回归为何被选中
1.1 流失不是“销户”,而是“沉默”
很多业务方一提到流失,第一反应是“销户才是流失”。但实际做数据的人都知道,股票交易场景下真正需要预警的,恰恰是那些账户还在、资金还在、人却不再活跃的客户。
我的处理方式是先把“流失”拆成两层:第一层是硬流失,也就是客户真实完成了销户流程;第二层是软流失,账户还开着,但连续一段时间没有任何交易、没有登录、没有资金转入转出。建模主要针对软流失,因为硬流失发生时已经来不及挽留,而软流失留出了干预窗口。
为了避免误判,流失标签要组合多个信号,不能只看“无交易”这一条。有些客户只是暂时被套牢,可能每周还会登录看行情,只是不下单;有些客户则是因为行情冷清,所有股票投资者整体活跃度都在下降。所以标签一般会定义为“连续N天无交易,且登录天数低于某阈值”,比如30天无交易、同期登录天数不超过2天,才标记为流失样本。这样能把“真流失”和“被动观望”大致分开。
1.2 为什么不用树模型而用逻辑回归
做这个项目前我对比过几种方案:XGBoost、随机森林、逻辑回归,甚至试过小规模的神经网络。最后在业务侧真正留下来的,反而是最“朴素”的逻辑回归。
原因很简单:逻辑回归的输出天然是一个概率值,每一个特征的系数都能直接解释为正负影响。运营同事问“这个客户为什么分数高”,我可以说“因为他的近30天登录次数从15次降到了2次,这个特征贡献了很大的正权重”。这种解释链条对业务非常友好,尤其是券商这类受合规和审计约束比较重的场景,决策依据必须能讲清逻辑。
树模型在拟合非线性关系上的确更强,精度通常会高几个点,但它一旦深入多层分裂,解释起来就很费劲。业务方不可能为了一个“黑盒高分”去和客户沟通,就算沟通了,话术也站不住脚。神经网络就更不用说了,样本量不够时很容易过拟合,而且调参成本高、部署链路重。逻辑回归训练快、推理快、可直接转成评分卡逻辑,在“月级甚至日级全量客户打分层”的场景下,性能完全够用。
1.3 动手前先算清一笔账:挽回收益和触达成本
建模之前,一定要先跟业务方对齐两个数字:成功挽回一个流失客户的期望收益是多少;对一个客户做一次触达挽留动作,平均成本是多少。
这两个数字决定了后面选阈值的方向。不同平台差异很大,我用相对保守的例子说明:假设一个新客户的获客成本是500元,一个存量客户未来一年预期能贡献的佣金及服务收入约2000元;如果一次标准挽留动作(短信+App弹窗+一张低门槛权益券)成本是20元,那么只要挽回概率大于1%,这个动作从期望上看就不亏。具体计算我在第4章会展开,这里只是提醒一句:阈值取舍不是纯统计问题,本质是商业成本问题。
2. 特征工程:把交易流水改成一张能喂给模型的数据宽表
2.1 观察窗口与表现窗口:新手最容易踩的坑
流失预警模型的数据结构,核心是两个时间窗口:观察窗口和表现窗口。
观察窗口用来计算特征,比如取2024年1月到6月共6个月的数据,计算这期间客户的交易次数、登录天数、资产变化等;表现窗口用来判断客户是否流失,比如取2024年7月这30天,看客户是否有交易行为。两个窗口必须严格不重叠,否则就是典型的数据泄漏。
我见过不少新手直接把“今天之前的所有行为”都算作特征,再用“今天之后是否流失”做标签,这看起来没毛病,实际上特征里已经包含了标签时间段的信息。举个极端例子,如果做特征时统计了“近30天是否有资金转出”,而这个转出恰好发生在即将流失的那个月,模型等于提前看到了答案,训练集AUC能到0.95,上线后立刻打回原形。
所以正确做法是:对每个样本固定一个基准日T,观察窗口取T之前6个月,表现窗口取T之后30天。如果要扩大样本量,可以滚动取多个基准日,比如每月生成一批样本,每个客户在数据集里可能出现多次。这样做没问题,但要注意同一客户的样本之间存在相关性,评估模型时最好按客户分组划分训练集和测试集,避免同一客户既在训练集又在测试集,导致评估结果虚高。
另外还要注意行情周期的影响。如果训练数据全是2024年前半年的高活跃行情,而预测时遇到市场转冷,客户行为天然会变化,模型输出的概率会发生整体偏移。这个事后面监控部分会再讲。
2.2 三类核心特征:账户资产、交易活跃度、服务交互
特征宽表的质量直接决定模型上限。我通常会从三个大方向构造特征:账户资产、交易活跃度、服务交互。以下是一份常用特征清单:
| 特征大类 | 特征名 | 计算口径 | 业务直觉 |
|---|---|---|---|
| 账户资产 | total_asset | 期末总资产 | 资产越高,客户潜在价值越高,但长期不动也可能流失 |
| 账户资产 | market_value | 期末股票持仓市值 | 反映客户与股票市场的绑定深度 |
| 账户资产 | available_fund | 期末可用资金 | 可用资金多却没有交易,说明观望情绪浓 |
| 资金流动 | net_fund_inflow_30d | 近30日银证净转入 | 资金净流入是积极信号,净流出可能是流失先兆 |
| 交易活跃 | trade_cnt_30d | 近30日有效交易次数 | 交易频次骤降是流失最直接的前兆 |
| 交易活跃 | trade_days_30d | 近30日有交易天数 | 比交易次数更稳健,不受单日频次扰动 |
| 持仓偏好 | holding_stock_cnt | 持仓股票数量 | 持仓很少且长期不动,容易被行情吸引走 |
| 持仓偏好 | turnover_ratio_30d | 近30日换手率 | 换手下降代表参与意愿降低 |
| 服务交互 | login_days_7d | 近7日登录天数 | 短期登录骤降是即将沉寂的即时信号 |
| 服务交互 | login_days_30d | 近30日登录天数 | 中期活跃度的稳定度量 |
| 服务交互 | app_func_cnt_30d | 近30日使用功能种类数 | 使用功能越少,客户对平台粘性越弱 |
| 历史盈亏 | profit_ratio_90d | 近90日账户收益率 | 大幅亏损后客户沮丧情绪强,流失概率高 |
| 历史盈亏 | max_drawdown_90d | 近90日最大回撤 | 回撤过大容易引发离场 |
这些特征中,我认为“净资金流入”和“登录下降趋势”往往比交易次数更有前瞻性。很多客户在真正停止交易之前,其实已经停止转账了。而资金净流出往往是流失前最后一个明显信号,一旦出现,后续很难逆转。
2.3 标签定义:用“30天无交易且低登录”做基线
标签生成可以直接用SQL或Pandas实现。核心逻辑是:对每个客户,判断表现窗口内是否满足流失条件。
import pandas as pd # df_record 是交易流水表:客户ID、日期 # df_login 是登录日志表:客户ID、日期 # 判断表现窗口 [2024-07-01, 2024-07-31] 内是否有交易 trade_dates = set(df_record.loc[df_record["date"].between("2024-07-01", "2024-07-31"), "customer_id"]) login_dates = set(df_login.loc[df_login["date"].between("2024-07-01", "2024-07-30"), "customer_id"]) # 若30天内没有交易,且登录天数不超过2天,则标记为流失 df["is_churn_next_30d"] = 0 df.loc[~df["customer_id"].isin(trade_dates) & ~df["customer_id"].isin(login_dates), "is_churn_next_30d"] = 1这个口径要结合业务实际情况调。我见过有的团队把“30天无交易”单独作为流失标准,结果把一批频繁登录但不交易的低频打新客户全部误判成流失。加上“低登录”这个辅助条件后,误判率明显下降。
3. 用Python把逻辑回归跑起来:细到参数级别的实操
3.1 数据清洗:缺失值和无穷值的常规处理
业务表一定不是干干净净的。资产字段可能为空、收益率可能因为本金为0变成无穷大、部分登录行为可能根本没记录下来。清洗策略很简单,但不能没有。
import pandas as pd import numpy as np df = pd.read_csv("stock_customer_features.csv", encoding="utf-8") feature_cols = [ "total_asset", "market_value", "available_fund", "net_fund_inflow_30d", "trade_cnt_30d", "trade_days_30d", "login_days_7d", "login_days_30d", "holding_stock_cnt", "turnover_ratio_30d", "profit_ratio_90d", "max_drawdown_90d", ] target_col = "is_churn_next_30d" # 先剔除标签为空的样本 df = df.dropna(subset=[target_col]) # 将无穷值统一替换为 NaN df.replace([np.inf, -np.inf], np.nan, inplace=True) # 资产类字段用中位数填充,避免极端值拉动均值 df["total_asset"] = df["total_asset"].fillna(df["total_asset"].median()) df["market_value"] = df["market_value"].fillna(0) df["available_fund"] = df["available_fund"].fillna(0) # 活跃类字段缺失按0填充,代表“没有发生过” for col in ["trade_cnt_30d", "trade_days_30d", "login_days_7d", "login_days_30d", "app_func_cnt_30d"]: df[col] = df[col].fillna(0) df = df.dropna(subset=feature_cols)关于缺失值填充,我不建议在流失建模里做太复杂的插补。因为这类特征缺失本身往往就代表“客户没动作”,直接填0反而合理。比如登录天数为空,逻辑上可以理解成没有任何登录记录,那就应该填0而不是填平均值。
3.2 样本不均衡:分层采样和类别权重一起上
股票客户流失率通常不高。在一段正常行情里,未来30天真正流失的客户可能只占5%左右。如果直接用原始比例训练,逻辑回归很容易把所有样本都预测成“不流失”,因为这样整体准确率最高,却对业务毫无价值。
处理方式是两层配合。第一层,划分训练集和测试集时用stratify参数,保证测试集里正负样本比例和原始数据一致;第二层,在逻辑回归里设置class_weight="balanced",让算法根据类别频率自动放大少数类的损失权重。
from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler X = df[feature_cols] y = df[target_col].astype(int) X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.3, random_state=42, stratify=y ) print("训练集流失率:", y_train.mean()) print("测试集流失率:", y_test.mean())3.3 标准化与模型训练:正则化强度怎么选
逻辑回归使用梯度下降求解,如果特征量纲差异太大,比如总资产是百万级别、登录天数是个位数,收敛会非常慢,而且正则化对每个特征的惩罚力度不一致。所以标准化的意义不是“可选项”,而是“必选项”。
scaler = StandardScaler() X_train_scaled = scaler.fit_transform(X_train) X_test_scaled = scaler.transform(X_test)训练时我用的是liblinear求解器,原因是它和小样本、二分类场景配合得很好,而且支持L1正则化,后续如果要做特征筛选可以直接切换。
from sklearn.linear_model import LogisticRegression model = LogisticRegression( C=0.1, class_weight="balanced", solver="liblinear", max_iter=1000, random_state=42, ) model.fit(X_train_scaled, y_train)关于C参数,它是正则化强度的倒数。C值越小,正则化越强,模型越简单,方差越低;C值越大,模型越贴近训练数据,容易过拟合。实际项目中我一般会从0.01、0.1、1、10这一组里做调参搜索,选测试集AUC和业务阈值下净收益最优的那个。默认的1.0在金融场景里容易过拟合,尤其是特征数量多、样本量不大的时候。
3.4 用模型系数解释特征方向
回归类模型最招人喜欢的一点,就是可以直接看系数。
coef_df = pd.DataFrame({ "feature": feature_cols, "coef": model.coef_[0], }) coef_df["abs_coef"] = coef_df["coef"].abs() coef_df = coef_df.sort_values("abs_coef", ascending=False) print(coef_df)系数为正,代表该特征值越大,流失概率越高;系数为负,代表该特征值越大,流失概率越低。做完这步要给业务方做一次“系数解释会”,让运营理解模型为什么给出高分,而不是把模型当黑盒甩给他们。
4. 别拿准确率当宝:混淆矩阵、ROC与业务成本阈值
4.1 准确率在流失预警中基本没有参考价值
假如流失率是5%,你直接对所有人预测“不流失”,准确率就已经是95%。这个数字看着很好,但模型什么都没做。
流失预警真正关心的是“该捞的人捞到了没有”。所以我评估模型时主要看三个东西:召回率、精确率、ROC AUC,以及最终落到业务收益上的阈值选择。
先给出预测和基础评估代码:
from sklearn.metrics import confusion_matrix, classification_report, roc_auc_score y_prob = model.predict_proba(X_test_scaled)[:, 1] y_pred = (y_prob >= 0.5).astype(int) cm = confusion_matrix(y_test, y_pred) print("混淆矩阵:") print(cm) print(classification_report(y_test, y_pred)) print("ROC AUC:", roc_auc_score(y_test, y_prob))4.2 手算一组混淆矩阵数字
假设测试集有1000人,其中实际流失80人。模型以0.5为阈值时,预测流失30人,这30人里有20人真的流失,10人是误判。那么:
- 精确率 = 20 / 30 = 0.67,意思是预测为流失的客户里,有67%是真的会流失。
- 召回率 = 20 / 80 = 0.25,意思是所有真实流失客户里,模型只捞出了25%。
精确率0.67还算能看,但召回率0.25说明四分之三的流失客户被漏掉了。在“漏掉一个流失客户损失很大”的场景里,这个阈值显然不合适。这时候我会主动把阈值往下调,比如降到0.3,牺牲一点精确率,换取更高的召回率。
4.3 用ROC和AUC判断模型整体排序能力
AUC和阈值无关,它衡量的是模型能不能把流失客户排在不流失客户前面。AUC为0.7到0.8时,模型有实际可用价值;0.8以上属于不错;低于0.65则说明特征或标签定义有根本性问题,调参救不回来。
有一点需要提醒:AUC高不代表业务效果好。AUC只代表排序能力,不反映绝对概率值,也不反映某个具体阈值下的精确率和召回率。所以最终决策还要回到成本计算。
4.4 按业务成本找最优阈值
这是整个建模过程中最有业务价值的一步。阈值不是拍脑袋定的0.5,而应该根据“挽回收益”和“触达成本”算出来。
import numpy as np # 业务参数按自己平台实际填 revive_income = 500 # 成功挽回一个真实流失客户的期望收益 touch_cost = 20 # 对一个客户做一次触达动作的成本 target_cnt = y_test.sum() # 测试集真实流失人数 best_threshold = None best_benefit = -np.inf for threshold in np.arange(0.1, 0.9, 0.01): y_pred_t = (y_prob >= threshold).astype(int) # 真流失且被预测为流失的数量 hit = ((y_pred_t == 1) & (y_test == 1)).sum() # 所有被预测为流失的客户数量 total_pred = y_pred_t.sum() benefit = hit * revive_income - total_pred * touch_cost if benefit > best_benefit: best_benefit = benefit best_threshold = threshold print("最优阈值:", round(best_threshold, 3)) print("最大期望净收益:", round(best_benefit, 1))这个逻辑很直白:每捞回一个真实流失客户,赚一笔挽回收益;每对一个客户发触达,花一笔成本。把所有候选阈值都试一遍,找净收益最大的那个。0.5只是一个统计约定,不一定是业务最优值。
5. 分数出来了,然后呢:模型落地与业务动作闭环
5.1 概率分层:高、中、低风险对应不同动作
客户流失概率算出来后,不能简单扔一个名单给运营。运营需要知道“哪些人值得用人工电话,哪些人只配发推送”。我常用的分层规则如下:
| 流失概率 | 风险等级 | 推荐动作 | 单人预算成本 |
|---|---|---|---|
| 大于0.7 | 高风险 | 专属投顾电话沟通 + 定向权益券 | 高 |
| 0.4到0.7 | 中风险 | App推送 + 短信 + 低门槛活动 | 中 |
| 0.2到0.4 | 低风险 | 常规内容触达,不过度打扰 | 低 |
| 小于0.2 | 观察 | 暂不触达,进入下轮评分观察 | 无 |
分层逻辑背后是成本约束。高风险客户确实可能流失,但把所有高分客户都进行高成本干预,成本很快会超过收益。按概率分层,可以让运营把有限预算花在最有价值的人身上。
5.2 每日评分跑批与监控:模型不是一次性交付
模型落地时,我通常会在数据仓库里把特征宽表做成每日任务。每天早上6点跑批,用当天最新的交易、登录、资金数据重新计算特征,调用已训练好的逻辑回归模型输出每个客户的流失概率,再写入客户关系管理系统的营销名单表。
跑批流程稳定后,还要做两件事:一是监控特征分布漂移,计算每个月预测分数的PSI指标,如果PSI超过0.1,说明客户行为和训练期差异已经很大;二是监控模型效果,比如每个月回看上个月预测名单的真实流失率,和模型训练期的召回率做对比。一旦发现偏差明显,就要准备重训。
5.3 业务闭环:上线后用AB实验验证真实收益
模型上线不等于结束,真正检验模型价值的是业务侧的动作结果。
我建议在运营策略上做一组AB实验:一半客户使用模型名单做高优先级触达,另一半客户沿用过去的“全量群发”或“按交易金额排序”老规则。观察周期至少两个自然月,核心指标是两个:30日内不交易率是否下降、单位获客和挽回成本是否下降。
实验结果出来后,自然就能算出模型比老规则多挽回了多少客户、节省了多少成本。这个数字在向管理层汇报时,比任何AUC和KS都更有说服力。
我在实操中发现,真正决定项目成败的不是算法选择,而是流失标签定义和特征窗口设计。你花一周把这两个问题想透,逻辑回归的线上效果往往比随手用XGBoost更好,因为业务团队真的愿意配合落地。最后再分享一个经验:不要迷信月度重训,逻辑回归这种稳定模型每季度重训就够,频繁重训反而会被短期行情噪声带着跑。重点观察的是特征分布漂移和运营策略变化带来的影响。