枯藤老树昏鸦,小桥流水人家。语文老师给我讲这首诗的时候,说这个只是名词的堆砌,但给人一种直指内心的感觉。
——以上是这篇技术文章的核心思想。
(资料图片仅供参考)
(来源:微信公众号“兰木达电力现货”作者:王超一)
一.两种决策树的差异对比
在讲正题之前,先简要讲一下 scikit-learn(以下简称sklearn)决策树的主要实现逻辑。另外,这一块代码位于 sklearn/tree 目录下,而 ensemble 里的随机森林依赖 tree 目录代码。
首先,构造一棵决策树有两种逻辑,一种是深度优先搜索DepthFirstTreeBuilder,即每次找到最优的分裂节点,然后在左右子树中按同样逻辑构建;另一种是类似宽度优先搜索的贪心策略BestFirstTreeBuilder,之所以说是类似宽度优先搜索,是因为每次都找到当前的最优分裂,而不是按层分裂,即把宽搜用的队列换成优先级队列。用哪一种方式建树取决于是否指定了最大叶子节点数T,如果指定了就用BestFirstTreeBuilder,因为这样即使无脑搜索也是O(T2)复杂度,当然sklearn用了基于堆的优先级队列可以将复杂度降低为O(TlogT)。
其次,除了构建树的方法外,剩下的逻辑核心在于 Splitter, Splitter也保存了原始的X、y、 sample_weight,Splitter主要有4个方法:
1)init 方法,保存原始X、y、sample_weight,生成一个对样本(行)的索引表,并利用X和行索引表和一个特征的缓存数组生成一个Partitioner 对象;
2)node_reset方法,利用 y,sample weight和行索引构造 Criterion 对象,分类树的熵、基尼系数,回归树的MSE,MAE都是在这个Criterion的实现;
3)node_impurity方法,调用self.criterion.node_impurity计算一个节点的纯度,这个数越小越好;
4)node_split 方法,本方法是核心,有两个更基础的方法,分别为 node_split_best 和 node_split_random,这两个方法都调用了 Splitter、Partitioner、Criterion 3个对象,同时有很多性能上的优化,如在行索引上操作,对快速排序的优化,对常量特征的优化等。
上边这一段看不懂没有关系,大致就是说,sklearn用cython写了个树的构建,并把api暴露给python,然后每个关键点都有几种选择,这些一般都传入字符串参数,然后sklearn有内置的字典来找到对应的类,这些类用 Cython 实现来确保效率。
二.电力市场交易的优化目标是收益而不是偏差
问题在于,拿电力交易为例,我们最终的目标是收益最大化,一般来说这个收益取决于当前价格和未来价格之差,后文简称价差,如果我们构造一个回归树,y为价差,那么可以选择 MAE、MSE或其变种作为一个纯度的度量,这样就是把收益最大化问题转换为价差的回归了。这个数学建模的问题在于,虽然逻辑上更优的价差预测会带来更好的策略收益,但实践中也不绝对,有可能把MAE降低了,策略收益没有提高反而降低了。
能否给出一个直面目标函数(策略收益)的决策树呢?
首先,回过头再看一下决策树为我们提供了什么,决策树给出了一些条件下的样本集合,这些条件是分层的,可以定义这个样本集合的一个指标,代表这个分层好不好,即决策树是否恰当划分。那么这个指标就可以直接定义为这个集合下按照某种交易策略下的利润。
比如,我们有一种策略,只看预测价差的符号,不论价差大小,都申报同样多的电量,那么这个集合的利润就是所有价差之和的绝对值。
其次,还得重新实现proxy_impurity_improvement和children_impurity这两个方法,限于篇幅原因不再赘述。这里面有个坑,这个纯度即node_impurity 越小越好,那么我们利润最大化得乘以-1,然而在sklearn中,默认纯度为正数,我们开头讲的树构建过程中,如果纯度小于一个极小正数时认为当前节点已经是叶子节点了,所以得改树构建的逻辑,具体而言就是加上3个#注释掉3行即可。
经过这样的改造,在山东日前交易中,回测利润是MSE的3倍或更多。
接下来,我们更进一步,注意到我们这里要用-1倍的利润作为节点纯度,对新能源电站,计算利润需要更多的数据,比如装机容量、实际发电和预测发电,而实际发电在sklearn框架中是一个尴尬的存在。
我们不能把这个数值当做X,因为这个是个未来数据。然而在回归中,如果把这个数当做y,在代码逻辑上勉强能跑通,但会在代码中把真正的y与辅助性的数据混在一起,代码结构化变差,比如在predict会得到一个二维矩阵。所以我们尝试了修改 sklearn 的 api,分别给 BaseDecisionTree.fit、TreeBuilder.build、Splitter.init, Criterion.init 中增加相同的辅助计算节点纯度的参数,加上这个机制,就可以任意修改(节点纯度)目标函数了。
自定义的目标函数让我们可以直面交易利润,把价差预测的学习直接升级到交易策略的学习,然而,这也给我们提出了更高的决策树后处理要求,对策略的过拟合也得有更多的控制手段。
最后吐槽一下,Pycharm 对 Cython 的支持太差了,右边一堆红(提示代码错误),代码折叠也有问题,可能逼着我们去用 vs code 了。