)
车险定价这个活儿最容易被问到的一句话就是你们用的模型到底是不是深度学习其实在真实业务里能稳定跑线、能向监管解释、能算出每个因子赔率系数的模型反而常常是逻辑回归。逻辑回归在车险定价里面不是落伍而是够用、好用、可解释。这篇文章我就以车险定价为例把逻辑回归从数据准备、特征工程、模型训练到模型评估、上线推理的完整链路拆开讲一遍附上可以直接跑的Python代码。适合正在做保险风控、金融评分卡或者刚入门机器学习想找一个真实业务场景练手的同学。1. 先说清楚为什么车险定价能用逻辑回归1.1 车险定价到底在算什么车险定价不是简单报个价它本质上是在回答两个问题这个客户未来一年会不会出险出险的话大概要赔多少钱在实际业务里纯保费通常等于出险频率乘以案均赔款而最核心的第一步就是先估计出险概率。我们做车险评分卡、做定价模型第一步都是围绕出险概率来做文章。出险概率是一个二分类问题客户出险记为1没出险记为0。影响出险概率的因素很多比如驾驶员的年龄、驾龄、历史违章次数、历史理赔次数、车辆品牌、行驶里程、所在地区等等。这些变量有的连续、有的离散、有的高基数必须经过特征工程统一处理。而逻辑回归天然输出0到1之间的概率值公式为[ P(y1|x) \frac{1}{1 e^{-(\beta_0 \beta_1 x_1 \beta_2 x_2 ... \beta_n x_n)}} ]这个概率值经过单调变换之后可以直接映射到保费调整系数上。比如说基准保费乘以一个系数系数大于1就加费小于1就折扣这和车险定价的因子费率法完全对得上。1.2 逻辑回归凭什么合适很多刚接触机器学习的人会觉得逻辑回归太简单想直接上XGBoost、LightGBM甚至神经网络。但在车险这种强监管、重解释的领域模型的复杂度并不是越高越好核心原因有三个。第一是透明度。保险定价需要向监管提交备案监管要能看懂风险因子是怎么影响保费的。逻辑回归的系数就是每个变量的影响方向和影响程度解释起来非常直接。树模型可以给你特征重要性排序但没法精确告诉你年龄每大一岁保费系数变化多少。第二是稳定性。传统车险业务里特征稳定性比模型精度更重要。逻辑回归是线性边界方差小对训练集波动不敏感树模型容易在小样本下过拟合上线后特征分布一偏移分数就可能乱跳。再加上业务上还要考虑平滑性、单调性等要求逻辑回归加一些手工分箱和WOE编码反而是最稳妥的方案。第三是效率。逻辑回归训练快推理更快。scikit-learn 1.5.x下训练一个几十万条样本的模型基本是秒级完成。上线实时评分时模型文件很小单条预测的耗时在微秒到毫秒级别完全扛得住车险报价这种高并发场景。所以我一直跟团队里的人说先用逻辑回归把基线跑通再考虑要不要上更复杂的模型。2. 建模前的数据准备与特征工程2.1 原始数据长什么样先造一份模拟数据集来演示真实业务里的车险数据字段只会比这多不会比这少。我通常在建模前会拿到两类数据一类是保单信息和投保人信息比如年龄、性别、驾龄、车辆价格、车辆使用性质另一类是历史理赔和违章数据比如过去三年的出险次数、赔款金额、违章分数。我们这份模拟数据包含这些字段age代表投保人年龄driving_years代表驾龄car_age代表车龄annual_mileage代表年行驶里程公里prior_claims代表过去三年出险次数violation_count代表近一年违章次数vehicle_type代表车辆类型0家用轿车1SUV2跑车region代表地区等级0一线1二线2三四线is_claim是目标变量1表示当年出险0表示未出险。import pandas as pd import numpy as np rs np.random.RandomState(42) n 20000 df pd.DataFrame({ age: rs.randint(18, 65, n), driving_years: rs.randint(0, 45, n), car_age: rs.randint(0, 15, n), annual_mileage: rs.randint(2000, 50000, n), prior_claims: rs.poisson(0.3, n), violation_count: rs.poisson(1.2, n), vehicle_type: rs.choice([0, 1, 2], n), region: rs.choice([0, 1, 2], n), }) # 构造一个和特征到目标变量之间的大致关系 logit (-4.5 0.02 * (df[age] - 40) - 0.03 * df[driving_years] 0.08 * df[car_age] 0.00002 * df[annual_mileage] 0.35 * df[prior_claims] 0.25 * df[violation_count] 0.3 * (df[vehicle_type] 1) 0.5 * (df[vehicle_type] 2) 0.3 * (df[region] 1) 0.5 * (df[region] 2)) prob 1 / (1 np.exp(-logit)) df[is_claim] rs.binomial(1, prob)这里有一点要说明模拟数据里我故意把真实逻辑回归系数写在了数据生成过程里目的就是方便后面训练完把学到的系数和真实系数做对比大家更能直观理解逻辑回归在做什么。2.2 特征处理的几个关键动作拿到原始数据之后不能直接往模型里塞必须先处理缺失值、异常值和离散变量。我先说缺失值。age、driving_years这些字段在真实保单数据里一般不缺但annual_mileage经常会缺因为有些保单不记录里程。常见做法是用中位数填充因为里程分布偏态严重均值容易被人为拉高。然后是离散变量。vehicle_type和region是分类变量逻辑回归不能直接理解0、1、2这种编码因为数字大小会被模型误认为有顺序关系。对低基数分类变量最简单的方式是One-Hot编码也就是把一列变成多列哑变量。举个例子vehicle_type有3个取值就可以拆成vehicle_type_1和vehicle_type_2两列基线是vehicle_type_0。df pd.get_dummies(df, columns[vehicle_type, region], drop_firstTrue)drop_firstTrue是为了避免哑变量陷阱。如果不做drop_first三分类会生成三列但逻辑回归里这三列存在完全共线性会导致系数不稳定、解释困难。这是新手最容易踩的坑之一。再补充一个真实业务中的常见做法连续变量分箱。在车险评分卡实务里很少直接把年龄、车龄这种连续变量塞进逻辑回归因为真实关系往往不是线性的年轻驾驶员出险率高中年驾驶员出险率低老年驾驶员出险率又走高如果用线性项去拟合这种倒U型关系是抓不住的。所以一般会先把连续变量按分位数或业务经验切成几段然后计算每段的WOE证据权重用WOE值替代原始数值进入模型。这样做的好处是既能刻画非线性又能让每个分组保持单调或符合业务经验。2.3 样本划分与数据泄露的坑数据划分看似简单但对时间序列性质的保险数据来说坑非常深。车险数据的本质是用历史保单预测未来出险所以训练集和测试集应该按时间切分而不是随机切分。比如用2022年的保单特征预测2022年内的出险用2023年的数据做验证这样才能模拟真实上线后的表现。随机切分会导致一个隐蔽的数据泄露同一辆车或者同一个客户可能在训练集和测试集里同时出现特征高度相关测试集效果虚高。真实业务里最好在客户ID或者车辆维度去做_group_by_切分保证同一个实体不会横跨两个集合。代码层面先按最简单的随机切分演示因为我们的模拟数据没有时间字段。用train_test_split就够了但要设置stratify参数来保证正负样本比例在训练集和测试集中一致。from sklearn.model_selection import train_test_split X df.drop(is_claim, axis1) y df[is_claim] X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.3, random_state42, stratifyy ) print(ftrain size: {len(X_train)}, test size: {len(X_test)}) print(fpositive rate in train: {y_train.mean():.4f}) print(fpositive rate in test: {y_test.mean():.4f})3. 核心代码实现从训练到评估3.1 环境准备运行这段代码需要Python 3.8以上版本建议直接用Anaconda或者Miniconda管理环境。核心依赖是scikit-learn、pandas、numpy、matplotlib。如果是从零开始配置环境我用的是Pip方式在终端执行pip install scikit-learn pandas numpy matplotlib joblib这里特别提醒一下scikit-learn版本至少要1.0以上1.5.x是我当前在用的版本。不同版本的API差别不大但部分参数默认值有过调整如果你用的老版本个别参数表现会不一样。装好之后可以用python -c import sklearn; print(sklearn.version)确认版本号避免后续代码跑不动。顺带说一句如果你用VS Code写Python记得先在终端里激活对应的虚拟环境。很多同学遇到的ModuleNotFoundError其实不是环境没装而是VS Code右下角选的解释器和终端激活的环境不是同一个。切换到正确的解释器路径很多问题就消失了。3.2 训练逻辑回归模型逻辑回归在sklearn里用法非常简单但参数坑不少。先看核心训练代码from sklearn.linear_model import LogisticRegression from sklearn.preprocessing import StandardScaler from sklearn.pipeline import Pipeline scaler StandardScaler() lr LogisticRegression( penaltyl2, C1.0, solverlbfgs, max_iter1000, class_weightbalanced ) pipeline Pipeline([ (scaler, scaler), (lr, lr) ]) pipeline.fit(X_train, y_train)这里我特别解释几个关键参数因为这些参数直接决定模型能不能收敛、系数会不会爆炸。第一个是penalty和C。默认penalty是l2正则C是正则强度的倒数C越小正则越强系数越往0压缩。在车险定价场景我一般不会把C设得太小因为系数既要稳定又要有业务解释。可以用交叉验证去调C但更常见的做法是直接用默认C1.0跑一版看系数符号是否符合业务常识再做微调。第二个是solver。lbfgs是目前比较推荐的求解器适合大多数中小规模数据而且支持l2正则。如果你的数据非常大可以试试saga。liblinear是历史遗留选项适合小数据集但用之前建议先看当前版本是否支持你要的正则化类型。第三个是class_weight。车险出险率一般只有10%~20%负样本远多于正样本如果不设class_weight模型会倾向于把所有样本都预测为不出险导致AUC虚高但实际抓不到出险人群。设置class_weightbalanced可以按样本比例自动加权。这个参数在车险场景里几乎必开。第四个是max_iter。默认是100但数据量稍大或者特征相关性稍高时lbfgs可能到100次迭代还没收敛控制台会报ConvergenceWarning。我把max_iter设到1000省得每次看到警告都紧张。这里再多说一句迭代不收敛不一定代表效果差但既然做项目还是让loss收敛到稳定状态比较好。3.3 模型评估与业务解读模型训练完成后先看最基础的预测概率和类别结果from sklearn.metrics import classification_report, roc_auc_score, roc_curve, confusion_matrix import matplotlib.pyplot as plt y_prob pipeline.predict_proba(X_test)[:, 1] y_pred pipeline.predict(X_test) print(AUC:, roc_auc_score(y_test, y_prob)) print(classification_report(y_test, y_pred))输出大概长这样数值随随机种子会略浮动AUC: 0.7102 precision recall f1-score support 0 0.84 0.75 0.79 5101 1 0.34 0.48 0.40 1499从业务角度看AUC在0.7左右属于有区分度但不是特别强的水平这和真实车险数据很像。AUC不到0.8很正常因为影响出险的变量很多光靠保单特征很难做到极高区分度。真正上线时还会加入历史理赔、驾驶行为等更多维度的数据。再看混淆矩阵cm confusion_matrix(y_test, y_pred) print(cm)这里我建议不要只盯着准确率车险场景下recall更重要一些。说得直白点假设100个实际会出险的人我们只抓到48个漏掉了52个。漏掉的这些人会被按低风险定价可能造成赔付损失但反过来如果为了多抓而把所有客户都定义为高风险保费会大幅上升导致客户流失。所以阈值的选择要在recall和precision之间找平衡真实业务里常常用分数阈值来决定折扣档位而不是直接硬切0.5。系数解读这一步很关键也是逻辑回归比其他模型强的地方。我们可以把学到的系数拿出来逐一检查import numpy as np feature_names X_train.columns coef pipeline.named_steps[lr].coef_[0] intercept pipeline.named_steps[lr].intercept_[0] coef_df pd.DataFrame({ feature: feature_names, coef: coef, abs_coef: np.abs(coef), odds_ratio: np.exp(coef) }).sort_values(abs_coef, ascendingFalse) print(intercept:, intercept) print(coef_df)注意这里的系数是在标准化之后的特征上得到的所以系数绝对值越大代表该变量对出险概率影响越大。odds_ratio列表示特征每增加一个单位发生出险的几率比会变为原来的多少倍。比如prior_claims的odds_ratio如果是1.6就意味着历史出险次数每增加一次出险几率大约提升60%。这个数字可以直接做成业务话术也能作为定价系数调整的参考依据。画ROC曲线也是老规矩fpr, tpr, thresholds roc_curve(y_test, y_prob) plt.figure(figsize(6, 4)) plt.plot(fpr, tpr, labelfAUC {roc_auc_score(y_test, y_prob):.3f}) plt.plot([0, 1], [0, 1], k--) plt.xlabel(False Positive Rate) plt.ylabel(True Positive Rate) plt.title(ROC Curve) plt.legend() plt.show()ROC曲线的意义在于能直观看到不同阈值下的区分能力。如果曲线越靠近左上角说明模型能同时保持高召回和低误报业务上代表高风险的客户能被精准圈出来。4. 实时评分与落地部署的思路4.1 保存模型与推理脚本模型训练完不是终点关键是能不能上线。在车险业务里客户在APP上点一下报价后端要在几百毫秒内返回保费结果这就依赖模型实时推理。先把训练好的模型保存下来import joblib joblib.dump(pipeline, car_insurance_lr.joblib)这个joblib文件很小一个几百KB的模型文件就能包含标准化器加逻辑回归整个预处理链路。上线推理时只需要加载模型然后调用predict_proba即可model joblib.load(car_insurance_lr.joblib) new_customer pd.DataFrame([{ age: 32, driving_years: 8, car_age: 3, annual_mileage: 15000, prior_claims: 1, violation_count: 2, vehicle_type_1: 1, vehicle_type_2: 0, region_1: 0, region_2: 0 }]) prob model.predict_proba(new_customer)[0][1] print(f预估出险概率: {prob:.2%})4.2 实时推理的性能注意点推理本身非常快但有几个性能问题必须注意。第一个是特征输入格式要和训练时完全一致。比如训练时vehicle_type做了One-Hot展开推理时就必须保证传入vehicle_type_1和vehicle_type_2这两列格式不对会报错或者预测结果偏差。在真实工程里通常会在上游生成好同一套特征宽表而不是在推理接口里临时做特征拼接。第二个是标准化器的保存问题。很多人只保存模型不保存scaler结果上线后发现预测分数和离线差距很大。原因是训练时特征被标准化到均值为0、方差为1推理时的输入也必须用训练集的均值和标准差做同样的变换。用Pipeline把scaler和模型绑在一起保存可以彻底避免这个问题。第三个是并发与容灾。车险报价是业务系统的高频接口模型服务一般会部署在容器里加上负载均衡和超时熔断机制。scikit-learn的模型在单机上做推理时是CPU密集型的可以先把模型加载进内存再用多线程或者多进程的方式处理请求。如果QPS很高也可以考虑把模型转成ONNX格式用ONNX Runtime做加速单条推理时间还能再压一个量级。5. 常见问题与实测避坑指南5.1 收敛警告与迭代次数我见过很多同学跑逻辑回归一上来就遇到ConvergenceWarning实际上在车险数据这种几十万条、特征几十列的量级下lbfgs在100次迭代里往往够用但如果特征做过独热编码列数会上来收敛速度会变慢。直接调大max_iter是第一步但也不是越大越好迭代次数太大只是浪费时间一般500到1000就够了。如果调大max_iter还是收敛不了就要考虑特征之间的量纲差异。逻辑回归对特征尺度比较敏感虽然l2正则能起到一定约束作用但量纲差异过大会让梯度下降路径变得曲折收敛自然慢。用StandardScaler标准化后再训练问题基本都能解决。5.2 类别不平衡的处理车险出险率低类别不平衡是所有做车险模型的人都要面对的问题。class_weightbalanced是一种快速解法但对业务人员来说更常见的是在阈值和评分上做文章。比如把预测概率不做硬分类而是映射成分数分数越高风险越高再设定一个高风险客户清单大于80分的进入人工核保或者提高保费系数。这种方式不要求模型百分百分类准确只需要排序能力够好就行。还有一种思路是过采样或欠采样但对车险数据我一般不建议。欠采样会大量丢弃未出险样本浪费信息过采样容易过拟合而且上线后真实分布和训练分布差距会变大分数稳定性变差。真正有效的是从数据源头入手多引入一些和出险强相关的特征比如驾驶行为数据、信用数据、历史理赔频度等模型区分度上去了类别不平衡带来的问题自然会缓解。5.3 变量解释与多重共线性逻辑回归系数的可解释性是一把双刃剑。如果你的特征维度很高而且变量之间存在明显相关性比如age和driving_years高度相关系数的标准误就会变大符号甚至会变得不符合业务直觉。这并不意味着模型不可用但解释系数时要谨慎。处理多重共线性有几个土办法先看特征之间的相关系数矩阵超过0.7的考虑只保留其中一个或者用VIF筛选但VIF对高维稀疏One-Hot特征并不适用所以实际操作中我会优先靠业务经验做变量筛选。另一个办法是用L1正则也就是lasso它能把不重要的特征系数压到0起到自动特征选择的作用。scikit-learn里把penalty设为l1即可但要注意solver要换成liblinear或saga。lr_l1 LogisticRegression( penaltyl1, solverliblinear, C0.5, class_weightbalanced, max_iter1000 ) lr_l1.fit(X_train, y_train) selected X_train.columns[lr_l1.coef_[0] ! 0] print(l1保留的特征:, list(selected))5.4 分数映射与业务沟通最后再分享一个我在实际项目里被问得最多的问题模型输出的概率怎么转成保费逻辑回归输出的P(y1)本身不能直接当保费通常需要做logit变换转成分数然后线性映射到某个保费基准区间。业务上更常见的是把模型系数转成因子每一列特征的每一个取值段对应一个系数这个系数就是对基准保费的调整比例。比如历史出险次数prior_claims1对应的因子是1.25那就意味着这类客户的保费要上浮25%。整个逻辑回归模型在这个环节的价值就是提供了一组稳定且可解释的系数让精算部门能够在此基础上调优。业务人员不会关心AUC是0.72还是0.74但他们会在意系数符号方向是否符合直觉因子的平滑性和单调性怎么样监管能不能看懂。这也是我一直坚持用逻辑回归做车险定价基线的原因。在我自己做过的一个车险风险评分项目里第一版上线的就是逻辑回归整体效果虽然不如后来尝试的GBDT模型但在特征监控和模型解释方面省了非常多沟通成本。后来我们也尝试过用树模型做增量但实际落地的核心定价模块依然保留了逻辑回归的系数因子表。做算法不是追求最复杂而是追求在业务里最稳。如果你现在正准备做车险或者金融评分相关的模型我的建议就是先把数据质量和特征工程做扎实再用逻辑回归把全链路跑通后面再上更复杂的模型会顺手很多。