Skip to content

关于中性化实现的疑问 #3

Description

@absatu

请教一下,关于中性化实现,你设计了17种方法,应该是覆盖了从线性模型到机器学习模型的场景。

疑问1:中性化一般用哑变量回归来做吧?为什么会有17种完全不同的实现呢?能否简单讲讲?
疑问2:针对LGB模型,你建议用哪种中性化方法比较合适?
疑问3:怎么校验中性化后的数据准确性?

对于疑问3,我对比了3个方案,均没有得到完全相同的中性化后数据,甚至量纲级别都不同

1. 聚宽平台提供的中性化函数

代码:

from jqdata import *
import pandas as pd
import numpy as np
from jqfactor import neutralize

def initialize(context):
    # 生成数据
    data = pd.DataFrame({
    '600036.XSHG': [1.5, 801780, 8111.3009],   # 招商银行,2026-03-31 close:39.32,流通8111.3009亿, sw_l1: 801780
    '600519.XSHG': [2.3, 801120,18157.9181],   # 茅台,2026-03-31 close:1450,流通18157.9181亿, sw_l1: 801120
    '300059.XSHE': [0.8, 801790, 2519.5017]    # 东方财富,2026-03-31 close:18.89,流通2519.5017亿, sw_l1: 801790
        }, index=['feature_a', 'industry', 'circulating_market_cap'])

    print(f"before neutralize: \n{data}")
    
    # 取 feature_a
    feature_df = data.loc[['feature_a', 'industry', 'circulating_market_cap']]
    
    # neutralize 只传 feature_a(DataFrame),axis=1
    # how 直接传列名列表(中性化因子),不要传 dict
    df = neutralize(
        feature_df.loc[['feature_a']],       # 只传 feature_a
        how=['sw_l1', 'circulating_market_cap'],
        date='2026-03-31',
        axis=1
    )
                            
    print(f"after neutralize: \n{df}")

日志:

2019-01-01 00:00:00 - INFO  - before neutralize: 
                        600036.XSHG  600519.XSHG  300059.XSHE
feature_a                    1.5000       2.3000       0.8000
industry                801780.0000  801120.0000  801790.0000
circulating_market_cap    8111.3009   18157.9181    2519.5017

2019-01-01 00:00:00 - INFO  - after neutralize: 
            600036.XSHG   600519.XSHG   300059.XSHE
feature_a  8.881784e-16  1.776357e-15  1.110223e-16

2. 你提供的中性化函数

代码:

def compare():
    data = pd.DataFrame(
        [
            {
                "datetime": "2026-03-31",
                "symbol": "600036.XSHG",
                "close": 39.32,
                "circulating_market_cap": 8111.3009,
                "feature_a": 1.5,
                "industry": 801780,
            },
            {
                "datetime": "2026-03-31",
                "symbol": "600519.XSHG",
                "close": 1450,
                "circulating_market_cap": 18157.9181,
                "feature_a": 2.3,
                "industry": 801120,
            },
            {
                "datetime": "2026-03-31",
                "symbol": "300059.XSHE",
                "close": 18.89,
                "circulating_market_cap": 2519.5017,
                "feature_a": 0.8,
                "industry": 801790,
            },
        ]
    )

    purifier = AlphaPurifier(base_df=data, factor_name="feature_a", trade_date_col="datetime", symbol_col="symbol")

    # 默认 multiOLS,按行业和市值中性化
    method = ["multiOLS", "lasso", "ridge", "elasticnet", "polynomial", "kernelridge", "huber", "rank", "theilsen", "randomforest", "GBDT", "ICA", "PCA", "bayesianridge", "partialcorrelation"]
    for m in method:
        try:
            result = purifier.neutralize(m, ["industry", "circulating_market_cap"]).to_result()
            print(f"{m}: \n{result}") 
        except Exception as e:
            print(f"{m} failed with error: {e}")

日志(其中有一些方法调用报错):

multiOLS: 
     datetime       symbol    close  circulating_market_cap     feature_a  industry
0  2026-03-31  600036.XSHG    39.32               8111.3009  2.273737e-13    801780
1  2026-03-31  600519.XSHG  1450.00              18157.9181  2.726708e-13    801120
2  2026-03-31  300059.XSHE    18.89               2519.5017  2.728928e-13    801790
lasso: 
     datetime       symbol    close  circulating_market_cap     feature_a  industry
0  2026-03-31  600036.XSHG    39.32               8111.3009 -3.027208e-14    801780
1  2026-03-31  600519.XSHG  1450.00              18157.9181  1.502502e-14    801120
2  2026-03-31  300059.XSHE    18.89               2519.5017  1.524706e-14    801790
ridge: 
     datetime       symbol    close  circulating_market_cap     feature_a  industry
0  2026-03-31  600036.XSHG    39.32               8111.3009 -8.637504e-19    801780
1  2026-03-31  600519.XSHG  1450.00              18157.9181  3.083064e-19    801120
2  2026-03-31  300059.XSHE    18.89               2519.5017  5.554439e-19    801790
elasticnet: 
     datetime       symbol    close  circulating_market_cap     feature_a  industry
0  2026-03-31  600036.XSHG    39.32               8111.3009 -8.637504e-19    801780
1  2026-03-31  600519.XSHG  1450.00              18157.9181  3.083064e-19    801120
2  2026-03-31  300059.XSHE    18.89               2519.5017  5.554439e-19    801790
polynomial: 
     datetime       symbol    close  circulating_market_cap     feature_a  industry
0  2026-03-31  600036.XSHG    39.32               8111.3009 -8.185202e-33    801780
1  2026-03-31  600519.XSHG  1450.00              18157.9181  7.703720e-34    801120
2  2026-03-31  300059.XSHE    18.89               2519.5017  4.911121e-33    801790
5.20s - Error patching args (debugger not attached to subprocess).
Traceback (most recent call last):
  File "/Users/abab/.vscode/extensions/ms-python.debugpy-2025.18.0-darwin-x64/bundled/libs/debugpy/_vendored/pydevd/_pydev_bundle/pydev_monkey.py", line 541, in patch_args
    new_args.append(_get_python_c_args(host, port, code, unquoted_args, SetupHolder.setup))
                    ~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/abab/.vscode/extensions/ms-python.debugpy-2025.18.0-darwin-x64/bundled/libs/debugpy/_vendored/pydevd/_pydev_bundle/pydev_monkey.py", line 193, in _get_python_c_args
    if "__future__" in code:
       ^^^^^^^^^^^^^^^^^^^^
TypeError: a bytes-like object is required, not 'str'
No module named joblib.externals.loky.backend.popen_loky_posix
No module named joblib.externals.loky.backend.popen_loky_posix
No module named joblib.externals.loky.backend.popen_loky_posix
No module named joblib.externals.loky.backend.popen_loky_posix
No module named joblib.externals.loky.backend.popen_loky_posix
No module named joblib.externals.loky.backend.popen_loky_posix
No module named joblib.externals.loky.backend.popen_loky_posix
No module named joblib.externals.loky.backend.popen_loky_posix
kernelridge failed with error: A worker process managed by the executor was unexpectedly terminated. This could be caused by a segmentation fault while calling the function or by an excessive memory usage causing the Operating System to kill the worker.

The exit codes of the workers are {EXIT(0)}
Detailed tracebacks of the workers should have been printed to stderr in the executor process if faulthandler was not disabled.
No module named joblib.externals.loky.backend.popen_loky_posix
No module named joblib.externals.loky.backend.popen_loky_posix
No module named joblib.externals.loky.backend.popen_loky_posix
No module named joblib.externals.loky.backend.popen_loky_posix
No module named joblib.externals.loky.backend.popen_loky_posix
No module named joblib.externals.loky.backend.popen_loky_posix
No module named joblib.externals.loky.backend.popen_loky_posix
No module named joblib.externals.loky.backend.popen_loky_posix
huber failed with error: A worker process managed by the executor was unexpectedly terminated. This could be caused by a segmentation fault while calling the function or by an excessive memory usage causing the Operating System to kill the worker.

The exit codes of the workers are {EXIT(0)}
Detailed tracebacks of the workers should have been printed to stderr in the executor process if faulthandler was not disabled.
No module named joblib.externals.loky.backend.popen_loky_posix
No module named joblib.externals.loky.backend.popen_loky_posix
No module named joblib.externals.loky.backend.popen_loky_posix
No module named joblib.externals.loky.backend.popen_loky_posix
No module named joblib.externals.loky.backend.popen_loky_posix
No module named joblib.externals.loky.backend.popen_loky_posix
No module named joblib.externals.loky.backend.popen_loky_posix
No module named joblib.externals.loky.backend.popen_loky_posix
rank failed with error: A worker process managed by the executor was unexpectedly terminated. This could be caused by a segmentation fault while calling the function or by an excessive memory usage causing the Operating System to kill the worker.

