跳转至


课程  因子投资  机器学习  Python  Poetry  ppw  tools  programming  Numpy  Pandas  pandas  算法  hdbscan  聚类  选股  Algo  minimum  numpy  algo  FFT  模式识别  配对交易  GBDT  LightGBM  XGBoost  statistics  CDF  KS-Test  monte-carlo  VaR  回测  过拟合  algorithms  machine learning  strategy  python  sklearn  pdf  概率  数学  面试题  量化交易  策略分类  风险管理  Info  interview  career  xgboost  PCA  wavelet  时序事件归因  SHAP  Figures  Behavioral Economics  graduate  arma  garch  人物  职场  Quantopian  figure  Banz  金融行业  买方  卖方  story  量化传奇  rsi  zigzag  穹顶压力  因子  ESG  因子策略  投资  策略  pe  ORB  Xgboost  Alligator  Indicator  factor  alpha101  alpha  技术指标  wave  quant  algorithm  pearson  spearman  tushare  因子分析  Alphalens  涨停板  herd-behaviour  momentum  因子评估  review  SMC  聪明钱  trade  history  indicators  zscore  波动率  强化学习  顶背离  freshman  resources  others  AI  DeepSeek  network  量子计算  金融交易  IBM  weekly  LLT  backtest  backtrader  研报  papers  UBL  quantlib  jupyter-notebook  scikit-learn  pypinyin  qmt  xtquant  blog  static-site  duckdb  工具  colors  free resources  barra  world quant  Alpha  openbb  数据  risk-management  llm  prompt  CANSLIM  Augment  arsenal  copilot  vscode  code  量化数据存储  hdf5  h5py  cursor  augment  trae  Jupyter  jupysql  pyarrow  parquet  数据源  quantstats  实盘  clickhouse  notebook  redis  remote-agent  AI-tools  Moonshot  回测,研报,tushare 

factor&strategy »

WorldQuant? Word Count!


如果你去商场逛,你会发现,销量最好的店和最好的商品总是占据人气中心。对股票来说也是一样,被新闻和社交媒体频频提起的个股,往往更容易获得更大的成交量。

如果一支个股获得了人气,那它的成交量一定很大,两者之间有强相关关系。但是,成交量落后于人气指标。当一支个股成交量开始放量时,有可能已经追不上了(涨停)。如果我们能提前发现人气指标,就有可能获得提前介入的时机。

那么具体怎么操作(建模)呢?

我们首先讲解一点信息检索的知识,然后介绍如何运用统计学和信息检索的知识,来把上述问题模型化。

TF-IDF

TF 是 Term Frequency 的意思。它是对一篇文档中,某个词语共出现了多少次的一个统计。IDF 则是 Inverse Document Frequency 的意思,大致来说,如果一个词在越多的文档中出现,那么,它携带的信息量就越少。

比如,我们几乎每句话都会用到『的、地、得』,这样的词几乎在每个语境(文档)中都会出现,因此就不携带信息量。新闻业常讲的一句话,狗咬人不是新闻,人咬狗才是新闻,本质上也是前者太常出现,所以就不携带信息量了。

最早发明 TF-IDF 的人应该是康奈尔大学的杰拉德·索尔顿(康奈尔大学的计算机很强)和英国的计算机科学家卡伦·琼斯。到今天,美国计算机协会(ACM)还会每三年颁发一次杰拉德·索尔顿奖,以表彰信息检索领域的突出贡献者。

TF-IDF 的构建过程如下:

假如我们有 3 篇文档,依次是:

  1. 苹果 橙子 香蕉
  2. 苹果 香蕉 香蕉
  3. 橙子 香蕉 梨

看上去不像文档,不过这确实是文档的最简化的形式--就是一堆词的组合(在 TF-IDF 时代,还无法处理词的顺序)。在第 1 篇文档中,橙子、香蕉和苹果词各出现 1 次,每个词的 TF 都记为 1,我们得到:

1
2
3
4
5
TF_1 = {
    '苹果': 1/3,
    '香蕉': 1/3,
    '橙子': 1/3,
}

