Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

最近MASTER项目发现了数据预处理的一些重要错误不知道对于MATCC有没有影响 #4

Open
weituo2002 opened this issue Dec 17, 2024 · 7 comments

Comments

@weituo2002
Copy link

最近MASTER项目在readme中更新了数据预处理的一些重要错误,不知道对于MATCC有没有影响

@caozhiy
Copy link
Owner

caozhiy commented Dec 23, 2024

MASTER更新之后,原始数据集变为开源的数据了,也更新了手动drop Extreme label的操作。如果要是对比的话,可以有两种方法:

  1. 只使用MASTER提供的2008~2022数据集,这个结果没有影响
  2. 按照最新的数据集进行测试,由于预处理方法不同,实验结果应该会有明显影响

@weituo2002
Copy link
Author

仔细看了一下代码好像确实有问题,这个问题在https://github.com/cq-dong/DFT_25中也有讨论,而且gsyyysg的stockformer也存在同样的问题,就是前几天MASTER项目作者刚刚发现的预处理的重大bug,就是对于DropExtremeLabel这个数据预处理processor并没有针对训练集进行fit学习最大最小值参数,而是对于整个数据集进行了去掉5%的最到最小值,这个是会有严重的信息泄漏的,可以参考gsyyysg/stockformer的issue,也是存在同样的问题,原文是:“数据处理方法在别的issue有人介绍过是当前值除以OCHL中的最大值,我记得我之前有验证过确实是这样。这种处理方法对未来泄漏的信息严重程度反而与历史数据相关,有违常理:只要模型越确信某个标的接近1的价格不在过去,那必然在未来。极端点,按这种数据处理方法,只要检查标的历史数据,做多价格越低的标的,做空价格越高的标的就行
具体程度应该检查代码,但光凭这一点,我就觉得这玩意没有任何价值”;而在DFT_25中的讨论中也有:"qlib的处理过程,t-1时间步切面被drop的股票,会拿t-2的数据去填充,则t-1和t-2是相同的,经过rwkv时间序列提取,这个是很强的特征,模型会直接去拟合这个特征(相当于t-1这个切面上的股票有了未来label的信息)。 说白了,模型学到的就是只要t-1和t-2数据相同,就预测很高或很低收益率的label。直接导致了ic的暴涨。 其实只要拿master最新的全量数据,跟master一样训练过程中去动态drop当前切面的extremelabel,你会发现根本不拟合。"
而工程中的源码部分,确实是针对整个数据集去掉的最到最小值,那么无论是stockformer的issues中的说法,还是DFT_25中的说法,貌似这样做都存在信息泄漏,而且MASTER的作者也在工程的readme中说明的这个全局取极值的带来的问题,感觉这样做确实存在bug
class DropExtremeLabel(Processor):
def init(self, fields_group='label', percentile: float = 0.975):
super().init()
self.fields_group = fields_group #针对目标标签列处理
assert 0 < percentile < 1, "percentile not allowed"
self.percentile = percentile

def forward(self, df):
df = df.copy() #去掉告警添加该语句
rank_pct = df['label'].groupby(level='datetime').rank(pct=True)
df.loc[:,'rank_pct'] = rank_pct
trimmed_df = df[df['rank_pct'].between(1 - self.percentile, self.percentile, inclusive='both')]
return trimmed_df.drop(columns=['rank_pct'])

def call(self, df):
return self.forward(df)

def is_for_infer(self) -> bool:
"""The samples are dropped according to label. So it is not usable for inference"""
return True

def readonly(self):
return False

@weituo2002
Copy link
Author

这里我的理解是在DropExtremeLabel类中对于is_for_infer返回了True,那么就是对于全局数据进行了去极值操作
class DropExtremeLabel(Processor):
def is_for_infer(self):
# 该processor是否对infer(test)可用,默认为True
# 如果该processor包含的操作只能作用于learn(train),则设为False
# 举个例子,我们如果想定义一个对label操作的processor,由于test中没有label,此时就要设为False
return True
而这个预处理器将用在DataHandlerLP准备数据的过程,核心函数是:
def setup_data(self, init_type: str = IT_FIT_SEQ, **kwargs):