The exit codes of the workers are {EXIT(0)}
Detailed tracebacks of the workers should have been printed to stderr in the executor process if faulthandler was not disabled.
No module named joblib.externals.loky.backend.popen_loky_posix
No module named joblib.externals.loky.backend.popen_loky_posix
No module named joblib.externals.loky.backend.popen_loky_posix
No module named joblib.externals.loky.backend.popen_loky_posix
No module named joblib.externals.loky.backend.popen_loky_posix
No module named joblib.externals.loky.backend.popen_loky_posix
No module named joblib.externals.loky.backend.popen_loky_posix
No module named joblib.externals.loky.backend.popen_loky_posix
theilsen failed with error: A worker process managed by the executor was unexpectedly terminated. This could be caused by a segmentation fault while calling the function or by an excessive memory usage causing the Operating System to kill the worker.

The exit codes of the workers are {EXIT(0)}
Detailed tracebacks of the workers should have been printed to stderr in the executor process if faulthandler was not disabled.
randomforest: 
     datetime       symbol    close  circulating_market_cap     feature_a  industry
0  2026-03-31  600036.XSHG    39.32               8111.3009 -7.203620e-33    801780
1  2026-03-31  600519.XSHG  1450.00              18157.9181  1.751954e-33    801120
2  2026-03-31  300059.XSHE    18.89               2519.5017  5.892704e-33    801790
GBDT: 
     datetime       symbol    close  circulating_market_cap     feature_a  industry
0  2026-03-31  600036.XSHG    39.32               8111.3009 -7.350633e-33    801780
1  2026-03-31  600519.XSHG  1450.00              18157.9181  1.604942e-33    801120
2  2026-03-31  300059.XSHE    18.89               2519.5017  5.745691e-33    801790
No module named joblib.externals.loky.backend.popen_loky_posix
No module named joblib.externals.loky.backend.popen_loky_posix
No module named joblib.externals.loky.backend.popen_loky_posix
No module named joblib.externals.loky.backend.popen_loky_posix
No module named joblib.externals.loky.backend.popen_loky_posix
No module named joblib.externals.loky.backend.popen_loky_posix
No module named joblib.externals.loky.backend.popen_loky_posix
No module named joblib.externals.loky.backend.popen_loky_posix
ICA failed with error: A worker process managed by the executor was unexpectedly terminated. This could be caused by a segmentation fault while calling the function or by an excessive memory usage causing the Operating System to kill the worker.

The exit codes of the workers are {EXIT(0)}
Detailed tracebacks of the workers should have been printed to stderr in the executor process if faulthandler was not disabled.
PCA: 
     datetime       symbol    close  circulating_market_cap     feature_a  industry
0  2026-03-31  600036.XSHG    39.32               -0.187392 -2.736911e-48  0.564279
1  2026-03-31  600519.XSHG  1450.00                1.080440 -2.394797e-48 -1.154603
2  2026-03-31  300059.XSHE    18.89               -0.893048  0.000000e+00  0.590323
bayesianridge: 
     datetime       symbol    close  circulating_market_cap     feature_a  industry
0  2026-03-31  600036.XSHG    39.32               -0.187392 -9.502278e-49  0.564279
1  2026-03-31  600519.XSHG  1450.00                1.080440  1.207075e-49 -1.154603
2  2026-03-31  300059.XSHE    18.89               -0.893048  8.295202e-49  0.590323
partialcorrelation: 
     datetime       symbol    close  circulating_market_cap     feature_a  industry
0  2026-03-31  600036.XSHG    39.32               -0.187392 -2.556966e-61  0.564279
1  2026-03-31  600519.XSHG  1450.00                1.080440 -2.600266e-61 -1.154603
2  2026-03-31  300059.XSHE    18.89               -0.893048 -2.578237e-61  0.590323

3.我自己写的版本

代码(基于Qlib):