在第二篇文档中,苹果出现 1 次,香蕉出现 2 次,橙子和梨都没有出现。于是得到:

1
2
3
4
TF_2 = {
    '苹果': 1/3,
    '香蕉': 2/3,
}

第三篇文档中,TF 的计算依次类推。

IDF 实际上是每个词的信息权重,它的计算按以下公式进行:

\[ \text{IDF}(t) = \log \left( \frac{N + 1}{1 + \text{DF}(t)} \right) + 1 \]
  1. DF:每个词在多少篇文档中出现了。
  2. N 是文档总数,在这里共有 3 篇文档,所以\(N=3\)
  3. 公式中,分子与分母都额外加 1,一方面是为了避免 0 作为分母,因为\(DF(t)\)总是正的,另外也是一种 L1 正则化。这是 sklearn 中的做法。

这样我们可以算出所有词的 IDF:

\[ 苹果 = 橙子 = \log \left( \frac{4}{2+1} \right) + 1 = 1.2876 \]
\[ 梨 = \log \left( \frac{4}{1+1} \right) + 1 = 1.6931 \]

因为梨这个词很少出现,所以,一旦它出现,就是人咬狗事件,所以它的信息权重就大。而香蕉则是一个烂大街的词,在 3 篇文档中都有出现过,所有我们惩罚它,让它的信息权重为负:

\[ 香蕉 = \log \left( \frac{4}{3+1} \right) + 1 = 1 \]

最后,我们得到每个词的 TF-IDF:

\[ TF-IDF=TF\times{IDF} \]

这样我们以所有可能的词为列,每篇文档中,出现的词的 TF-IDF 为值,就得到如下稀疏矩阵:

苹果 香蕉 橙子
文档 1 1.2876/3 1/3 1.2876/3 0
文档 2 1.2876/3 1/3 * 2 0 0
文档 3 0 1/3 1.2876/3 1.6931/3

在 sklearn 中,最后还会对每个词的 TF-IDF 进行 L2 归一化。这里就不手动计算了。

我们把每一行称为文档的向量,它代表了文档的特征。如果两篇文档的向量完全相同,那么它们很可能实际上是同一篇文章(近似。因为,即使完全使用同样的词和词频,也可以写出不同的文章。

比如,『可以清心也』这几个字,可以排列成『以清心也可』,也可以排列成『心也可以清』,或者『清心也可以』,都是语句通顺的文章。

在实际应用中,我们可以使用 sklearn 的 TfidfVectorizer 来实现 TF-IDF 的计算:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
import pandas as pd
def jieba_tokenizer(text):
    return list(jieba.cut(text))

d1 = "苹果橙子香蕉"
d2 = "苹果香蕉香蕉"
d3 = "橙子香蕉梨"

vectorizer = TfidfVectorizer(tokenizer = jieba_tokenizer)
matrix = vectorizer.fit_transform([d1, d2, d3])

df = pd.DataFrame(matrix.toarray(), columns = vectorizer.get_feature_names_out())
df
橙子 苹果 香蕉
0 0.000000 0.619805 0.619805 0.481334
1 0.000000 0.000000 0.541343 0.840802
2 0.720333 0.547832 0.000000 0.425441

结果与我们手工计算的有所不同,是因为我们在手工计算时,省略了计算量相对较大的L2归一化。

从上面的例子可以看出,TF-IDF 是用来提取文章的特征向量的。有了这个特征向量,就可以通过计算余弦距离,来比较两篇文档是否相似。这可以用在论文查重、信息检索、比较文学和像今日头条这样的图文推荐应用上。

比如,要证明曹雪芹只写了《红楼梦》的前87回,就可以把前87回和后面的文本分别计算TF-IDF,然后计算余弦距离,此时就能看出差别了。

又比如,如果用TF-IDF分析琼瑶的作品,你会发现,如果去掉一些最重要的名词之后,许多她的文章的相似度会比较高。下面是对《还珠格格》分析后,得到的最重要的词汇及其TF-IDF:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
紫薇: 0.0876
皇帝: 0.0754
尔康: 0.0692
皇后: 0.0621
五阿哥: 0.0589
容嬷嬷: 0.0573
小燕子: 0.0556
四阿哥: 0.0548
福晋: 0.0532
金锁: 0.0519

跟你的印象是否一致?但是,TF-IDF的分析方法,在量化交易中有何作用,目前还没有例证。

讲到这里,关于 TF-IDF 在量化交易中的作用,基本上就讲完了。因为,接下来,我们要跳出 TF-IDF 的窠臼,自己构建因子了!

word-count 因子

根据 TF-IDF 的思想,这里提出一个 word-count 因子。它的构建方法是,通过 tushare 获取每天的新闻信息,用 jieba 进行分词,统计每天上市公司名称出现的次数。这是 TF 部分。

在 IDF 构建部分,我们做法与经典方法不一样,但更简单、更适合量化场景。这个方法就是,我们取每个词 TF 的移动平均做为 IDF。这个IDF就构成了每个词的基准噪声,一旦某天某个词的频率显著大于基准噪声,就说明该公司上新闻了!

最后,我们把当天某个词的出现频率除以它的移动平均的读数作为因子。显然,这个数值越大,它携带的信息量也越大,表明该词(也就是该公司)最近在新闻上被频频提起。

获取新闻文本数据

我们可以通过 tushare 的 news 接口来获取新闻。 这个方法是:

1
2
3
4
news = pro.news(src='sina', 
                date=start,
                end_date=end,
)

我们把获取的新闻数据先保存到本地,以免后面还可能进行其它挖掘:

Attention

即使是高级账号,tushare对每天能调用的新闻条数也是有限制的。所以,请在您本地安装tushare, 申请高级账号来运行以下代码,不要在Quantide Reseach环境中运行!!

运行代码前,需要指定data_home变量,以便将news保存到本地。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
```python
def retry_fetch(start, end, offset):
    i = 1
    while True:
        try:
            df =pro.news(**{
                "start_date": start,
                "end_date": end,
                "src": "sina",
                "limit": 1000,
                "offset": offset
            }, fields=[
                "datetime",
                "content",
                "title",
                "channels",
                "score"])
            return df
        except Exception as e:
            print(f"fetch_new failed, retry after {i} hours")
            time.sleep(i * 3600)
            i = min(i*2, 10)

def fetch_news(start, end):
    for i in range(1000):
        offset = i * 1000
        df = retry_fetch(start, end, offset)

        df_start = arrow.get(df.iloc[0]["datetime"]).format("YYYYMMDD_HHmmss")
        df_end = arrow.get(df.iloc[-1]["datetime"]).format("YYYYMMDD_HHmmss")
        df.to_csv(os.path.join(data_home, f"{df_start}_{df_end}.news.csv"))
        if len(df) == 0:
            break

        # tushare 对新闻接口调用次数及单次返回的新闻条数都有限制
        time.sleep(3.5 * 60)
```

在统计新闻中上市公司出现的词频时,我们需要先给 jieba 增加自定义词典,以免出现分词错误。比如,如果不添加关键词『万科 A』,那么它一定会被 jieba 分解为万科和 A 两个词。

增加自定义词典的代码如下:

1
2
3
4
5
6
7
8
def init():
    # get_stock_list 是自定义的函数,用于获取股票列表。在 quantide research 环境可用
    stocks = get_stock_list(datetime.date(2024,11,1), code_only=False)
    stocks = set(stocks.name)
    for name in stocks:
        jieba.add_word(name)

    return stocks

这里得到的证券列表,后面还要使用,所以作为函数返回值。

接下来,就是统计词频了:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
def count_words(news, stocks)->pd.DataFrame:
    data = []
    for dt, content, _ in news.to_records(index=False):
        words = jieba.cut(content)
        word_counts = Counter(words)
        for word, count in word_counts.items():
            if word in stocks:
                data.append((dt, word, count))
    df = pd.DataFrame(data, columns=['date', 'word', 'count'])
    df["date"] = pd.to_datetime(df['date'])
    df.set_index('date', inplace=True)

    return df

tushare 返回的数据共有三列,其中 date, content 是我们关心的字段。公司名词频就从 content 中提取。

然后我们对所有已下载的新闻进行分析,统计每日词频和移动均值:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
def count_words_in_files(stocks, ma_groups=None):
    ma_groups = ma_groups or [30, 60, 250]
    # 获取指定日期范围内的数据
    results = []

    files = glob.glob(os.path.join(data_home, "*.news.csv"))
    for file in files:
        news = pd.read_csv(file, index_col=0)

        df = count_words(news, stocks)
        results.append(df)

    df = pd.concat(results)
    df = df.sort_index()
    df = df.groupby("word").resample('D').sum()
    df.drop("word", axis=1, inplace=True)
    df = df.swaplevel()
    unstacked = df.unstack(level="word").fillna(0)
    for win in ma_groups:
        df[f"ma_{win}"] = unstacked.rolling(window=win).mean().stack()

    return df

最后,完整的代码如下:

Attention

以下代码需要先将新闻数据存在放在变量data_home指定的目录,否则会报错。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
import os
import glob
import jieba
from collections import Counter
import time

data_home = "/data/rw/news"
def init():
    stocks = get_stock_list(datetime.date(2024,12,2), code_only=False)
    stocks = set(stocks.name)
    for name in stocks:
        jieba.add_word(name)

    return stocks

def count_words(news, stocks)->pd.DataFrame:
    data = []
    for dt, content, *_ in news.to_records(index=False):
        if content is None or not isinstance(content, str):
            continue

        try:
            words = jieba.cut(content)
            word_counts = Counter(words)
            for word, count in word_counts.items():
                if word in stocks:
                    data.append((dt, word, count))
        except Exception as e:
            print(dt, content)
    df = pd.DataFrame(data, columns=['date', 'word', 'count'])
    df["date"] = pd.to_datetime(df['date'])
    df.set_index('date', inplace=True)

    return df

def count_words_in_files(stocks, ma_groups=None):
    ma_groups = ma_groups or [30, 60, 250]
    # 获取指定日期范围内的数据
    results = []

    files = glob.glob(os.path.join(data_home, "*.news.csv"))
    for file in files:
        news = pd.read_csv(file, index_col=0)

        df = count_words(news, stocks)
        results.append(df)

    df = pd.concat(results)
    df = df.sort_index()
    df = df.groupby("word").resample('D').sum()
    df.drop("word", axis=1, inplace=True)
    df = df.swaplevel()
    unstacked = df.unstack(level="word").fillna(0)
    for win in ma_groups:
        df[f"ma_{win}"] = unstacked.rolling(window=win).mean().stack()

    return df.sort_index(), unstacked.sort_index()

stocks = init()
factor, raw = count_words_in_files(stocks)
factor.tail(20)

这里计算的仍然是原始数据。最终因子化要通过 factor["count"]/factor["ma_30"] 来计算并执行 rank,这里的 ma_30 可以替换为 ma_60, ma_250 等。

跟以往的文章不同,这一次我们没有直接得到好的结果。我们的研究其实多数时候都是寂寞的等待,然后挑一些成功的例子发表而已。毕竟,发表不成功的例子,估计意义不大(很少人看)。

但是这一篇有所不同。我们没有得到结果,主要是因为数据还在积累中。这篇文章从草稿到现在,已经半个月了,但是我仍然没能获取到 1 年以上的新闻数据,所以,无法准确得到每家公司的『基底噪声』,从而也就无法得到每家公司最新的信息熵。但要获得 1 年以上的数据,大概还要一个月左右的时间。所以,先把已经获得的成果发出来。

尽管没有直接的结果,但是我们的研究演示了对文本数据如何建模的一个方法,也演示了如何使用TF-IDF,并且在因子化方向也比较有新意,希望能对读者有所启发。

我们已经抓取的新闻数据截止到今年的 8 月 20 日,每天都会往前追赶大约 10 天左右。这些数据和上述代码,可以在我们的 quantide research 平台上获取和运行。加入星球,即可获得平台账号。