量化投资与机器学习微信公众号,是业内垂直于量化投资、对冲基金、Fintech、人工智能、大数据领域的主流自媒体公众号拥有来自公募、私募、券商、期货、银行、保险、高校等行业30W+关注者,荣获2021年度AMMA优秀品牌力、优秀洞察力大奖,连续2年被腾讯云+社区评选为“年度最佳作者”。

核心观点
  • 本文将公司主营产品分项为预定义概念的 HIST 模型作为实验组,每只股票可对应多个预定义概念;将行业分类作为预定义概念的 HIST模型作为对照组,每只股票只对应一个预定义概念。
  • 在实测时补充加入了预定义概念 point-in-time 的操作逻辑。
  • 实证表明,以主营产品分项为预定义概念的 HIST 模型,能获得 13.48% 的超额收益(相对于沪深 300 基准),且比对照组的超额收益高出近 3.94%。
前言
在实践中,具有相同概念的股票,它们的价格趋势往往存在较高的相关性。这里的概念并不局限于大家熟悉的概念板块,公司的所属行业、主营业务、经营范围等都可以算作“概念concept”。那如何利用这些概念所包含的有用信息来预测股票价格未来趋势呢?目前比较普遍的做法是基于股票共享的概念构建邻接矩阵,再结合图神经网络 GNN 来预测股价趋势。
有印象的读者可能会想起公众号早期推文介绍的(因子挖掘:基于图神经网络与公司主营(附代码)) HIST 模型框架(the concept-oriented shared information for stock trend forecasting),就属于上述研究范畴,而且 HIST 模型的表现非常出众。
本文我们利用数库SAM产业链数据中有关公司主营业务分项的数据,来实证 HIST 模型在沪深 300 成分股中的表现。
HIST模型概览
HIST 模型的亮点在于:提取共享概念中所包含的信息时,考虑了概念的不完备性和概念的动态变化性。作者将现有的行业分类、主营业务、经营范围等概念称为预定义概念,但由于这些预定义概念并不能包含所有的概念关系,所以模型还能进一步“学”到隐含概念。基于预定义概念和隐含概念,作者将股票特征所包含的信息分割成了 3 部分:来自预定义概念的共有信息、来自隐含概念的共有信息、股票自身特有的特质信息,再用这 3 部分信息来共同预测股价未来趋势。
数据介绍
本次实证分析将采用数库SAM产业链数据中的产品节点作为预定义概念,数库SAM产业链数据中的每个产品节点是经过名称标准化后的公司主营产品,产品节点之间除了上下游关系外,还有产品层级关系,1 级产品对应的是 GICS 4 级行业,1 级产品往下延伸,最深可以细分至产品 12 级。
本次研究将结合产品节点和业务营收数据,取“相同营业收入下最细层级的产品”作为预定义概念。以下图的比亚迪为例,比亚迪所属的预定义概念有:汽车制造、手机零件、二元电池。(注:“其他”项都会被剔除,不作为预定义概念。)
由此可知,一只股票会同时属于多个预定义概念,而且预定义概念会随着公司披露的主营产品的变化而变化。2017 年至 2022 年初,沪深 300 成分股平均每天涉及的总预定义概念数约为 1456 个(总预定义概念数=每只沪深 300 成分股所属预定义概念数之和,做过去重处理),沪深 300 成分股所属预定义概念的数量分布如下图所示:
经统计可知,沪深 300 成分股中,所属预定义概念数>=2 的占比高达 95.5%,所属预定义概念数>=3 的占比高达 90.73%,所属预定义概念数>=5 的占比也高达 67.02%。
构建预定义概念矩阵
预定义概念采用的是上面【数据介绍】部分提到公司主营业务分项数据,由于主营业务大部分来自财报,且不同时间财报披露的业务会有所不同,所以我们对主营业务数据根据披露日期进行了PIT处理,并将处理后的数据存为三重索引的DataFrame(第一层为交易日trade_date、第二层为股票代码 sec_code、第三层为产品代码 product,取值为concept=1),这样存的好处在于剔除了缺失产品的数据,节省存储资源:
在模型训练的过程中,再提取每个交易日对应的预定义概念数据,并将其转换成预定义概念的矩阵格式(行为股票代码,列为预定义概念代码),转换代码如下所示:
defget_stock2concept_matrix_dt(stock2concept_matrix, index):
'''

    按索引提取预定义概念

    :param stock2concept_matrix:

    :param index: 每个交易日下的index

    :return:

    '''

    dt = index.get_level_values(
0
).unique()

