车险定价这个活儿,最容易被问到的一句话就是:你们用的模型到底是不是深度学习?其实在真实业务里,能稳定跑线、能向监管解释、能算出每个因子赔率系数的模型,反而常常是逻辑回归。逻辑回归在车险定价里面不是"落伍",而是"够用、好用、可解释"。这篇文章我就以车险定价为例,把逻辑回归从数据准备、特征工程、模型训练到模型评估、上线推理的完整链路拆开讲一遍,附上可以直接跑的Python代码。适合正在做保险风控、金融评分卡,或者刚入门机器学习想找一个真实业务场景练手的同学。
1. 先说清楚:为什么车险定价能用逻辑回归
1.1 车险定价到底在算什么
车险定价不是简单报个价,它本质上是在回答两个问题:这个客户未来一年会不会出险?出险的话大概要赔多少钱?在实际业务里,纯保费通常等于出险频率乘以案均赔款,而最核心的第一步,就是先估计出险概率。我们做车险评分卡、做定价模型,第一步都是围绕"出险概率"来做文章。
出险概率是一个二分类问题:客户出险记为1,没出险记为0。影响出险概率的因素很多,比如驾驶员的年龄、驾龄、历史违章次数、历史理赔次数、车辆品牌、行驶里程、所在地区等等。这些变量有的连续、有的离散、有的高基数,必须经过特征工程统一处理。而逻辑回归天然输出0到1之间的概率值,公式为:
[ P(y=1|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家用轿车,1SUV,2跑车),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_first=True)drop_first=True是为了避免哑变量陷阱。如果不做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', axis=1) y = df['is_claim'] X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.3, random_state=42, stratify=y ) print(f'train size: {len(X_train)}, test size: {len(X_test)}') print(f'positive rate in train: {y_train.mean():.4f}') print(f'positive 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( penalty='l2', C=1.0, solver='lbfgs', max_iter=1000, class_weight='balanced' ) pipeline = Pipeline([ ('scaler', scaler), ('lr', lr) ]) pipeline.fit(X_train, y_train)这里我特别解释几个关键参数,因为这些参数直接决定模型能不能收敛、系数会不会爆炸。
第一个是penalty和C。默认penalty是l2正则,C是正则强度的倒数,C越小正则越强,系数越往0压缩。在车险定价场景,我一般不会把C设得太小,因为系数既要稳定又要有业务解释。可以用交叉验证去调C,但更常见的做法是直接用默认C=1.0跑一版,看系数符号是否符合业务常识,再做微调。
第二个是solver。lbfgs是目前比较推荐的求解器,适合大多数中小规模数据,而且支持l2正则。如果你的数据非常大,可以试试saga。liblinear是历史遗留选项,适合小数据集,但用之前建议先看当前版本是否支持你要的正则化类型。
第三个是class_weight。车险出险率一般只有10%~20%,负样本远多于正样本,如果不设class_weight,模型会倾向于把所有样本都预测为不出险,导致AUC虚高但实际抓不到出险人群。设置class_weight='balanced'可以按样本比例自动加权。这个参数在车险场景里几乎必开。
第四个是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', ascending=False) 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, label=f'AUC = {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_weight='balanced'是一种快速解法,但对业务人员来说,更常见的是在阈值和评分上做文章。比如把预测概率不做硬分类,而是映射成分数:分数越高风险越高,再设定一个"高风险客户清单",大于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( penalty='l1', solver='liblinear', C=0.5, class_weight='balanced', max_iter=1000 ) lr_l1.fit(X_train, y_train) selected = X_train.columns[lr_l1.coef_[0] != 0] print('l1保留的特征:', list(selected))5.4 分数映射与业务沟通
最后再分享一个我在实际项目里被问得最多的问题:模型输出的概率怎么转成保费?逻辑回归输出的P(y=1)本身不能直接当保费,通常需要做logit变换转成分数,然后线性映射到某个保费基准区间。业务上更常见的是把模型系数转成"因子":每一列特征的每一个取值段对应一个系数,这个系数就是对基准保费的调整比例。
比如历史出险次数prior_claims=1对应的因子是1.25,那就意味着这类客户的保费要上浮25%。整个逻辑回归模型在这个环节的价值,就是提供了一组稳定且可解释的系数,让精算部门能够在此基础上调优。业务人员不会关心AUC是0.72还是0.74,但他们会在意"系数符号方向是否符合直觉""因子的平滑性和单调性怎么样""监管能不能看懂"。这也是我一直坚持用逻辑回归做车险定价基线的原因。
在我自己做过的一个车险风险评分项目里,第一版上线的就是逻辑回归,整体效果虽然不如后来尝试的GBDT模型,但在特征监控和模型解释方面省了非常多沟通成本。后来我们也尝试过用树模型做增量,但实际落地的核心定价模块,依然保留了逻辑回归的系数因子表。做算法不是追求最复杂,而是追求在业务里最稳。如果你现在正准备做车险或者金融评分相关的模型,我的建议就是:先把数据质量和特征工程做扎实,再用逻辑回归把全链路跑通,后面再上更复杂的模型会顺手很多。