简介:房车险客户购买预测是机器学习中典型的二分类问题,这份资源提供了一套基于Python与伯努利朴素贝叶斯的完整实战项目,适合数据分析初学者和从事保险客户分析的人员使用。项目从客户数据读取开始,逐步完成缺失值处理、特征编码、模型训练与交叉验证,并结合准确率、F1等指标评估效果;训练脚本可直接运行,预测模块支持对新客户进行判断。压缩包共14个文件,整体仅1.51MB,内含5个Python源码、5个数据集与字段说明文本、1个Jupyter可视化分析笔记、1个训练好的模型文件和1张特征相关图,结构简洁、便于快速定位。目前已有834人学习,资源通过伯努利NB模型演示了特征独立性假设在实际业务中的应用,可视化图表能帮助理解数据分布,训练好的pkl模型可即时调用,是一份适合从理论走向实践的二分类建模参考。
1. 用伯努利朴素贝叶斯预测房车险购买:一个值得先跑通的最小闭环
保险营销的痛不在话术,而在名单。给全量客户群发房车险推广,单次触达成本高、转化率低,真正要做的是在销售动作开始前先回答一个问题:这个人会不会买。Python 实现基于伯努利朴素贝叶斯预测客户购买房车险,正是这类场景里最轻量的起步方案:一份客户数据集,配几十行源码,把「买 / 不买」压缩成二元分类问题,再用概率输出给营销名单排序。它不追求模型复杂度,而是用朴素贝叶斯的概率逻辑先跑通「数据 → 特征 → 预测 → 名单」闭环,适合刚接触机器学习分类的从业者,也适合想快速验证保险营销数据价值的团队。我下面讲的这套做法,会直接落到能跑的 Python 代码和参数上,你照着改字段名就能复现。
2. 伯努利朴素贝叶斯为什么适合「买不买」:模型原理与数据格式准备
2.1 伯努利分布与朴素贝叶斯的结合点:二元特征假设
朴素贝叶斯家族里有高斯、多项式、伯努利三兄弟,各自的适用前提完全不同。高斯朴素贝叶斯假设连续特征服从正态分布,多项式朴素贝叶斯假设特征近似离散词频,而伯努利朴素贝叶斯做了一个更严格的假设:每个特征只有 0/1 两种取值,并且特征之间条件独立。它的预测逻辑可以写成后验概率形式:P(购买|特征组合) 正比于 P(购买) 乘以每个特征在购买条件下的伯努利概率。分母对所有类别求和后归一化,最终得到的就是 predict_proba 输出的那个分数。
放在「客户是否会购买房车险」这个场景里,二元特征恰好和业务口径对得上:是否投保过交强险、是否收到过营销短信、是否在官网留过联系方式、是否发生过理赔、车辆是否属于房车品类,这些字段本质就是“是/否”。即使原始数据里存的是连续量,比如客户年龄、年收入,也可以先按业务阈值切成“高龄与否”“高收入与否”,再交给伯努利模型。这样一来,模型要学的 p(x_i|y) 就退化成两张简单的频次表:买的人里,某个特征是 1 的比例;不买的人里,这个特征是 1 的比例。模型复杂度低,意味着训练极快,数据量小也扛得住,这是它在这个场景里的核心价值。
这个模型的输出也贴合业务场景。predict_proba 给的是 P(购买|特征组合),而不是一个干巴巴的 0/1。营销负责人可以把概率当作“购买倾向分”,按分数降序圈名单,而不是让模型替你做最终决定。换句话说,伯努利朴素贝叶斯在这里不是用来追求超高准确率的,而是用来在大池子里快速捞出一小撮高倾向客户,这决定了后面我们看指标的方式,也决定了它对特征要求的苛刻程度:特征必须先变成“是否”语义,模型才接得住。
2.2 把数据集整理成模型能吃的形态:字段清洗与目标变量定义
标题里提到的“源码+数据集”通常不是直接能训练的形态。以常见实践为例,数据集侧面一般是这种结构:每一行是一个客户,列包含人口学属性、历史保单、渠道互动记录和购买标签。由于不能假设原始 CSV 一定整洁,第一步永远是加载后查空缺、查类型、查目标分布。
import pandas as pd df = pd.read_csv("caravan_insurance.csv", encoding="utf-8") print(df.shape) print(df.dtypes) print(df.isnull().sum().sort_values(ascending=False).head(10)) # 目标变量:是否购买房车险,原始数据里的字段名可能是 CARAVAN / purchase # 把 yes/no 或 Y/N 统一成整数,便于 sklearn 识别 df["purchase"] = df["purchase"].map({"yes": 1, "no": 0}).fillna(0).astype(int) print(df["purchase"].value_counts(normalize=True))这段代码干了两件事:首先是体检,确认有多少行、每个字段的类型有没有解析错位、哪些字段缺失严重;其次是收拾标签,把字符串型的目标变量映射成 0/1。如果原始列里缺失值太多,比如超过 40%,我一般直接删掉这列,而不是硬补,因为伯努利模型本身不擅长处理缺失,补出来的 0/1 反而会把噪声带进去。
目标变量定义要慎重,别把“曾经购买过”和“有意向购买”混成一类。如果数据里有单独的“咨询过房车险”字段,那是很好的特征,不是目标。购买标签应以保单成交为准,否则模型学到的分布会失真,上线后翻车的概率很高。我见过有人把“点击过广告”当标签,训练出来模型分数虚高,一跑外呼名单就现原形。
特征列里常有一些取值混乱的字段,比如保险类型写成“全险”“comprehensive”“Comprehensive”三种形式,直接独热会产生三个列。处理时要先归一化取值,再生成 0/1 标记:
# 原始数据常见陷阱:字段名不一致、取值混乱 df["has_full_coverage"] = df["coverage_type"].apply( lambda x: 1 if str(x).strip().lower() in ("comprehensive", "全险", "c") else 0 )这段转换把字符串取值统一成小写后匹配,命中就置 1。逻辑很朴素,但能避免同一个业务含义被独热拆成多列,稀释模型权重。保险数据里这种“同义不同形”的字段非常多,处理时宁可慢一点,也别让模型把噪声当规律学进去。
2.3 标签分布体检与特征列选择原则
房车险购买率在真实业务里通常很低,5% 上下很正常。这个数字直接影响后面的评估方式和阈值设计,拿到数据后第一件事就是确认它。value_counts(normalize=True) 输出的结果是 0.95 和 0.05,就应该立刻意识到:全预测“不买”也有 95% 准确率,模型必须围绕那 5% 来评价。
特征选择遵循一个朴素原则:只保留在销售流程开始前就能拿到的字段。比如客户年龄、行业、历史投保年份数、前一年是否有过理赔、是否通过邮件渠道回应过。像“是否访问过产品页”这类后验字段,训练时很漂亮,部署时拿不到,属于典型的泄露特征,务必排除。一个有效的判断方法是问自己:如果明天要为一个今天刚注册的客户打分,这个字段的值能从数据库里查出来吗?查不出来就删。
我一般会把特征分成三组:客户社会属性、历史保单行为、营销触达响应。社会属性刻画“这个人像不像房车险客群”,保单行为刻画“他是不是我们的长期优质客户”,触达响应刻画“他对保险营销是否敏感”。三组各取几个代表字段,比单押一组字段稳定得多。
3. 特征二元化与模型训练:从 CSV 到可运行的 Python 源码
3.1 连续特征怎么转 0/1:阈值切分的细节与代码
伯努利模型不吃连续值,这是新手最容易踩的硬门槛。很多人在网上找到源码,把年龄、收入原样传给 BernoulliNB,结果模型要么报错,要么预测结果毫无区分度,因为 BernoulliNB 的 binarize 参数默认值只对非负值生效,直接喂连续值会把大部分实数值变成 1,信息全丢。
常见做法是先对连续字段做阈值切分,切分依据有两类:一类是业务口径,比如“年龄大于 45 岁视为高龄客户”,因为房车险的主力客群是退休前后的家庭用户;另一类是统计口径,比如用中位数或分位数,保证切出来的两组尽量均衡。
# 连续特征二元化示例:年龄、年收入、历史保单年限 import numpy as np df["senior_flag"] = (df["age"] > 45).astype(int) df["high_income_flag"] = (df["income"] > df["income"].median()).astype(int) df["long_tenure_flag"] = (df["policy_years"] >= 3).astype(int) # 多分类名义变量也要转成独热再合并,别直接当数值用 df = pd.get_dummies(df, columns=["occupation"], prefix="occ", dtype=int)这里的三个新特征含义:senior_flag 抓房车险的目标客群年龄特征;high_income_flag 用中位数做切点,避免极值把均值拉偏;long_tenure_flag 表达“老客户比新客户更容易加购新品”。编码方式全是 astype(int),布尔值转 0/1,干净利落。
如果是名义变量,比如职业、居住地区,那就别用阈值,直接独热编码。独热之后每个取值都变成独立的 0/1 列,伯努利模型处理起来很自然。需要注意的是,独热列数可能很多,训练前先确认这些列不会让矩阵变成几万维,否则 2000 行数据扛几百个特征,过拟合风险非常大。出现频次极低的取值可以预先归并成“其他”,减少特征面。
3.2 用 sklearn 的 BernoulliNB 训练房车险预测模型:核心代码与参数
特征准备好之后,训练代码很短,但参数不能盲抄。下面是可运行的构造方式,语义很明确:每个样本的每个特征要么出现,要么没出现,不要把它们当作计数。核心代码如下:
from sklearn.model_selection import train_test_split from sklearn.naive_bayes import BernoulliNB from sklearn.metrics import confusion_matrix feature_cols = [ "senior_flag", "high_income_flag", "long_tenure_flag", "email_contacted_flag", "prior_claim_flag", ] + [c for c in df.columns if c.startswith("occ_")] X = df[feature_cols].values.astype(int) y = df["purchase"].values X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.25, random_state=42, stratify=y ) model = BernoulliNB(alpha=1.0, binarize=0.0, fit_prior=True) model.fit(X_train, y_train) y_pred = model.predict(X_test) print(confusion_matrix(y_test, y_pred))这里必须解释三个参数:alpha、binarize、fit_prior。alpha 是拉普拉斯平滑系数,默认 1.0,当训练集中某个特征在某个类别下从未出现时,避免概率算成 0 吞掉其他信息,一般保持默认即可;binarize 设为 0.0 表示“大于 0 的值一律置 1”,因为我们已经做了特征二元化,再传 0.0 可以保证模型内部不会乱动编码;fit_prior 是是否从训练数据里学先验概率,默认 True,当购买比例明显失衡时也可以手动传 class_prior 覆盖,后面专门讲。
数据量小的话,stratify 分层抽样很有必要。房车险购买率低,不分层的话测试集可能一个正样本都没有,混淆矩阵直接缺一块。random_state 固定一个数字,不仅为了复现,更重要的是让你调参时能对比同一批测试集上的效果。每次改动特征或参数,只要随机种子不变,指标变化就可以归因到你的改动,而不是随机波动。
3.3 数据集分割与类别不平衡处理
购买率只有 5% 的场景,直接训练会出现一个尴尬现象:模型预测几乎所有样本为“不买”,准确率却有 95% 以上。这个准确率是虚的,因为营销人员要的是从名单里捞出那 5% 的人,而不是复述“大部分人不会买”这个事实。
解决类别不平衡,伯努利朴素贝叶斯特有的手段是改先验。模型默认按训练集的类别比例算 P(购买) 和 P(不买),如果真实业务里购买率就是 5%,那默认是对的;但如果数据采集时正样本被刻意抽样放大了,比如为了做营销活动把过去的购买客户全部拉进来,比例失真,就要手动纠正。
# 训练集购买率可能被抽样扭曲,按业务真实先验覆盖 real_prior = 0.05 # 全量客户中实际购买房车险的比例 model_prior = BernoulliNB(alpha=1.0, binarize=0.0, fit_prior=False, class_prior=[1 - real_prior, real_prior]) model_prior.fit(X_train, y_train)fit_prior=False 时,模型不再从训练集统计类别频率,直接用 class_prior 给的两项做预测。这个技巧在做跨时间测试时特别有用,比如用 2023 年数据训练、2024 年数据验证,两年的购买率可能差好几个百分点,直接套用训练集先验会系统性低估。改完先验后,predict_proba 输出的分数量级会变化,名单排序也会重新洗牌,值得专门盯一下。
注意,class_prior 的列表顺序要跟着 classes_ 走,通常 classes_[0] 是 0(不买),classes_[1] 是 1(购买)。写反了会得到完全镜像的错误概率,等于把名单发给了反向客户。训练完打印 model.classes_ 确认一下就放心了。
4. 模型评估与阈值调整:不只盯准确率
4.1 混淆矩阵与召回的解读方式
训练完第一版模型,大多数人第一反应是打印 accuracy,看到 93% 就觉得完事。但在购买率 5% 的数据集上,93% 的准确率可能意味着正样本一个都没捞着。评估模型服务于营销名单时,我更建议同时看四个数:准确率、精确率、召回率、F1,并且按“购买”这个少数类去读。
from sklearn.metrics import classification_report print(classification_report(y_test, y_pred, target_names=["不买", "购买"]))实际会看到类似这样的输出:模型在“不买”类上召回 99%,在“购买”类上召回 0%。这就是典型的“名单全救不活”的症状,原因通常有两个:阈值不当或者特征区分度不足。此时不要急着改模型,先跑一次 predict_proba,把购买类的概率降序排列,看看前 100 个客户里到底有多少个真正买了房车险。如果 100 个里能命中 15 个,说明模型学到的排序能力是有的,只是默认阈值 0.5 太苛刻,需要往下调。
另一个容易忽略的角度是基线对比。全量客户购买率若为 5%,随机抽 100 人预期命中 5 人;模型名单命中 12 人,就是基线的 2.4 倍,这个倍数才是业务方能直观理解的指标。我在给业务方汇报时从不念精确率和召回率,只讲一句话:这份名单的命中率是随机名单的两倍还是三倍。
4.2 调整 predict_proba 阈值来改变营销名单
朴素贝叶斯输出的概率绝对值偏大或偏小是常事,因为特征独立性假设在真实保险数据里几乎不成立,所以概率分数只能看相对排序,不能当作真实概率。这也决定了阈值调整要基于名单目标来设定,而不是盯着 0.5。
import numpy as np proba = model.predict_proba(X_test)[:, 1] # 购买类的概率 # 按分数排序,取前 10% 客户作为营销名单 threshold = np.quantile(proba, 0.90) marketing_list = proba >= threshold print("名单人数:", marketing_list.sum()) print("名单命中购买人数:", y_test[marketing_list].sum()) print("名单命中率:", y_test[marketing_list].sum() / max(marketing_list.sum(), 1))threshold 的取法可以完全绕开模型输出的概率绝对值,用分位数把“分数最高的 10%”圈成第一批外呼名单。好处是名单规模和营销预算直接挂钩:预算够就取前 20%,不够就取前 5%。这里的关键是,不要拿全部客户的平均购买率和名单内的命中率比,而是拿名单内命中率和随机抽样的 5% 基线比,命中率翻倍就算模型有效。
我习惯把评估报告和阈值实验串成一个函数重跑,每次改特征或调参后,输出“前 5% / 前 10% / 前 20%”三档名单的命中率。这个表比任何单一指标都能说服业务方,它直接回答“给我这批名单,这批里到底有多少人真会买”。调阈值时如果发现前 5% 名单命中率反而低于前 10%,说明分数尾部噪声大,需要回去看特征质量,而不是继续切阈值。
提示:不要用 train_test_split 的随机拆分做过拟合判断。跨时间验证更接近真实场景,把前几个月的当训练集,后一个月的当测试集,这样评估出的命中率你敢直接报给业务。
5. 避坑:伯努利朴素贝叶斯做房车险预测的五个常见问题
5.1 现象:预测结果全是“不买”,购买类召回率为 0
训练完成后打印分类报告,购买类的召回率为 0%,整个预测输出没有一个 1。原因很简单:训练集里购买样本占比太低,模型学到的先验 P(购买)≈0.05,而特征对购买类的似然又不够强,后验概率整体压不过不买类,默认阈值 0.5 下全被判为负样本。
解决路径分两步。第一步确认特征真的有意义,用交叉验证看一下购买类的 AUC 分数,如果 AUC 在 0.5 附近说明特征是摆设,得回去做特征工程;如果 AUC 有 0.7 以上,说明模型排序能力存在,就执行第二步,调整决策阈值,把阈值从 0.5 降到 0.2 或更低,用名单坐定策略而不是硬性分类。这一步在所有朴素贝叶斯保险场景里都是标配操作,不丢人。
5.2 现象:连续特征直接丢进 BernoulliNB,报错或者输出毫无区分度
部分网上的源码示例用 binarize=0.5 直接吃连续特征,看起来没报错,结果所有连续值较大的样本都变成 1,模型把年龄、收入信息全当成“都大于 0”,区分度丧失。伯努利模型命名已经说明问题:每个特征只回答“有没有”,不回答“有多少”。
解决方法是先人工切分,把连续字段按业务或分位数转成 0/1;如果实在想偷懒,就给 BernoulliNB 传 binarize=0.0,让它把大于 0 的离散为一个状态,但这只对本来就在 0/n 附近分布的特征有效,年龄这种平滑分布照样失效。正确的姿势永远是:先 explore,再 binary,别指望模型替你圆场。
5.3 现象:同一个数据集换用 MultinomialNB 后分数完全相反
MultinomialNB 假设特征服从多项式分布,适合单词计数、次数统计这类离散频数,而伯努利适合布尔指示量。如果特征里有“理赔次数”“接触次数”这种计数型字段,MultinomialNB 可能会表现更好,但两者混用是概念性错误。伯努利模型的概率公式里,某一个特征为 1 与特征值大小没有关系,一旦计数特征没有被二元化,模型会把数值大小当成出现概率的一部分去计算,排序结果自然诡异。
解决方法是先想清楚特征矩阵是什么语义:全是“是否”用 BernoulliNB;有“几次”也有“是否”的,把“几次”字段先二元化再统一进伯努利,或者干脆改用适合混合分布的逻辑回归。别在同一个模型里混两种语义,这是选型问题,不是调参问题。
5.4 现象:训练集上效果惊艳,测试集上一败涂地
房车险数据集特征列数多、样本量少,很容易出现高维稀疏导致的过拟合。伯努利模型虽然结构简单,但加了大量独热列后同样会记住训练样本。症状是训练集精确率很高,测试集精确率掉一半以上。
解决方法是压缩特征面。先看每个独热列的出现频次,出现次数小于 5 的取值直接归并为“其他”类型;再看特征之间相关性,比如“是否高龄”和“是否退休年龄”高度重叠,留一个即可。特征做减法对朴素贝叶斯的收益往往大于换模型,因为模型本身不擅长做特征筛选,垃圾进垃圾出在这里表现得比很多模型都明显。
5.5 现象:新预测数据转特征时报错:特征数量不一致
训练时用的特征列里包含独热编码产生的多个 occ_ 列,等到实际预测线上新数据时,如果新数据集里职业取值比训练集少,get_dummies 产生的列数就比训练时少,模型直接报维度不匹配。这类错误在把模型封装成 Python 脚本、批量跑新名单时特别常见。
解决方法是先保存一份完整的特征列清单,预测前用 reindex 把新数据框对齐到训练特征列上,缺失列补 0。代码如下:
def prepare_features(raw_df, feature_cols): df = raw_df.copy() df["senior_flag"] = (df["age"] > 45).astype(int) df["high_income_flag"] = (df["income"] > df["income"].median()).astype(int) df = pd.get_dummies(df, columns=["occupation"], prefix="occ", dtype=int) df = df.reindex(columns=feature_cols, fill_value=0) return df[feature_cols]这里还有个隐含问题:中位数、分位数这些切分参数是训练集里算出来的,预测时一定要沿用训练阶段的切点,不能在 predict 时用新数据的 median 重新算。否则同一个客户在不同月份的分数会漂移,上线后行为完全不可控。
提示:把训练阶段计算出的阈值、列清单、class_prior 全部保存成 JSON 或 pkl,预测阶段只加载这些元数据,不在预测脚本里重新计算。
6. 从预测到落地:用概率分数圈营销名单的进阶玩法
模型跑通之后,价值不在一份 sklearn 报告里,而在每天生成的那份外呼名单里。进阶玩法是把概率分数直接对接营销流程:每天早晨从数据库拉新增客户,跑一遍特征转换和 predict_proba,按分数倒序输出 Excel,交给电销团队。名单前面可以加一列“推荐强度”,把概率分数分档位,比如前 5% 标记为“A 类”,5% 到 20% 标记为“B 类”,低于 20% 不进入当期营销池。
这里要特别注意一个我踩过不止一次的坑:朴素贝叶斯概率分数会随客户群结构变化而漂移。某个月进了大量年轻客户,他们本身购买倾向低,top 10% 的分数门槛会跟着波动,导致同样分数的客户在不同月份被划入不同档位。我的习惯是每个月用当月数据跑一次名单分位数,动态定档,而不是写死一个概率阈值。
另一个值得投入的验证方法是分层抽样回访。从模型挑出的 A 类名单里随机抽 100 人做外呼试点,同时从 B 类里抽 100 人,对比两组的实际购买转化率。只要 A 类转化率明显高于 B 类,模型就真正兑现了价值,这个测试结果比任何离线精度指标都更能让业务方认可。伯努利朴素贝叶斯的优势之一就是训练快、更新成本低,一个月重新训练一次完全可行,配合滚动验证能持续监控名单质量。
我把这套流程跑了三个季度后最深切的体会是:真正难的不是那几十行 Python 源码,而是每次数据格式变化后都要重新守住的稳定性约定——阈值不用新数据重算、特征列不擅自增加、先验不随手改动。把这三条约定写进代码注释里,比试图理解每个概率数字背后的玄学更值钱。希望这些经验能帮到你。
本文还有配套的精品资源,点击获取