assert
 len(dt ==
1
),
'提取的stock2concept_matrix涉及多个交易日!'
    stock_code = index.get_level_values(
1
).unique().tolist()

    stock2concept_matrix_dt = stock2concept_matrix.loc[dt[
0
]].unstack().reindex(stock_code).fillna(
0
)

return
 stock2concept_matrix_dt
关于 index 参数的说明:由于模型训练的 batch_size 为每个交易日的股票数,所以每一轮训练对应的预定义概念矩阵可以用每个交易日下的 index 来提取,index 的形式如下:
利用 get_stock2concept_matrix_dt 函数就可以在每轮训练时,提取相应交易日的数据,并转换为如下的矩阵形式(属于该概念的取值为1,不属于该概念的取值为0),这个矩阵就是我们的预定义概念二分图(bipartite graph):
实证分析
关于 HIST 模型的输入,需要读取 3 部分数据:
① Qlib 计算的 Alpha360 特征,作为股票特征(因子);
② 股票每日市值数据,用于加权预定义概念得到初始化的预定义概念表征;
③ 上文构建的预定义概念矩阵。
关于 HIST 模型的核心代码逻辑,主要做了如下几部分的工作:
① 股票特征提取:使用 2 层的 GRU 对股票原始特征(Alpha360因子)进行时间序列特征提取;
② 构建预定义概念模型 (Predefined concept module ):主要负责 a. 初始化预定义概念表征;b. 对初始化后的概念表征进行修正,进而解决“预定义概念的信息缺失”和“预定义概念的信息过剩”问题;c. 从修正后的预定义概念表征中提取共有信息;d. 对 ① 中提取的股票特征,剥离掉 c. 中提取的共有信息,作为下一步模型的输入;
③ 构建隐含概念模型(Hidden concept module):主要负责 
  • 初始化隐含概念表征,即以 ②-d 中返回的剥离了预定义概念共有信息后剩余股票特征作为初始的隐含特征;
  • 计算股票剩余表征与初始化的隐含概念表征之间的 cos 相似度,并筛选出相似度最高的 K 个概念作为新的隐含概念表征;
  • 从新的隐含概念表征中提取共有信息;
  • 进一步剥离掉 c. 中提取的共有信息,作为下一步模型的输入;
④ 构建股票特质信息模型(Individual information module):主要用于生成股票特征中除去预定义概念共有信息和隐含概念共有信息外剩下的股票自身特有的信息;
⑤ 合并②-④模型提取的预定义概念共有信息、隐含概念共有信息、股票自身特质信息,一起预测股价未来走势。
classHIST(nn.Module):
def__init__(self, d_feat=6, hidden_size=64, num_layers=2, dropout=0.0, base_model="GRU", K =3):
        super().__init__()

        self.d_feat = d_feat

        self.hidden_size = hidden_size

# 构建⻔控循环单元(gated recurrent units, GRU)
        self.rnn = nn.GRU(

                input_size=d_feat,
# 输入的特征维度
                hidden_size=hidden_size,
# 隐藏单元个数(相当于输出维度)
                num_layers=num_layers,
# 网络层数
                batch_first=
True
,
# 输入的数据形式,True 为 (batch,seq,feature),False 为 (seq,batch,feature)
                dropout=dropout,
# 0 表示不使用 dropout 层;1 表示除最后一层外,其它层的输出都会加 dropout 层
            )

        self.fc_es = nn.Linear(hidden_size, hidden_size)
# 构建全连接层,in_features 输入的特征数和输出的特征数都为 hidden_size
        torch.nn.init.xavier_uniform_(self.fc_es.weight)
# 用一个均匀分布生成值,填充输入的张量或变量
        self.fc_is = nn.Linear(hidden_size, hidden_size)

        torch.nn.init.xavier_uniform_(self.fc_is.weight)

        self.fc_es_fore = nn.Linear(hidden_size, hidden_size)
# 对应需要提出的共有信息 x
        torch.nn.init.xavier_uniform_(self.fc_es_fore.weight)

        self.fc_is_fore = nn.Linear(hidden_size, hidden_size)

        torch.nn.init.xavier_uniform_(self.fc_is_fore.weight)

        self.fc_es_back = nn.Linear(hidden_size, hidden_size)
# 对应预测输出 y
        torch.nn.init.xavier_uniform_(self.fc_es_back.weight)

        self.fc_is_back = nn.Linear(hidden_size, hidden_size)

        torch.nn.init.xavier_uniform_(self.fc_is_back.weight)

        self.fc_indi = nn.Linear(hidden_size, hidden_size)

        torch.nn.init.xavier_uniform_(self.fc_indi.weight)

        self.leaky_relu = nn.LeakyReLU()

        self.softmax_s2t = torch.nn.Softmax(dim =
0
)
# 按列进行归一化
        self.softmax_t2s = torch.nn.Softmax(dim =
1
)
# 按行进行归一化

        self.fc_out_es = nn.Linear(hidden_size,
1
)
# 预定义概念处理模块的输出
        self.fc_out_is = nn.Linear(hidden_size,
1
)
# 隐含概念处理模块的输出
        self.fc_out_indi = nn.Linear(hidden_size,
1
)
# 股票特质信息处理模块的输出
        self.fc_out = nn.Linear(hidden_size,
1
)
# 总输出
        self.K = K
# 在构建隐概念时,选取相似度最高的前 K 个概念

defcal_cos_similarity(self, x, y):# the 2nd dimension of x and y are the same
        xy = x.mm(torch.t(y))

        x_norm = torch.sqrt(torch.sum(x*x, dim =
1
)).reshape(
-1
,
1
)

        y_norm = torch.sqrt(torch.sum(y*y, dim =
1
)).reshape(
-1
,
1
)

        cos_similarity = xy/x_norm.mm(torch.t(y_norm))

        cos_similarity[cos_similarity != cos_similarity] =
0# 令无法进行除法的取值为0
return
 cos_similarity
# 返回维度为[股票数量,新预概念数量]
defforward(self, x, concept_matrix, market_value):
        device =
'cuda:0'if
 torch.cuda.is_available()
else'cpu'

## part1:股票特征提取
        x_hidden = x.reshape(len(x), self.d_feat,
-1
)
# [N, F, T],如变为 [股票样本数(每天)batch, 特征数=6,序列seq=60]
        x_hidden = x_hidden.permute(
0
,
2
,
1
)
# [N, T, F], 变更维度顺序:[股票样本数(每天)batch, 序列 seq, 特征数 feature]
        x_hidden, _ = self.rnn(x_hidden)
# 返回的特征维度为 [股票样本数(每天)batch, 序列 seq, 隐藏单元个数=64]
# _ 为隐含层的输出结果,一共有 2 层,维度为[num_layers=2, 股票样本数(每天)batch, 隐藏单元个数 hidden_size=64]
# 其中 _[1] 最后一层的结果等于 x_hidden[:,-1,:]
        x_hidden = x_hidden[:,
-1
, :]
# 返回每只股票的seq下的最后一行特征,维度为[股票样本数(每天)batch, 隐藏单元个数=64]

## part2: 构建预定义概念模型
# ① 初始化预定义概念表征
# 原始的市值 market_value 是 维度为股票数量的一维序列,然后将每只股票的市值数据重复 m 次,m 为预定义概念的数量
        market_value_matrix = market_value.reshape(market_value.shape[
0
],
1
).repeat(
1
, concept_matrix.shape[
1
])

# 只保留各股票所属概念下的市值
        stock_to_concept = concept_matrix * market_value_matrix
# [股票数量,m] * [股票数量,m] 点乘
# repeat() 即沿着给定的维度对 tensor 进行重复,其他维度保持不变(对应取值为1)
        stock_to_concept_sum = torch.sum(stock_to_concept,
0
).reshape(
1
,
-1
).repeat(stock_to_concept.shape[
0
],
1
)

# 只保留各股票所属概念下的市值之和
        stock_to_concept_sum = stock_to_concept_sum.mul(concept_matrix)
# 点乘
# 加 1 是为了避免分母为0的情况
        stock_to_concept_sum = stock_to_concept_sum + (torch.ones(stock_to_concept.shape[
0
], stock_to_concept.shape[
1
]).to(device))

# 基于市值得到个股股票在在所属概念下的权重
        stock_to_concept = stock_to_concept / stock_to_concept_sum

# 转置后的[概念数,股票样本数] 与 [股票样本数(每天)batch, 隐藏单元个数=64] 进行矩阵相乘
        hidden = torch.t(stock_to_concept).mm(x_hidden)
# 维度为[概念数,特征数64],即为初始的概念表征
# ② 预定义概念表征修正
        hidden = hidden[hidden.sum(
1
)!=
0
]
# 只保留预概念表征取值非零的概念,为初始预概念
# 计算的是股票表征与概念表征的相似度
        stock_to_concept = cal_cos_similarity(x_hidden, hidden)
# TODO 股票到概念那里
# 将 stock_to_concept 按列进行归一化,相同概念下所有股票的预概念占比之和等于1
        stock_to_concept = self.softmax_s2t(stock_to_concept)

# 重新计算预概念的取值,返回维度为 [剩余概念数,特征数64],返回修正后的概念特征
        hidden = torch.t(stock_to_concept).mm(x_hidden)

# ③ 从修正后的预定义概念表征中提取共有信息
# 计算股票原始特征与修正后的概念特征之间的cos相似度,维度为[股票数量,新概念数]
        concept_to_stock = cal_cos_similarity(x_hidden, hidden)

# 对cos相似度,将每只股票各自所属的概念进行归一化处理,每只股票横向求和等于1
        concept_to_stock = self.softmax_t2s(concept_to_stock)

# 利用 新的聚合权重来修正预定义概念[股票数量,新概念数] * [新概念数, 特征数64]
        e_shared_info = concept_to_stock.mm(hidden)
# 得到预定义概念相关的共有信息
        e_shared_info = self.fc_es(e_shared_info)
# 将预定义概念相关的共有信息放入全连接层
        e_shared_back = self.fc_es_back(e_shared_info)
# 维度为 [股票样本数量,特征数64]
        output_es = self.fc_es_fore(e_shared_info)
# 放入全连接层,返回预测输出 y
        output_es = self.leaky_relu(output_es)

        pred_es = self.fc_out_es(output_es).squeeze()

# ④ 从股票特征中剥离预定义概念共有信息,作为该层模型的输入
        i_shared_info = x_hidden - e_shared_back


## part3: 构建隐含概念模型
# ① 初始化隐含概念表征
        hidden = i_shared_info
# 模型输入
# ② 计算股票剩余表征与初始化的隐含概念表征之间的cos相似度,并筛选出相似度最高的 K 个概念作为隐含概念
        i_stock_to_concept = cal_cos_similarity(i_shared_info, hidden)
