逻辑回归车险定价实战:从可解释系数到费率因子落地
最开始做车险定价的时候我的想法很简单把 XGBoost 扔上去让树模型自己找规律。但真到了出费率表那一步业务方过来问年龄系数为什么是负的你这个因子为什么比驾龄重要我对着树模型的特征重要性半天张不开嘴。后来我才明白在保险精算这个场景里逻辑回归才是那个能把模型讲成人话的东西。车险定价的核心需求不是追求极致精度而是每个风险因子都能被解释、被验证、被换算成保费数字而这恰好是逻辑回归的看家本领。这篇文章我会用一份完整的模拟车险保单数据从数据清洗、特征工程到模型训练再到把预测概率换算成费率因子把整个逻辑回归车险定价的小项目跑通。代码全部基于 Python scikit-learn 实现只要本地有 Python 环境就能直接复现。适合正在入门机器学习、或者在做保险、金融风控类项目、需要理解业务模型落地流程的同学参考。1. 为什么车险定价要把逻辑回归当主力模型1.1 车险定价的业务逻辑先搞清楚车险定价在精算里叫费率厘定说白了就是回答一个问题什么样的司机、开什么样的车、在什么样的环境下出险风险更高、更高多少。传统做法是把影响因素分成几大类从人因素年龄、驾龄、性别、历史违章记录、历史出险记录。从车因素车辆品牌、车型、购置价格、车龄、是否豪华车。从用因素年行驶里程、用车用途家用还是营运。从地因素车辆使用地区、城市级别、停车场环境。每个因素都可能影响风险但不是简单的一刀切。比如年龄和出险率的关系通常是一条 U 型曲线年轻司机经验不足高龄司机反应变慢中间段反而最稳。这种非线性关系在逻辑回归里可以通过分段变量或哑变量来处理也是业务上容易理解的方式。车险定价和普通分类任务还不一样模型输出不能停留在预测这个人会不会出险而是要落到费率上。也就是说模型给出的每个因子系数最后都要乘以基础保费生成一张可查的费率表。这个特点决定了模型必须有很强的可解释性而不是只给一个黑盒概率。1.2 逻辑回归凭什么在这个场景站稳脚跟有人可能会问现在深度学习和树模型这么强为什么车险定价还在用逻辑回归这种老古董我在实际项目里的体会是三个字解释性。逻辑回归本质上是广义线性模型GLM的一种输出的是事件发生概率的对数几率。每个特征的系数就代表该特征每变化一个单位风险对数几率的变化量。这种性质让精算师可以把系数直接换算成风险倍数比如驾龄每增加一年出险风险降低百分之几。保险行业对定价模型的可解释性和稳定性要求极高。一个费率模型上线前要经历内部评审、业务验证、合规审查每一步都需要把模型逻辑讲清楚。树模型虽然精度可能更高但你很难解释清楚为什么某个具体的司机被判为高风险。逻辑回归的系数表往桌上一放谁高谁低、高了多久、怎么换算一目了然。另外逻辑回归的训练速度和推理速度都很快模型文件也很小。在车险这种需要频繁重训、按地区分模型、甚至嵌入到实时核保链路的场景里逻辑回归的工程成本远低于复杂模型。它可能不是精度最高的却是综合性价比最高的。2. 数据准备先搞清楚字段再谈建模2.1 示例数据字段与业务含义任何定价项目都是从数据开始的。这里我构造了一份模拟数据包含 5000 条保单数据字段设计尽量贴近真实的从人、从车、从用、从地信息。实际业务里你拿到的数据会比这个脏得多但字段结构是类似的。字段名类型业务含义age数值被保险人年龄18-70 岁driving_years数值驾龄从 0 到 30 年vehicle_value数值车辆购置价值单位万元annual_mileage数值年行驶里程单位万公里history_claims数值过去三年出险次数violations数值过去一年违章次数vehicle_type分类型车辆类型family / suv / luxuryregion_risk分类型地区风险等级low / medium / highis_claim标签目标变量本保单年度是否出险模拟数据的生成逻辑里我特意设置了一些符合业务直觉的关系年轻司机低于 25 岁风险更高驾龄长风险降低年行驶里程长风险增加历史出险和违章多的司机风险偏高豪华车型和高风险地区的风险也会上浮。这样训练出来的模型系数方向基本符合真实业务的预期。2.2 目标变量构造为什么不直接预测赔款金额严格来说车险精算定价通常拆成两个模型出险频率模型和案均赔款模型二者相乘得到纯保费。出险频率模型预测一段时间内出险几次常用泊松回归案均赔款模型预测单次出险的平均赔款金额常用伽马回归。在入门示例里我做了简化把问题定义成本保单年度是否出险的二分类任务用逻辑回归解决。这样做的好处是代码简单、概念清晰能完整走通从数据到费率的链路。实际项目中如果数据量充足、时间充足再升级成频率-严重度模型也不迟。构造目标变量时有个容易忽略的细节训练集和测试集要按目标变量的比例分层划分也就是stratifyy。如果不分层某些极端情况下测试集里出险样本比例可能偏差很大导致评估指标失真。这个我在后面对应代码里会强调。2.3 特征编码与缺失值处理逻辑回归对输入特征的尺度比较敏感因为训练过程用的是梯度下降类优化算法特征数值范围差距太大时收敛会变慢甚至不收敛。数值型特征一般要做标准化让它变成均值 0、标准差 1 的分布。分类型特征不能直接塞进LogisticRegression需要编码。最简单的是 one-hot 编码把vehicle_type拆成多列每列表示是不是这个类别。缺失值处理上真实数据里annual_mileage、violations这些字段经常有缺失。常用的做法是数值类型用中位数填充分类型用众数填充。注意用训练集填充后测试集要用同样的统计值填充不能拿测试集去重新计算否则会造成数据泄漏。给新手一个建议特征工程阶段不要一次性把所有操作都在原始数据上完成最好用 scikit-learn 的Pipeline把填充 → 编码 → 标准化 → 建模这个流程串起来。这样在预测时同一套预处理逻辑会自动应用不容易出现训练和测试流程不一致的问题。下面我在代码里也会贯彻这个思路。3. 模型训练逻辑回归的完整代码实现3.1 训练集和测试集划分先把依赖库和模拟数据准备好然后做特征拆分。完整代码如下import numpy as np import pandas as pd from sklearn.model_selection import train_test_split # 生成模拟数据 np.random.seed(42) n 5000 age np.random.randint(18, 70, n) driving_years np.clip(age - np.random.randint(17, 25, n), 0, 50) vehicle_value np.random.uniform(5, 50, n).round(2) annual_mileage np.random.uniform(0.3, 6.0, n).round(2) history_claims np.random.poisson(0.3, n) violations np.random.poisson(0.4, n) vehicle_type np.random.choice([family, suv, luxury], n, p[0.5, 0.35, 0.15]) region_risk np.random.choice([low, medium, high], n, p[0.4, 0.4, 0.2]) # 基于业务逻辑构造出险概率 logit ( -2.0 0.08 * np.maximum(0, 25 - age) - 0.04 * driving_years 0.12 * annual_mileage 0.35 * history_claims 0.25 * violations 0.50 * (vehicle_type luxury) 0.60 * (region_risk high) ) p_claim 1 / (1 np.exp(-logit)) is_claim np.random.binomial(1, p_claim) data pd.DataFrame({ age: age, driving_years: driving_years, vehicle_value: vehicle_value, annual_mileage: annual_mileage, history_claims: history_claims, violations: violations, vehicle_type: vehicle_type, region_risk: region_risk, is_claim: is_claim, }) # 划分特征和目标变量 X data.drop(is_claim, axis1) y data[is_claim] # 分层划分训练集和测试集 X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.2, random_state42, stratifyy )这里有个细节值得多说一句stratifyy的作用是让训练集和测试集里出险和未出险的比例保持和原始数据一致。如果出险率本身只有 15%随机划分时测试集出险率可能变成 12% 或 18%模型评估结果就不稳定。分层抽样虽然不能完全消除随机性但能把波动控制到最小。3.2 用 scikit-learn 训练逻辑回归接下来用Pipeline做特征处理和模型训练这也是我实际项目里比较推荐的方式from sklearn.compose import ColumnTransformer from sklearn.preprocessing import OneHotEncoder, StandardScaler from sklearn.pipeline import Pipeline from sklearn.linear_model import LogisticRegression # 数值特征与分类型特征 num_features [age, driving_years, vehicle_value, annual_mileage, history_claims, violations] cat_features [vehicle_type, region_risk] # 分类型特征 one-hot数值型特征标准化 preprocessor ColumnTransformer([ (num, StandardScaler(), num_features), (cat, OneHotEncoder(dropfirst), cat_features), ]) # 逻辑回归模型 model Pipeline([ (preprocess, preprocessor), (classifier, LogisticRegression(max_iter1000, C1.0, solverlbfgs, random_state42)), ]) # 训练 model.fit(X_train, y_train)关于LogisticRegression的参数我说几个实际操作用的经验max_iter1000特征经过 one-hot 后维度会增加默认的 100 次迭代有可能不收敛设置到 1000 更稳妥。solverlbfgs这个求解器在中小规模数据集上比较稳定也是 scikit-learn 1.5.x 中的默认选择。如果是大数据量或者强正则化场景可以再尝试liblinear或saga。C1.0这是正则化强度的倒数C越小正则化越强。默认 1.0 在多数场景下表现还行但如果特征维度很高可以考虑调小到 0.1 或 0.01。训练完之后model作为一个完整的 Pipeline保存和复用都很方便。你在预测新数据时不需要单独处理特征Pipeline 会自动完成编码和标准化。3.3 输出系数并解读风险因子逻辑回归的魅力在于系数可以翻译成业务语言。先看一下每个特征的系数import numpy as np # 提取 one-hot 后的特征名 feature_names num_features list( model.named_steps[preprocess].named_transformers_[cat] .get_feature_names_out(cat_features) ) # 提取系数 coef model.named_steps[classifier].coef_[0] intercept model.named_steps[classifier].intercept_[0] # 整理成表格 coef_df pd.DataFrame({ feature: feature_names, coef: coef, odds_ratio: np.exp(coef), }).sort_values(coef, ascendingFalse) print(coef_df)输出里coef是该特征对对数几率的影响odds_ratio是exp(coef)表示该特征每增加一个单位出险几率变化的倍数。比如某个特征的odds_ratio是 1.5意味着这个特征每增加 1 个标准差出险几率提升 50%。注意系数解读时要和标准化挂钩。因为数值特征都做过了StandardScaler所以age的系数代表的是年龄每增加一个标准差风险对数几率的变化而不是每增加一岁。在给业务方解释的时候要么先把系数换算回原始尺度要么直接按年龄段分档做哑变量业务沟通成本会低很多。one-hot 编码的类别特征比较特殊系数是相对参照组而言的。比如vehicle_type_luxury的系数是 0.5意味着豪华车相对于参照组family车型出险几率提升了exp(0.5) ≈ 1.65倍。这就是一张初步的风险因子表。4. 模型评估不能只看准确率4.1 评估指标选择准确率会骗人在分类任务里很多人第一反应就是看准确率。但在车险定价这种出险率通常偏低的场景里准确率会非常具有迷惑性。假设出险率只有 15%模型哪怕把所有样本都预测成不出险准确率也有 85%但这个模型毫无价值。我实际用的评估指标有两个AUC和KS。AUC 衡量模型区分正负样本的能力0.5 等于瞎猜1.0 是完美区分。KS 统计量衡量好样本和坏样本累计分布的最大差距通常超过 0.3 就算不错的模型。再加一个classification_report看精确率和召回率。from sklearn.metrics import roc_auc_score, classification_report, confusion_matrix # 预测概率和类别 y_prob model.predict_proba(X_test)[:, 1] y_pred model.predict(X_test) # AUC auc_score roc_auc_score(y_test, y_prob) print(fAUC: {auc_score:.4f}) # 混淆矩阵与分类报告 print(confusion_matrix(y_test, y_pred)) print(classification_report(y_test, y_pred))在保险定价场景里我更关注的是对高风险样本的召回率因为漏掉一个高风险客户意味着潜在的理赔损失。有时候为了保住召回率会牺牲一部分精确率也就是把一些低风险客户也标记为高风险这个取舍要和业务方一起定。模型指标好不代表业务可用阈值和风险偏好的匹配才是关键。4.2 阈值选择从赔付成本出发找最优切分点默认情况下predict用 0.5 作为阈值但 0.5 不一定适合业务。这里分享一个思路把每个可能阈值下的精确率、召回率、以及对应的业务成本算出来再挑一个业务上能接受的切分点。from sklearn.metrics import precision_recall_curve precisions, recalls, thresholds precision_recall_curve(y_test, y_prob) # 找召回率不低于 0.6 时精确率最高的阈值 valid recalls[:-1] 0.6 best_idx np.argmax(precisions[:-1][valid]) best_threshold thresholds[best_idx] print(f推荐阈值: {best_threshold:.3f})这个思路的含义是如果业务上希望至少识别出六成的高风险客户同时误报率尽量低那么可以选这样一个阈值。你也可以把识别出一个高风险客户带来的成本节省和误判一个低风险客户带来的保费损失分别量化用总成本最低的那个阈值。定价项目里模型的输出最终要转化为价格差异阈值不是一个技术参数而是一个商业决策。4.3 参数调优方向逻辑回归可调参数虽然不多但也别忽略。我常用的调优顺序是调C正则化强度用交叉验证找最优值。C太大容易过拟合太小容易欠拟合。处理类别不平衡设置class_weightbalanced。它会自动给少数类更高的权重缓解出险样本太少的问题。换solver。如果数据量很大saga可以支持L1正则化L1 会让部分系数变成 0起到特征选择的作用。用网格搜索可以一次跑完from sklearn.model_selection import GridSearchCV param_grid { classifier__C: [0.01, 0.1, 1.0, 10.0], classifier__class_weight: [None, balanced], } grid GridSearchCV(model, param_grid, cv5, scoringroc_auc, n_jobs-1) grid.fit(X_train, y_train) print(grid.best_params_)注意 Pipeline 里参数名要用双下划线连接层级classifier__C指的就是 Pipeline 里classifier这一步的C参数。网格搜索用roc_auc作为评分指标比默认的准确率更贴合这个业务场景。5. 从概率到费率模型的落地转化5.1 把预测概率变成费率倍数模型训练完不是终点车险定价要把概率换算成费率系数。基本思路是先算整体基础出险率再算单个样本相对基础水平的风险倍数。# 基础出险率 base_rate y_train.mean() base_odds base_rate / (1 - base_rate) # 预测概率转 odds odds y_prob / (1 - y_prob) # 费率系数 单个样本 odds / 基础 odds rate_factor odds / base_odds # 把费率因子合并回测试集方便查看 result X_test.copy() result[pred_prob] y_prob result[rate_factor] rate_factor print(result.head())这里的逻辑是odds代表出险几率对比不出险几率的倍数某个样本的odds是基础水平的 2 倍那么理论上的风险保费也应该是基础保费的 2 倍。实际定价中还会把案均赔款、费用、利润因子等因素加进去但风险倍数的核心逻辑就是这一步。在使用时要注意模型输出的是是否出险的概率不是赔多少钱。如果要做精准定价需要单独建模案均赔款。很多时候我只是用逻辑回归先做风险分层再在分层基础上套用赔付金额的均值也能得到一份可用的费率表。5.2 部署与实时推理模型训练完成后用joblib把完整 Pipeline 保存下来实测最省心。模型文件里包含预处理逻辑部署时不用再重新写一遍转换代码import joblib # 保存模型 joblib.dump(model, auto_insurance_model.pkl) # 推理时加载模型 loaded_model joblib.load(auto_insurance_model.pkl) # 输入单个保单信息 new_policy pd.DataFrame([{ age: 30, driving_years: 5, vehicle_value: 18.5, annual_mileage: 2.0, history_claims: 1, violations: 0, vehicle_type: suv, region_risk: medium, }]) # 预测概率 prob loaded_model.predict_proba(new_policy)[0, 1] print(f预测出险概率: {prob:.3f})部署时有个高频坑单个样本的预测很容搞错字段顺序。训练时Pipeline里的ColumnTransformer是按照列名取数据的但如果你输入的新数据列名和训练时不一致或者顺序不一样程序可能不会报错结果却完全不对。我在项目里吃过这个亏后来统一做法是把训练用的列名列表和模型一起pickle保存推理前先校验字段。另外scikit-learn 1.5.x 之后模型的predict_proba接口性能已经做了优化单条推理毫秒级就能完成完全够实时核保场景使用。瓶颈往往不在模型本身而在特征拼接和网络开销。这种场景下把特征预处理和模型推理逻辑合在一起用一个函数封装性能会更好管理。5.3 模型监控与更新上线只是开始。保险数据的风险结构不是一成不变的市场环境、交通状况、客户结构都在变模型要持续监控。我常用的监控手段是 PSI群体稳定性指数用于判断模型评分分布是否发生漂移。如果 PSI 超过 0.25说明样本结构和训练时差异很大模型大概率已经不太适用了需要重新训练。指标的监控则关注 KS 和 AUC 是否明显下降如果线下验证掉得很凶先排查特征、数据口径、外部环境最后再考虑重训。车险数据的季节性和地区差异也很明显建议按月度或季度滚动重训同时保留历史模型做对比不要盲目换新。6. 常见问题与踩坑实录我在做这个项目的过程中踩过不少坑有些是新手容易碰到的有些是走进业务之后才发现的统一整理成一张速查表问题现象可能原因解决办法模型 AUC 只有 0.55 左右特征和标签关系太弱或目标变量定义有问题检查是否有强特征未纳入重新审视数据生成逻辑系数符号不符合业务常识特征之间存在多重共线性先看相关性矩阵删除高度相关特征或改用 L1 正则化出险样本太少模型学不到规律类别不平衡严重设置class_weightbalanced或对少数类做上采样训练集准测试集崩过拟合调小C增强正则化或简化特征上线单条预测结果和线下不一致字段顺序或编码方式不一致用完整 Pipeline 保存特征名推理前统一列名预测概率整体偏高或偏低模型校准问题用CalibratedClassifierCV做概率校准类别不平衡这个问题我想多说两句。在真实车险数据里出险率可能只有 5% 到 15%如果模型把所有样本都预测为不出险AUC 可能还不太难看但业务上没法用。用class_weightbalanced是简单有效的手段之一但也有副作用就是预测概率整体会被拉高这时就要配合第 5 节的费率因子做归一化不能直接用概率绝对值当费率。另一个容易忽略的问题是特征编码后的维度爆炸。如果原始有几十个类别特征全部 one-hot 后可能产生几百列逻辑回归还能扛住但系数解释会变得很痛苦。实际项目里我会优先按业务含义对类别做合并比如车型先合并成家用车、SUV、豪华车几个大类地区按风险等级而不是按每个城市单独编码这样模型既稳又可解释。概率校准也是一个值得关注的环节。逻辑回归输出的是未经校准的概率如果在某个分数段内预测 0.7 的样本真实出险率不是 70%说明校准度有问题。可以画可靠性曲线calibration_curve检查必要时对概率做 Platt 缩放或使用IsotonicRegression校准。这步在定价场景里尤其关键因为费率因子直接用概率换算概率偏了钱就偏了。最后再分享一个我实际项目里的小技巧训练完模型后把系数、截距和特征名单独导出成一份 CSV这就是你和业务方沟通时最有力的文档。无论后面换了多少个模型这张系数表都能快速回答哪个因子贵、哪个因子便宜、贵了多少这类问题。我在做车险定价项目时靠着这张表省掉的沟通成本比任何调参技巧都值钱。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →