项目背景
原本业务内容是比较常见的判定业务,即输入为某个实体有一定误差的测量信息和相关参考信息,输出为其应当归属的实体。套用一个简单场景就是输入一篇未署名文章,根据文风归属到库中已存在的作者名下,抑或是归属到一个新建的匿名作者名下。
问题的难点在于:
- 分类实体数量较多,在百万量级
- 分类数量不确定,且在动态变化,即有新增和过期
- 测量存在误差
- 场景较多
评价标准:
- 归属要准确(作者名下文章不要错)
- 少遗漏(文章能尽量找到作者)
- 避免错误创建(同一作者不要创建多个实体)
原解决方案也比较传统,即制定了一套策略,将策略组合为一颗决策树结构,逻辑较重的同时需要很多先验知识。套用场景即根据常用的人称代词、语法结构来判断是否属于同一作者。
随着准确率要求不断提高,策略组合的调整和维护愈发困难,主要原因在于:
- 策略不断增加,维护难度提升
- 为兼容误差打了不少补丁,维护难度提升
- 阈值、策略调整仅凭经验,每次调整需要回归验证的数据较多
- 场景较多,要针对每个场景制定对应的优化策略的工作量较大
总之一句话,可预见的未来策略会继续膨胀,维护难度也会进一步提升。意识到技术风险后,组内讨论认为如果使用机器学习方案一方面可降低解决方案的复杂度(降低维护成本),另一方面利于后期场景扩展和能力迁移。至少也应该能做到模型解决通用场景,在此基础上再定制优化策略(降低开发成本)。
本次幸运地被选中承担调研工作,尽管之前没有使用模型解决问题的经验(纯小白),但刚好有个拓宽技术面的机会肯定是不能放过的。于是现学现卖记录一下趟坑过程,也希望能给其他有兴趣的非算法攻城狮们一点信心,其实(浮于表面的)模型应用并没有通常大家认为的那么难。
前期准备
问题定义
问题的本质是匹配,即目标比较两个向量的距离。有很多方式可以实现这个比较,比如
- 分类:根据标注好的各类别数据参与训练,预测新数据属于哪一原定分类
- 聚类:把相似数据聚合为一类,最终得到多个分类
- 回归:多用于根据标注好的数据和原预测值获得一个拟合函数,预测新数据的值
模型选择
聚类模型
思路:特征聚为一类说明向量距离接近,属于同一分类。
使用聚类模型则似乎不利于实现在线预测(每次预测都要聚类,投入较高),且常用的聚类模型存在一些限制条件,如:
- K-means等,需要预设类簇个数K,不适合本业务场景
- DBScan等,需要一些先验知识如向量间的距离阈值等,在本场景中较难确定
- 聚类模型很难支持百万量级类簇数量的聚类
回归模型
实在不知如何将向量匹配问题转换为回归问题,所以干脆没怎么尝试。
多分类模型
思路:认为每个应当归属同一实体的数据属于一个分类,为所有特征计算分类。
多分类本质上是为每个类训练了一个分类器,但训练上百万个分类器显然也并不合理,放弃方案。
二分类模型
通过研究某届天池竞赛发现,直接计算两个特征向量的“差异”并根据最终是否匹配进行标注,是个比较合理的思路,最终便选择了二分类模型。
举例来说是这样:
x_diff | y_diff | match |
---|---|---|
0.1 | 0.1 | true |
0.9 | 0.9 | false |
简而言之,原本决策引擎使用的是多个isMatch(v1, v2, threshold)
规则组成的规则组,满足一组特定规则的就认为是同一类;而在使用机器学习模型后,使用的是[a1-a2, b1-b2, ... , x1-x2]
向量映射在高维空间的结果,由模型划线分类。
特征工程
基础数据
基础属性数据尽量选择与结果有相关性的,避免为明显毫无关联的属性计算特征参与训练。
特征计算
如前所述,最终特征的形式是输入与候选间同一属性维度的diff,最终形成的向量则是N个维度diff的结果。
特殊处理
- 首先明确,机器学习模型并不清楚特征各个维度之间的联系,也不知道特征维度的diff如何计算,所以需要做一些处理;
- 其次,可以做一些特殊处理使得基础特征与结果关联性更强,比如将x、y坐标转为二维坐标,使得最终的diff结果由x_diff、y_diff转化为(x, y)间的欧式距离
通过将一些非数值类特征通过特殊手段处理映射为(连续的)数值特征,对模型训练结果会有明显帮助:
- 文本类:可以尝试Levenshtein距离(编辑距离)
- 枚举类:可尝试One-Hot编码距离
- 距离类:可尝试余弦相似度、曼哈顿距离等
具体处理方式还是依业务而定,并非任何维度特征都应当处理。
数据准备
总数据量
通过特征工程计算的10w+特征向量。
打标
二分类模型需要将数据分为两个类,如0类和1类,或a类和b类等,分类标签需要体现在训练时的特征数据中,也就是说训练数据实际上是特征向量+分类标签
。
本业务中的分类就是两者是否匹配。套用场景即每篇文章提供多个候选作者,与其真正的作者属性diff形成的特征向量打标将会是a,而与其他非真正作者形成的特征向量打标将为b。
鉴于原决策模型准确率9x%左右,索性直接使用了原本业务决策模型输出的结果作为分类标签,当然使用人工打标结果效果是更好的,尽管人工打标准确率也不一定是100%。
预处理
- 归一化
- 避免单一维度影响过大,将每个维度的值映射到0~1的区间内
- 计算方式:
normalized_value = (value - min_value) / (max_value - min_value)
建模流程
建模
说明
本流程使用阿里云PAI平台搭建,省去搭环境的痛苦。当然,这个流程平台无关,完全也可以自行搭建。
读数据表
打标后的特征数据输入。
预处理脚本
对某些稀疏矩阵类型的特征进行预处理,转为标准顺序格式。即把Java中的Map结构按照拟定的顺序转为一维数组,由于特征较多,这个过程使用了代码生成脚本。
类型转换
某些特征值需要由非数值类型转为数值类型,另外可以做一些缺失值填充。
全表统计
统计数据表的各项信息,包括最大最小值、方差等。用于在数据预处理时参考,如归一化计算用到的每个维度的最大值最小值。
训练时的预测中这不是必须的步骤,输出的数据主要用于脱离平台使用。
数据归一化
将每个维度的值映射至0~1。
数据拆分
将输入的全量特征数据随机拆分成两个部分:
- 训练数据
- 用于训练模型,理论上训练数据越多,训练好的模型预测越稳定和准确
- 预测数据
- 用于给训练完的模型进行预测测试,模型对预测数据的预测结果将成为模型评估的依据
在这里可以把训练数据与预测数据按7:3或8:2拆分。
XGBoost二分类
XGBoost是基于梯度提升树(GBDT)算法的模型,由华人大牛陈天奇博士团队提出。
GBDT的思想可以用一个通俗的例子解释,假如有个人30岁,我们首先用20岁去拟合,发现损失有10岁,这时我们用6岁去拟合剩下的损失,发现差距还有4岁,第三轮我们用3岁拟合剩下的差距,差距就只有一岁了。如果我们的迭代轮数还没有完,可以继续迭代下面,每一轮迭代,拟合的岁数误差都会减小。
——参考资料
模型预测
将预测数据输入训练好的模型预测,结果用于模型效果评估。
模型导出
将训练好的模型导出为pmml格式文件。
混淆矩阵/二分类评估
用于评估模型效果。
模型评估
混淆矩阵
结果
说明
预测Positive | 预测Negative | |
---|---|---|
真值Positive | True Positive,该P的P了,正确 | False Negative,该P的N了,错误 |
真值Negative | False Positive,该N的P了,错误 | True Negative,该N的N了,正确 |
准确率
ACC = (TP + TN) / (TP + FN + FP + TN)
即整体准确率,所有预测正确的 / 总预测量。
精确率
PPV = TP / (TP + FP)
即该P也正确预测为P的准确率。也可以计算N的精确率。
召回率
TPR = TP / (TP + FN)
即P预测正确的占真值P的比例。也可计算N的。
总的来说准确率可以看出综合分类能力,而精确率和召回率可以看出其中一个分类的预测能力。
二分类评估
二分类评估结果通常是计算得到ROC/K-S曲线等。
简单地说,ROC曲线越靠左上角/AUC越大/F1 score越大/KS越大说明模型效果越好。
特征重要性
XGBoost模型对特征重要性进行了评估,对于贡献度非常小的特征维度在训练过程中舍弃掉了。
同时,训练好的模型可以输出特征重要性排序,也就是说可以根据特征重要性进行针对性的优化。例如,某维度重要特征由某服务计算得来,那么提升这个服务能力将比提高其他能力对结果的影响更大。
服务集成
模型导出
训练好的模型可导出为标准的pmml格式文件。
pmml格式是数据挖掘的通用规范格式,pmml文件其实就是一个很长的xml,包含用到的特征及特征间的关系。通过pmml文件可以加载训练的模型并执行预测,也就是说pmml是一个“类代码”,用于生成可运行的“实例”。
集成至服务
可以简单通过pmml-evaluator
包加载pmml文件:
<!-- 依赖 -->
<dependency>
<groupId>org.jpmml</groupId>
<artifactId>pmml-evaluator</artifactId>
<version>1.5.9</version>
</dependency>
/* 集成 */
@Service
public class PmmlDemo {
/**
* 模型pmml文件路径
*/
private static final String MODEL_PMML_PATH = "/model/gbdt_model_20210106.pmml";
/**
* 模型
*/
private Evaluator model;
/**
* 参数列表
*/
private List<InputField> paramFields;
/**
* Positive目标分类,同训练数据打标中的Positive分类
*/
private static final Object TARGET_CATEGORY = "0";
@PostConstruct
public void init() throws IOException, JAXBException, SAXException {
model = buildEvaluator();
paramFields = model.getInputFields();
}
/**
* 加载模型
* pmml-evaluator 1.5.x版本的使用方式与1.4略有不同
*/
private static Evaluator buildEvaluator() throws JAXBException, SAXException, IOException {
InputStream inputStream = PmmlDemo.class.getResourceAsStream(MODEL_PMML_PATH);
PMML pmml = PMMLUtil.unmarshal(inputStream);
inputStream.close();
ModelEvaluatorBuilder evaluatorBuilder = new ModelEvaluatorBuilder(pmml, (String)null)
.setModelEvaluatorFactory(ModelEvaluatorFactory.newInstance())
.setValueFactoryFactory(ValueFactoryFactory.newInstance());
return evaluatorBuilder.build();
}
/**
* 模型预测
*/
public Double getPredictScore(BizFeature feature) throws InvocationTargetException, IllegalAccessException {
if (feature == null) {
throw new NullPointerException();
}
// 读取feature数据
Map<String, Object> fieldMap = featureToMap(feature);
// 填充模型输入
Map<FieldName, FieldValue> params = fillParams(fieldMap);
// 预测
ProbabilityDistribution result = predict(params);
if (result == null) {
return null;
}
return result.getProbability(TARGET_CATEGORY);
}
/**
* 通过反射把业务特征BO属性转为map结构
* 包括数据的预处理
*/
private static Map<String, Object> featureToMap(BizFeature feature) throws InvocationTargetException, IllegalAccessException {
Map<String, Object> output = Maps.newHashMapWithExpectedSize(512);
Method[] methods = BizFeature.class.getDeclaredMethods();
for (Method method : methods) {
String key = method.getName();
if (!key.startsWith("get")) {
continue;
}
key = key.toLowerCase();
if (key.contains("bizid") || key.contains("entityid") || key.contains("label")) {
continue;
}
Object value = method.invoke(feature);
key = key.substring(3);
put(output, key, value);
}
return output;
}
/**
* 数据预处理
* 这里用到归一化
*/
private static void put(Map<String, Object> outputMap, String key, Object value) {
if (value instanceof Integer) {
outputMap.put(key, BizFeatureNormalizationHelper.normalization(key, (Integer)value));
} else if (value instanceof Double) {
outputMap.put(key, BizFeatureNormalizationHelper.normalization(key, (Double)value));
}
}
/**
* 根据模型需要的特征,提取对应的业务特征值进行填充
*/
private Map<FieldName, FieldValue> fillParams(Map<String, Object> map) {
Map<FieldName, FieldValue> params = Maps.newHashMap();
for (InputField inputField : paramFields) {
FieldName inputFieldName = inputField.getName();
Object rawValue = map.get(inputFieldName.getValue());
FieldValue inputFieldValue = inputField.prepare(rawValue);
params.put(inputFieldName, inputFieldValue);
}
return params;
}
/**
* 预测似乎是非线程安全的?使用synchronized
*/
private synchronized ProbabilityDistribution predict(Map<FieldName, FieldValue> arguments) {
Map<FieldName, ?> results = model.evaluate(arguments);
List<TargetField> targetFields = model.getTargetFields();
if (CollectionUtils.isEmpty(targetFields)) {
return null;
}
TargetField targetField = targetFields.get(0);
FieldName targetFieldName = targetField.getName();
return (ProbabilityDistribution)results.get(targetFieldName);
}
}
注意:pmml-evaluator
包1.5以前的版本模型加载方式和1.5.x版本方式不同。
在线预测
预测结果会输出每个分类的0~1打分,在二分类中两个分类的结果是互补的,如对a分类预测分0.3,则对b分类预测分就是0.7。
通过分类效果评估步骤可以确定一个合理的预测分阈值,即控制是以0.4分界还是0.6分界,小于这个阈值的为a分类,否则为b分类。
在线预测结果跟模型训练阶段同一条数据的预测结果可能有细微不同,因为数据预处理阶段使用的统计信息不完全相同。本业务训练时使用的归一化统计信息和在线预测时的略有不同。
补充
在淌过这条河以后发现,如果并不深究而是浅显地使用机器学习模型解决问题的话,确实没有想象中那么难。希望这篇简短的记录能够帮助更多人更容易地切换思路,在工具包里添加一项新的利器。
另外,Java/Python也有现成工具可以直接训练模型,导出为pmml文件,当然平台会更方便一些就是了。
踩坑经历
- 一定要定义好问题,这可能是小白尝试机器学习最难的一步(泪)
- 二分类中样本数量最好相近,否则可能会导致模型对某个分类过拟合
- 集成pmml后需要做与训练模型时相同的数据预处理
参考文档
机器学习系列(七)——分类问题(classification)zxhohai的博客-CSDN博客分类问题
分类器评估指标——混淆矩阵 ROC AUC KS AR PSI Lift Gain_snowdroptulip的博客-CSDN博客