class CSNeutralize:
    def __init__(
        self,
        factor_group,  # list of column tuples to neutralize
        risk_cols=None,  # list of column tuples as risk factors
        industry_col=None,  # single column tuple for industry
        ridge_alpha=1e-6,
    ):
        self.factor_group = factor_group
        self.risk_cols = risk_cols or []
        self.industry_col = industry_col
        self.ridge_alpha = ridge_alpha
        self._fit_done = False

    def fit(self, df: pd.DataFrame):
        # factor_cols:排除风险因子和行业列
        self.factor_cols = [c for c in self.factor_group if c not in self.risk_cols and c != self.industry_col]
        self._fit_done = True
        return self

    def __call__(self, data: pd.DataFrame):
        if not self._fit_done:
            self.fit(data)

        df_fg = data[self.factor_group].copy()
        Y_all = df_fg[self.factor_cols].values.astype(float)

        # 风险因子
        if self.risk_cols:
            risk_all = df_fg[self.risk_cols].values.astype(float)
        else:
            risk_all = np.empty((len(df_fg), 0))

        # 行业
        if self.industry_col:
            industry = df_fg[self.industry_col].astype(str).str.slice(0, 4).values
            industry_codes, industry_idx = np.unique(industry, return_inverse=True)
            n_ind = len(industry_codes) - 1
        else:
            industry_idx = None
            n_ind = 0

        # 日期
        dates = df_fg.index.get_level_values("datetime").values
        unique_dates, date_idx = np.unique(dates, return_inverse=True)
        n_dates = len(unique_dates)

        for d in range(n_dates):
            if d % 50 == 0:
                logger.info(f"Neutralize progress: {d}/{n_dates} dates")

            mask = date_idx == d
            Y = Y_all[mask]
            X_parts = []

            # intercept
            X_parts.append(np.ones((Y.shape[0], 1)))

            # numeric risk
            if self.risk_cols:
                X_parts.append(risk_all[mask])

            # industry dummy
            if self.industry_col:
                ind = industry_idx[mask]
                dummy = np.zeros((len(ind), n_ind))
                valid = ind > 0
                dummy[np.arange(len(ind))[valid], ind[valid] - 1] = 1
                X_parts.append(dummy)

            # 合并设计矩阵
            X = np.hstack(X_parts)

            # 过滤无效行
            valid_mask = np.isfinite(X).all(axis=1) & np.isfinite(Y).all(axis=1)
            if valid_mask.sum() == 0:
                continue

            Xv = np.ascontiguousarray(X[valid_mask])
            Yv = np.ascontiguousarray(Y[valid_mask])

            # 一次性对多列中性化
            resid = self._lstsq_resid(Xv, Yv, self.ridge_alpha)
            Y[valid_mask, :] = resid
            Y_all[mask, :] = Y

        logger.info("Neutralize finished")
        data.loc[:, self.factor_cols] = Y_all.astype(float)
        return data

    @staticmethod
    @njit(parallel=True, fastmath=True)
    def _lstsq_resid(X, Y, ridge_alpha):
        n_samples, n_features = X.shape
        n_targets = Y.shape[1]
        resid = np.empty_like(Y)
        XtX = X.T @ X + np.eye(n_features) * ridge_alpha
        beta = np.linalg.solve(XtX, X.T @ Y)
        for i in prange(n_samples):
            for j in range(n_targets):
                resid[i, j] = Y[i, j] - X[i, :] @ beta[:, j]
        return resid
if __name__ == "__main__":
    cols = pd.MultiIndex.from_tuples(
        [
            ("feature", "feature_a"),
            ("feature", "industry"),
            ("feature", "log_mktcap"),
        ]
    )
    index = pd.MultiIndex.from_arrays(
        [
            pd.to_datetime(["2026-03-31"] * 3),
            ["600036.XSHG", "600519.XSHG", "300059.XSHE"],
        ],
        names=["datetime", "instrument"],
    )
    data = pd.DataFrame(
        [
            [1.5, 801780, 8111.3009],
            [2.3, 801120, 18157.9181],
            [0.8, 801790, 2519.5017],
        ],
        index=index,
        columns=cols,
    )
    print(f"Original data: \n{data}")

    processor = CSNeutralize(
        factor_group=[
            ("feature", "feature_a"),
            ("feature", "industry"),
            ("feature", "log_mktcap"),
        ],
        # factor_group=[("feature", "feature_a"), ("feature", "feature_b"),
        #               ("feature", "industry"), ("feature", "log_mktcap")],
        risk_cols=[("feature", "log_mktcap")],
        industry_col=("feature", "industry"),
    )
    processor.fit(data)
    neutralized_data = processor(data)
    print(f"Neutralized data: \n{neutralized_data}")

日志:

Original data: 
                         feature                     
                       feature_a industry  log_mktcap
datetime   instrument                                
2026-03-31 600036.XSHG       1.5   801780   8111.3009
           600519.XSHG       2.3   801120  18157.9181
           300059.XSHE       0.8   801790   2519.5017
Neutralized data: 
                             feature                     
                           feature_a industry  log_mktcap
datetime   instrument                                    
2026-03-31 600036.XSHG  1.192460e-06   801780   8111.3009
           600519.XSHG -4.307257e-07   801120  18157.9181
           300059.XSHE -7.347961e-07   801790   2519.5017

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions