跳转至


课程  因子投资  机器学习  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 

算法 »

谁压垮了这个基站?用XGBoost如何进行时序事件归因


当一个系统(比如交易系统、服务器基站)意外宕机,我们能否像侦探一样,从海量监控数据中抽丝剥茧,找出导致事故的“罪魁祸首”?这是我们的一位学员最近提出来的一个问题。

本文将通过一个“基站宕机”的真实案例,演示如何利用机器学习模型XGBoost,对时序事件进行归因分析。这个问题看似与量化无关,但其核心思想——从多个潜在因素(时序因子)中定位关键驱动因素——与量化中的多因子模型如出一辙。

我们的“案发现场”数据如下:

已脱敏的监控数据

其中,列Y是我们的目标:1代表基站宕机,0代表正常。其他A到L列是各种监控指标的布尔值。在8月31日,基站不幸宕机。面对一堆01,我们需要回答:

  • 是哪些因素的组合导致了故障?
  • 是单次异常还是连续多天的异常更致命?
  • 我们能否构建一个模型,来预测未来的风险?

初看数据,我们发现一些看似矛盾的记录,这表明故障可能是概率性的。这恰好是机器学习大显身手的领域,特别是被誉为“表格数据之王”的XGBoost。

定义问题:让XGBoost看懂时间

要让XGBoost理解“过去N天”的数据,我们不能直接将一个时间窗口内的数据矩阵扔给它。XGBoost处理的是表格数据,每一行代表一个独立的样本,每一列是一个特征。

因此,我们必须将时间序列“压平”(Flatten),将过去N天的所有监控值,转换为一个长长的一维特征向量。这个过程称为特征工程,是连接时序数据和机器学习模型的关键桥梁。

具体来说,对于第i天的样本,它的特征向量\(X_i\)将由过去n天所有监控指标AL的值拼接而成:

\[ \begin{equation} X_i = [A_{i-n}, ..., A_{i-1}, B_{i-n}, ..., B_{i-1}, ..., L_{i-n}, ..., L_{i-1}] \end{equation} \]

下面的Python代码实现了这个转换过程:

 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
def expand_data(df: pd.DataFrame, n: int) -> pd.DataFrame:
    """
    Expands a time-series dataframe into a feature matrix with n lag features.

    Args:
        df: The input dataframe with a time index and feature columns.
        n: The number of time steps to look back.

    Returns:
        A new dataframe where each row contains the original data plus
        lagged features from the past n steps.
    """
    if n <= 0:
        base_cols = [c for c in ['start', 'y'] if c in df.columns]
        return df[base_cols].copy()

    # Identify feature columns (assumed to be uppercase letters)
    cols = list(df.columns)
    if 'A' in cols:
        start_idx = cols.index('A')
        feat_cols = cols[start_idx:]
    else:
        feat_candidates = {c for c in cols if c.isalpha() and c.upper() == c}
        feat_cols = [c for c in cols if c in feat_candidates]

    base_cols = [c for c in ['start', 'y'] if c in df.columns]

    # Build lag features using vectorized shift for efficiency
    out = {}
    for col in feat_cols:
        s = df[col]
        for k in range(1, n + 1):
            out[f'{col}_{k}'] = s.shift(k)

    features = pd.DataFrame(out, index=df.index)
    res = pd.concat([df[base_cols], features], axis=1)

    # Trim rows with NaN values resulting from the shift operation
    trimmed = res.iloc[n:].reset_index(drop=True)
    return trimmed

df = pd.read_csv(data_home/"ro/drama.csv")
expanded_df = expand_data(df, 5)
expanded_df.head()

n=3为例,转换后的数据形态如下,每一行都包含了当天(y)以及过去3天所有指标(A_1L_3)的信息:

展开后的特征矩阵

训练:请XGBoost出马

数据准备就绪后,我们就可以请出主角——XGBoost。训练过程本身非常直接。我们使用XGBClassifier,并选择适合二分类问题的目标函数(如binary:logistic)和评估指标(如logloss)。

 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
import numpy as np
import pandas as pd
from xgboost import XGBClassifier

def train_xgb_and_rank_features(
    df: pd.DataFrame,
    n_estimators: int = 400,
    max_depth: int = 5,
    learning_rate: float = 0.05,
    **kwargs
):
    """
    Trains an XGBoost classifier on the given dataframe.

    Args:
        df: The feature matrix.
        n_estimators, max_depth, learning_rate: XGBoost hyperparameters.

    Returns:
        A trained XGBoost classifier instance.
    """
    # 1. Separate features (X) and target (y)
    feat_cols = list(set(df.columns) - set(["y", "start"]))
    X = df[feat_cols].apply(pd.to_numeric, errors="coerce")
    y = pd.to_numeric(df["y"], errors="ignore")

    # 2. Clean data by dropping rows with any NaN values
    mask = X.notna().all(axis=1) & pd.notna(y)
    X, y = X.loc[mask], y.loc[mask]

    # 3. Set up XGBoost parameters
    params = dict(
        n_estimators=n_estimators,
        max_depth=max_depth,
        learning_rate=learning_rate,
        objective="binary:logistic",
        eval_metric="logloss",
        n_jobs=0,
        tree_method="hist",
        random_state=42,
        **kwargs
    )

    clf = XGBClassifier(**params)
    clf.fit(X, y)

    return clf

clf = train_xgb_and_rank_features(expanded_df)
clf

训练完成后,模型就学习到了各个特征与“宕机”之间的复杂关系。接下来,就是揭开谜底的时刻。

归因分析:全局与局部

全局重要性:谁是关键嫌疑人?

首先,我们可以通过模型的get_booster().get_score()方法,查看每个特征的全局重要性(gain)。这告诉我们,在模型看来,哪些特征对区分“正常”与“宕机”的贡献最大。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
def get_feature_importance(clf, df):
    """Extracts and sorts feature importance from a trained XGBoost model."""
    booster = clf.get_booster()
    score = booster.get_score(importance_type="gain")  

    feat_columns = list(set(df.columns) - set(["y", "start"]))

    imp_df = pd.DataFrame([(name, score.get(name, 0.0)) for name in feat_columns],
                                columns=["feature", "importance"])
    imp_df = imp_df.sort_values("importance", ascending=False).reset_index(drop=True)

    return imp_df

get_feature_importance(clf, expanded_df).head()

在我们的案例中,结果可能显示K_2(即两天前的K指标)的重要性远超其他特征,成为头号“嫌疑人”。同时,它还展示了K_1(即一天前的K指标)也会对此有所贡献。

为了进一步验证它的“罪行”,我们可以使用部分依赖图(Partial Dependence Plot, PDP)进行一次“思想实验”。PDP的逻辑是:假设我们能控制变量,当其他所有特征保持不变时,仅仅改变特征K_1的取值(从0到1),观察模型的平均预测概率会如何变化。这能帮助我们隔离并理解单个特征对最终结果的纯粹影响。

1
2
3
4
5
6
from sklearn.inspection import partial_dependence

feat_columns = list(set(expanded_df.columns) - set(["y", "start"]))
X = expanded_df[feat_columns]
pd_results = partial_dependence(clf, X, features=["K_1", "K_2"])
pd_results

我们将这个结果绘制成为热力图,再来讨论:

K_1 和 K_2 交互效应热力图

结果非常有意思!

  1. 如果前天异常,昨天正常,此时风险最高,达到了近40%。这比较反直觉。
  2. 强烈的交互效应: K_1 (昨天状态)的影响完全取决于 K_2 (前天状态)。
  3. 如果前天正常 ( K_2=0 ),那么昨天出现异常 ( K_1=1 ) 反而让风险从 17.4% 下降 到 9.2%。
  4. 如果前天异常 ( K_2=1 ),那么昨天继续异常 ( K_1=1 ) 会让风险从 39.7% 下降 到 24.1%。
  5. 这说明,一个“持续的”异常信号反而没有一个“刚刚恢复的”异常信号危险。
  6. 最安全的情况: 当 前天正常,昨天出现异常 ( K_2=0, K_1=1 ) 时,风险概率反而最低,仅为 9.2% 。

Attention

有必要强调一下,我只拿到了19条记录。出现这么奇怪的结果也不奇怪。

使用 SHAP 进行局部归因:重回“案发现场”

全局分析告诉我们K_2很重要,K_1也有一定的贡献,但这还不够。要进行“事件归因”,我们更关心的是:在8月31日基站挂掉那一天,各个因素到底扮演了什么角色?是哪个或哪些特征的取值,最终导致了“死亡”?

这就是局部可解释性(Local Explainability)要解决的问题,而 SHAP (SHapley Additive exPlanations) 是解决这个问题的最佳工具。它可以清晰地展示出对于单次预测,每个特征值是“推手”还是“拉手”,以及各自贡献了多少。

让我们聚焦到“案发”当天的样本,用 SHAP 来进行一次“法医鉴定”。

 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
import shap

def explain(clf, Xi):
    shap.initjs()

    # 1. 创建解释器
    # TreeExplainer是针对树模型(如XGBoost)优化过的高效解释器
    explainer = shap.TreeExplainer(clf)

    # 2. 计算SHAP值
    shap_values = explainer.shap_values(Xi)

    # 3. 可视化局部归因 - Waterfall图
    shap.waterfall_plot(
        shap.Explanation(
            values=shap_values[0],
            base_values=explainer.expected_value,
            data=Xi.iloc[0],
            feature_names=X.columns.tolist(),
        )
    )


# 倒数第二天,基站挂了
Xi = expanded_df[feat_columns][-2:-1]
explain(clf, Xi)

输出的图被称为SHAP Waterfall 图。如何解读它呢?

  • E[f(X)] 是模型的基准值,代表所有样本的平均预测概率。但这里输出的都是对数几率,需要通过公式\(p = \frac{1}{1 + e^{-E[f(X)]}}\) 转换为概率。当前值转换的结果大致是21%,也就是从已知数据来看,基站挂掉的概率大概是21%。
  • 图表从基准值开始,自下而上展示每个特征的贡献(SHAP值)。
  • 红色条块代表该特征的当前取值推高了最终的预测概率(风险因素)。
  • 蓝色条块代表该特征的当前取值拉低了最终的预测概率(安全因素)。
  • 所有特征的贡献累加,将预测从基准值 E[f(X)] 推向了该样本的最终预测值 f(x)(右上)。

通过左图,我们可以一目了然地看到,在宕机那天,K_1=1 是导致预测概率大幅上升的主要元凶。这为我们的归因分析提供了最直接、最微观的证据。

现在,我们可以输出 y = 1的那些天,究竟是哪些因素导致了基站挂掉:只要找出 shap_values 中那些大于0的列,把序号转换成对应的天,即可输出:

故障日期 故障原因及发生日期 说明
8月31日 8月30日,因素 k 即 K_1 = 1
8月29日,因素 k 即 K_2 = 1

模型思考:XGBoost与时序的正确关系

我们把时序数据“压平”喂给XGBoost,这种操作是否总是有效?

答案是:这取决于我们面对的是哪种“时序数据”

XGBoost本身无法直接学习时间序列中的自相关性、趋势或季节性。它看待每个特征(比如K_1, K_2)都是独立的,无法理解K_1K_2的“昨天”。因此,如果数据的核心在于时间上的连续动态(比如股价的连续波动),这种“压平”操作会丢失关键信息,模型效果将大打折扣。

然而,在我们的基站案例中,这种方法是有效的。为什么?因为这里的“时序”更多是事件日志的性质。我们关心的是在故障发生前,某个特定事件(如K=1)是否出现过,以及它出现的时间点(昨天、前天)。每个滞后特征(K_1, K_2)本身就是一个独立的事件标志。模型要学习的不是K这个指标的动态变化,而是K_1=1这个事件与故障y=1之间的关联强度。

这引出了一个核心观点:XGBoost无法替代时序分析,但可以成为强大特征工程的“裁判”

在量化交易中,我们不会直接将过去10天的收盘价作为10个特征输入模型。我们会先通过特征工程,将这些时序信息提炼成有经济学或统计学意义的指标,比如移动平均线(MA)、相对强弱指数(RSI)、动量等。这些指标本身就是对时间序列的高度浓缩和降维。

然后,我们将这些“因子”作为特征,送入XGBoost进行打分和组合。此时,XGBoost判断的是你的特征工程是否有效,而非直接处理原始时序。如果你的特征工程做得好,XGBoost将非常高效地告诉你哪些因子在当前市场环境下最重要。这正是我们在《因子挖掘与机器学习策略》课程中深入揭示和反复演示的核心思想。