# init raw data
super().setup_data(**kwargs)

with TimeInspector.logt("fit & process data"):
    if init_type == DataHandlerLP.IT_FIT_IND:
        self.fit()
        self.process_data()
    elif init_type == DataHandlerLP.IT_LS:
        self.process_data()
    elif init_type == DataHandlerLP.IT_FIT_SEQ:
        self.fit_process_data()
    else:
        raise NotImplementedError(f"This type of input is not supported")

其中:
def process_data(self, with_fit: bool = False):

# shared data processors
# 1) assign
_shared_df = self._data
if not self._is_proc_readonly(self.shared_processors):  # avoid modifying the original data
    _shared_df = _shared_df.copy()
# 2) process
_shared_df = self._run_proc_l(_shared_df, self.shared_processors, with_fit=with_fit, check_for_infer=True)

# data for inference
# 1) assign
_infer_df = _shared_df
if not self._is_proc_readonly(self.infer_processors):  # avoid modifying the original data
    _infer_df = _infer_df.copy()
# 2) process
_infer_df = self._run_proc_l(_infer_df, self.infer_processors, with_fit=with_fit, check_for_infer=True)

self._infer = _infer_df

# data for learning
# 1) assign
if self.process_type == DataHandlerLP.PTYPE_I:
    _learn_df = _shared_df
elif self.process_type == DataHandlerLP.PTYPE_A:
    # based on `infer_df` and append the processor
    _learn_df = _infer_df
else:
    raise NotImplementedError(f"This type of input is not supported")
if not self._is_proc_readonly(self.learn_processors):  # avoid modifying the original  data
    _learn_df = _learn_df.copy()
# 2) process
_learn_df = self._run_proc_l(_learn_df, self.learn_processors, with_fit=with_fit, check_for_infer=False)

self._learn = _learn_df

if self.drop_raw:
    del self._data

由于is_for_infer返回了True,那么处理器DropExtremeLabel将对整个数据集都做去极值的操作,这样就让无论是训练集还是测试集都看到了全局的统计特征了,这样应该就造成了数据泄漏了

@caozhiy
Copy link
Owner

caozhiy commented Dec 24, 2024

您是说,丢弃5%极端值不能在数据预处理时,这样可能会数据泄露,而应该在跟master一样训练过程中去动态drop当前切面的extremelabel,即生成[stock_num, time, model_dim]样本时进行丢弃是吗?

@weituo2002
Copy link
Author

我在https://github.com/cq-dong/DFT_25 的讨论中看到有人说是:“数据泄漏跟全局统计特征关系不大,跟时间序列中每个时间步中drop掉的数据用上一个时间步的数据填充有关。” “qlib的处理过程,t-1时间步切面被drop的股票,会拿t-2的数据去填充,则t-1和t-2是相同的,”
所以我看了又仔细看了下TSDataSampler的源码,貌似并没有:“跟时间序列中每个时间步中drop掉的数据用上一个时间步的数据填充有关。”,因为在DropExtremeLabel中是针对标签值进行的整行删除drop,也就是将该日期的这只股票的数据完整地给删除掉了,因此,在TSDataSampler的__getitem__获取数据的函数中调用了_get_indices通过行列索引去得到数据,而在_get_indices中有:
if self.fillna_type == "ffill":
indices = np_ffill(indices)
elif self.fillna_type == "ffill+bfill":
indices = np_ffill(np_ffill(indices)[::-1])[::-1]
else:
assert self.fillna_type == "none"
return indices
其中将执行indices = np_ffill(np_ffill(indices)[::-1])[::-1]
np_ffill(indices): 对 indices 数组进行前向填充(forward fill),即用前一个非 NaN 值填充 NaN 值。
[::-1]: 将数组反转。
np_ffill(...)[::-1]: 对反转后的数组再次进行前向填充,然后再反转回来,这样就实现了后向填充(backward fill)。
最终效果是对 indices 数组进行前向和后向填充,确保所有的 NaN 值都被填充。

然而,由于前面是针对某一日的某只股票的整行数据的删除,所以这里应该不存在任何NaN的情况需要填充,需要填充的是特征列中存在NaN的情况,所以这里的填充是与删除5%的极大极小标签值无关的操作。
所以也是应该不存在“qlib的处理过程,t-1时间步切面被drop的股票,会拿t-2的数据去填充,则t-1和t-2是相同的,” 这么一个处理过程的,因为根本这个交易日就不存在被删除的这只股票的数据行,所以更加谈不上填充了,只有当这个股票的数据行存在,而其中某些特征列中有NaN值,这个时候才有填充的问题,所以这个地方的操作没有问题的。
那么最有可能的还是应该是提前让模型知道了整个数据标签的最大最小值的极限在哪儿,模型会学习接近这个极限就预测反转的模式。
质疑的主要原因就是大家认为IC值达到0.14太高了,不可思议。我个人觉得可能的原因看是不是因为使用了全局数据的极值去处理数据,后面我将实验一下不进行DropExtremeLabel预处理的效果进行一个对比,因为没有具体看MASTER目前的预处理方法,所以没法用他的方式做验证,先简单去掉这一步试试

@YHY-10
Copy link

YHY-10 commented Dec 29, 2024

我在https://github.com/cq-dong/DFT_25 的讨论中看到有人说是:“数据泄漏跟全局统计特征关系不大,跟时间序列中每个时间步中drop掉的数据用上一个时间步的数据填充有关。” “qlib的处理过程,t-1时间步切面被drop的股票,会拿t-2的数据去填充,则t-1和t-2是相同的,” 所以我看了又仔细看了下TSDataSampler的源码,貌似并没有:“跟时间序列中每个时间步中drop掉的数据用上一个时间步的数据填充有关。”,因为在DropExtremeLabel中是针对标签值进行的整行删除drop,也就是将该日期的这只股票的数据完整地给删除掉了,因此,在TSDataSampler的__getitem__获取数据的函数中调用了_get_indices通过行列索引去得到数据,而在_get_indices中有: if self.fillna_type == "ffill": indices = np_ffill(indices) elif self.fillna_type == "ffill+bfill": indices = np_ffill(np_ffill(indices)[::-1])[::-1] else: assert self.fillna_type == "none" return indices 其中将执行indices = np_ffill(np_ffill(indices)[::-1])[::-1] np_ffill(indices): 对 indices 数组进行前向填充(forward fill),即用前一个非 NaN 值填充 NaN 值。 [::-1]: 将数组反转。 np_ffill(...)[::-1]: 对反转后的数组再次进行前向填充,然后再反转回来,这样就实现了后向填充(backward fill)。 最终效果是对 indices 数组进行前向和后向填充,确保所有的 NaN 值都被填充。

然而,由于前面是针对某一日的某只股票的整行数据的删除,所以这里应该不存在任何NaN的情况需要填充,需要填充的是特征列中存在NaN的情况,所以这里的填充是与删除5%的极大极小标签值无关的操作。 所以也是应该不存在“qlib的处理过程,t-1时间步切面被drop的股票,会拿t-2的数据去填充,则t-1和t-2是相同的,” 这么一个处理过程的,因为根本这个交易日就不存在被删除的这只股票的数据行,所以更加谈不上填充了,只有当这个股票的数据行存在,而其中某些特征列中有NaN值,这个时候才有填充的问题,所以这个地方的操作没有问题的。 那么最有可能的还是应该是提前让模型知道了整个数据标签的最大最小值的极限在哪儿,模型会学习接近这个极限就预测反转的模式。 质疑的主要原因就是大家认为IC值达到0.14太高了,不可思议。我个人觉得可能的原因看是不是因为使用了全局数据的极值去处理数据,后面我将实验一下不进行DropExtremeLabel预处理的效果进行一个对比,因为没有具体看MASTER目前的预处理方法,所以没法用他的方式做验证,先简单去掉这一步试试

如果股票在交易日t被删除了, 但是前一天t-1这个股票存在的话,在idx_df中t天这个股票是nan,前一天是有值的,那么'ffill+bfill'会将t天的nan用t-1天填充

@weituo2002
Copy link
Author

weituo2002 commented Jan 1, 2025

我仔细打印了预处理阶段的数据集和训练阶段的数据集,确实因为DropExtremeLabel预处理器造成很多NaN行的存在,从而因为qlib的机制会引入很多的重复填充行,但是其中的引起数据重复填充的情况还是要分为两类:一类是因为股票数据距离开始日期不足8个交易日,占总训练集的6%;另一类是因为DropExtremeLabel标签极值删除预处理器带来的,占总训练集的15.4%。
最终做了实验,将dropextremelabel预处理器去掉之后,并且RobustZScoreNorm改成绝对不引入未来信息的,只使用到当前交易日为止的历史数据的滑动窗口的RobustZScoreNorm,做75次epoch,验证集的ic值始终在0.01到0.02之间甚至为负的情况都有,而测试集始终不会超过0.03到0.04左右,大幅低于0.1+这种论文结果,看来引入了15%的重复数据在数据集中,对于结果的影响是巨大的
详细分析如下:
1、在数据预处理阶段generate_dataset.py中
TSDataSampler调用初始化__init__,在初始化阶段
self.idx_df, self.idx_map = self.build_index(self.data)
会调用build_index:
1)idx_df = pd.Series(range(data.shape[0]), index=data.index, dtype=object)
将格式为<datetime日期,instrument股票代码,feature特征(多个),label标签>,其中<datetime日期,instrument股票代码>是二级索引的原始数据集从0开始编号,得到每行数据的一维索引号
2)idx_df = lazy_sort_index(idx_df.unstack())
将<datetime日期,instrument股票代码>二级索引,再转变为行是日期,列是每个股票代码的矩阵:
<datetime日期,instrument股票代码1,instrument股票代码2,……,instrument股票代码N>
该矩阵的元素就是,元数组中每一行的一维索引号
对于当日没有数据的股票,在矩阵中被填入NaN值
并通过self.data_index = deepcopy(self.data.index)保存了原始数据的二级索引

2、在模型训练阶段train_model_MATCC.py
在train_epoch函数中,通过循环for data in data_loader,调用了TSDataSampler的__getitem__,其中将一维行号索引转成了索引矩阵<datetime,instrument1,……instrumentN>的行列号然后调用了_get_indices来获取每个股票记录的连续8个交易日的数据:
indices = self.idx_arr[max(row - self.step_len + 1, 0) : row + 1, col]
对数据集交易日开始不满8个交易日的股票索引都用NaN值填充成连续8个交易日:
if len(indices) < self.step_len:
indices = np.concatenate([np.full((self.step_len - len(indices),), np.nan), indices])
对于被DropExtremeLabel所删除的股票记录,在索引矩阵<datetime,instrument1,……instrumentN>中的索引值都是NaN。
然后这两种情况都会被用前后的索引进行填充,最后导致的取数据相同的索引会得到重复的行
if self.fillna_type == "ffill":
indices = np_ffill(indices)
elif self.fillna_type == "ffill+bfill":
indices = np_ffill(np_ffill(indices)[::-1])[::-1]

经过数据统计,不使用去标签极值过滤器DropExtremeLabel,有6%的股票数据因为距离开始日期不足8个交易日存在NaN值
图片1
而使用了去标签极值过滤器DropExtremeLabel,有21.4%的股票数据因为距离开始日期不足8个交易日和因为被删除了标签值在极值范围内的交易日数据而存在NaN值,其中因为被删除了标签值在极值范围内的交易日数据而存在NaN值的占比是21.4%-6%=15.4%
图片2
因此因为距离开始日期不足8个交易日而引入的重复数据占比是6%
而因为删除了标签值在极值范围内的交易日数据而引入的重复数据占比是15.4%

那么最终的问题就是这些总共21.4%的重复数据会对结果有非常大的影响。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

3 participants