# 维度为[股票样本数,股票样本数]
# 上述计算的相似度矩阵为对称矩阵,对角线都为1
        dim = i_stock_to_concept.shape[
0
]

        diag = i_stock_to_concept.diagonal(
0
)
# 返回对角线
        i_stock_to_concept = i_stock_to_concept * (torch.ones(dim, dim) - torch.eye(dim)).to(device)
# 将对角线的元素设设置为0
# 生成 tensor 类型的索引 index,数据类型为 torch.long,长度为 股票数 * K
        row = torch.linspace(
0
, dim
-1
, dim).reshape([
-1
,
1
]).repeat(
1
, self.K).reshape(
1
,
-1
).long().to(device)

# 沿维度dim 返回每只股票的相似度最高的前 3 个概念对应的列索引
# 返回一个元组 (values,indices),其中indices是原始输入张量input中测元素下标
        column = torch.topk(i_stock_to_concept, self.K, dim =
1
)[
1
].reshape(
1
,
-1
)

        mask = torch.zeros([i_stock_to_concept.shape[
0
], i_stock_to_concept.shape[
1
]], device = i_stock_to_concept.device)

        mask[row, column] =
1
        i_stock_to_concept = i_stock_to_concept * mask
# 只保留得分最高的前3个概念相关的信息,其他信息为0
# 将原先属于股票自身的隐含概念信息重新加回
        i_stock_to_concept = i_stock_to_concept + torch.diag_embed((i_stock_to_concept.sum(
0
)!=
0
).float()*diag)

        hidden = torch.t(i_shared_info).mm(i_stock_to_concept).t()
# 生成新的隐含概念表征信息
        hidden = hidden[hidden.sum(
1
)!=
0
]
# 得到隐含概念的表征,去除无用概念 [新隐含概念数,特征数]
# ③ 从新的隐含概念中提取隐含概念表征
        i_concept_to_stock = cal_cos_similarity(i_shared_info, hidden)
# 计算股票剩余信息与隐含概念表征信息之间的相似度,维度W为[股票数,新隐含概念数]
        i_concept_to_stock = self.softmax_t2s(i_concept_to_stock)
# 将各股票下新的相似度进行归一化
        i_shared_info = i_concept_to_stock.mm(hidden)
# 更新得到隐含概念层的共有信息,维度为[股票数,特征数64]
        i_shared_info = self.fc_is(i_shared_info)

        i_shared_back = self.fc_is_back(i_shared_info)

        output_is = self.fc_is_fore(i_shared_info)

        output_is = self.leaky_relu(output_is)
# 得到隐含概念层的输出
        pred_is = self.fc_out_is(output_is).squeeze()

# ④ 从股票特征中进一步剔除隐含概念的共有信息
        individual_info = x_hidden - e_shared_back - i_shared_back


## part4:构建股票特质信息模型
        output_indi = individual_info

        output_indi = self.fc_indi(output_indi)

        output_indi = self.leaky_relu(output_indi)

        pred_indi = self.fc_out_indi(output_indi).squeeze()


## Spart5: 利用上述提取的3部分信息预测股价未来走势
        all_info = output_es + output_is + output_indi

        pred_all = self.fc_out(all_info).squeeze()


return
 pred_all, pred_es, pred_is, pred_indi
关于实证分析的对照组
采用中信 3 级行业作为对照组,每只股票在每个时间点只属于一个中信 3 行业,以中信3级行业作为预定义概念,平均每个交易日的预定义概念数大概在 113 个。实验组采用 SAM 主营产品分项数据,每只股票可以同时属于多个概念,虽然 HIST 会自动学习隐含概念,但相比中信 3 级行业还是能提供更多的概念信息。
关于模型的一些训练参数
训练区间为 2017 年 1 月 1 日至 2019 年 12 月 31 日,验证集为 2020 年 1 月 1 日至 2020 年 12 月 31 日,测试集为 2021 年 1 月1 日至 2022 年 3 月 23 日。使用 3090 的显卡进行训练,每个 epoch 大概需要 4 分钟,一共 200 epoch,训练时间在 13 个小时左右。训练的 label 为['Ref($close, -2)/ Ref($close, -1)- 1'] 。损失函数采用的是常规的 MSE 。成分股为沪深 300 成分股。选取 cos 相似度最高的前 K=3 个概念作为隐含概念。
补充说明
模型选用的是 Qlib 的 Alpha360 作为股票特征,但本质上 Alpha360 可以看做是过去 60 天的高开低收等 6 大行情指标在时间上做一个“拉平”,在loader 数据时会通过设置 d_feat=6 参数,将特征转换成 [batch_size, step_len, d_feat] 的结构,再输入到 GRU 模型中提取股票的时序特征。
实证结果
同样的,基于模型的预测,我们采用 qlib 内置的 Topkdropout 策略进行回测,即在最开始的时候根据预测排名顺序,买入预测值最高的 TopK 个股票,后面每个交易日,把预测最低的 N 个股票卖出,替换上预测最高的 N 个股票重新构建组合,具体的回测参数设置如下:
from
 qlib.contrib.evaluate
import
 backtest_daily

from
 qlib.contrib.evaluate
import
 risk_analysis

from
 qlib.contrib.strategy
import
 TopkDropoutStrategy


# backtest
STRATEGY_CONFIG = {

"topk"
:
50
,

"n_drop"
:
5
,

"signal"
: pred_sam[
'score'
]
# 模型返回的预测结果
}

BACKTEST_CONFIG = {

"limit_threshold"
:
0.095
,

"account"
:
100000000
,

"benchmark"
:
"SH000300"
,

"deal_price"
:
"close"
,

"open_cost"
:
0.0005
,

"close_cost"
:
0.0015
,

"min_cost"
:
5
,

}


strategy = TopkDropoutStrategy(**STRATEGY_CONFIG)

report_normal, positions_normal = backtest_daily(start_time=
'2021-01-01'
, end_time=
'2022-03-23'
, strategy=strategy)


# analysis
analysis = dict()

analysis[
"excess_return_without_cost"
] = risk_analysis(report_normal[
"return"
] - report_normal[
"bench"
])

analysis[
"excess_return_with_cost"
] = risk_analysis(report_normal[
"return"
] - report_normal[
"bench"
] - report_normal[
"cost"
])

analysis_df = pd.concat(analysis)
# type: pd.DataFrame
print(analysis_df)
最终,实验组 sam_product(以数库科技 SAM 主营产品分项为预定义概念)和对照组 citic_l3(以中信 3 级行业作为预定义概念)及沪深 300 指数(CSI300) 在测试区间(2021 年 1 月1 日至 2022 年 3 月 23 日)的累计收益曲线如下所示:
相对沪深 300 的超额收益统计如下,以数库科技 SAM 主营产品分项为预定义概念的 HIST 表现要优于以单纯的中信 3 级行业分类为预定义概念的 HIST 表现:

后续
本文实证分析在作者原先的代码基础上做过一些调整,较大的调整有:
① 预定义概念的 PIT 处理;
② 预定义概念没有选用 tushare 的概念数据;
③ 预测目标改成了 t+2 日与 t+1 日收盘价计算的收益率;
④ 在返回模型预测结果的基础上,还返回了 3 大模型逐步提取的 3 大信息:预定义概念共有信息、隐含概念共有信息、股票自身特质信息。其实除了最终的 label 预测结果以及 3 大信息外,HIST 模型还有很多信息可以做进一步挖掘,比如学到的隐含概念等,后期会进一步探就这些信息在量化上的应用场景。
此外,Qlib 最近也新增了 HIST 模型,感兴趣的小伙伴可以前往查看:
https://github.com/microsoft/qlib/blob/main/qlib/contrib/model/pytorch_hist.py
点击阅读原文,了解更多
SAM产业链数据
继续阅读
阅读原文