精通模型解释:从您的机器学习模型中获取深入见解·综合运用
🔍

精通模型解释:从您的机器学习模型中获取深入见解·综合运用

(3/3)·模型越复杂越难 debug,本篇把特征重要性与单/集并影响串成一套可直接落手的解释流程

新手友好 第 3/3 篇
很多人训完模型只看回测曲线,一旦实盘偏离就无从下手。其实把特征重要性和单个/组合影响拆开看,就能定位是哪根因子在捣乱。这套解释思路同样适用于你筛选外汇贵金属信号的输入变量。

◍ 互信息筛特征后混合模型误差砍了94%

互信息回归给出的特征得分里,TSI_13_25_13 拿到 0.168086 排第一,AO_5_34 为 0.128653,BBB_20_2.0 是 0.104481,RSI_20 为 0.095368,MACDh_12_26_9 是 0.076360。指数移动平均、布林带与 OHLC 类特征长期霸榜,说明中心趋势类因子大概率携带了多数可用信息,其余低分特征可考虑丢弃。 用线性回归吃 OHLC 四列拟合训练集,再用 CatBoost 黑盒去学它的残差,且只喂 BBM/BBL/BBU 与三条 EMA。测试集上,全特征初始黑盒的均方误差,对比这个「线性+精选黑盒」混合体,误差指标提高了 94.428 个百分点,即新混合体误差降低了约 94%。 新模型不仅误差更低,预测曲线里原本那种不自然的钝化平台也消失了,价格跟随更真实。外汇与贵金属波动剧烈、杠杆高风险大,这种降维思路只解决过拟合与信号稀释,不保证实盘胜率。 下面这段 Python 是当时做特征筛选与混合训练的核心代码,MQL5 端可把同逻辑移植成自定义指标来跑信号。

MQL5 / C++
from sklearn.feature_selection class="kw">import RFE
from sklearn.linear_model class="kw">import LinearRegression
from sklearn.svm class="kw">import SVR
lm = LinearRegression()
rfe = RFE(lm,step=class="num">1)
rfe = rfe.fit(train_x,train_y)
rfe.support_
rfe.ranking_
train_x.columns[rfe.support_]
class="macro">#pip install eli5 if you don&class="macro">#x27;t allready have it installed
class="kw">import eli5
from eli5.sklearn class="kw">import PermutationImportance
from sklearn.ensemble class="kw">import GradientBoostingRegressor
gbr = GradientBoostingRegressor().fit(train_x,train_y)
permutation = PermutationImportance(gbr).fit(test_x,test_y)
eli5.show_weights(permutation,feature_names = test_x.columns.to_list())
class="macro">#Import partial dependence display from sklearn
from sklearn.inspection class="kw">import PartialDependenceDisplay
for feature_name in predictors:
    PartialDependenceDisplay.from_estimator(cat_full,test_x,[feature_name])
    plt.grid()
    plt.show()
对于预测的特征:
    PartialDependenceDisplay.from_estimator(cat_full,test_x,[feature],kind=&class="macro">#x27;both&class="macro">#x27;)
class="macro">#Setting up the plot
fig , ax = plt.subplots(figsize=(class="num">10,class="num">5))
column_names = [(&class="macro">#x27;ROC_10&class="macro">#x27;,&class="macro">#x27;ATRr_14&class="macro">#x27;)]
class="macro">#Plotting 2D PDP
disp_4 = PartialDependenceDisplay.from_estimator(cat_full, test_x[class="num">0:class="num">1000],column_names, ax=ax)
plt.show()
class="macro">#pip install shap if you don&class="macro">#x27;t have it installed
class="macro">#Import SHAP
class="kw">import shap
class="macro">#Initialise the shap package
shap.initjs()
class="macro">#Initialise shap value calculator
tree_explainer = shap.TreeExplainer(cat_full)
class="macro">#Store SHAP values
shap_values = tree_explainer.shap_values(test_x)
class="macro">#Plot SHAP values
shap.summary_plot(shap_values,test_x)
from sklearn.feature_selection class="kw">import mutual_info_regression
mi_scores = mutual_info_regression(train_x, train_y)
mi_scores = pd.Series(mi_scores, name="MI Scores", index=train_x.columns)
mi_scores = mi_scores.sort_values(ascending=False)
mi_scores
def plot_mi_scores(scores):
    scores = scores.sort_values(ascending=True)
    width = np.arange(len(scores))
    ticks = list(scores.index)
    plt.barh(width, scores)
    plt.yticks(width, ticks)
    plt.title("Mutual Information Scores")
plt.figure(dpi=class="num">100, figsize=(class="num">8, class="num">5))
plt.grid()
plot_mi_scores(mi_scores)
from sklearn.linear_model class="kw">import LinearRegression
from catboost class="kw">import CatBoostRegressor
from sklearn.metrics class="kw">import mean_squared_error
class="macro">#First we fit the simpler model
lm = LinearRegression()
lm.fit(train_x.loc[:,[“open”,“high”,“low”,“close”]],train_y)
lm_predictions = pd.DataFrame(lm.predict(train_x.loc[:,[“open”,“high”,“low”,“close”]]), index = train_y.index)
lm_fit = lm.predict(train_x.loc[:,[“open”,“high”,“low”,“close”]])
residuals = pd.DataFrame(train_y - lm_fit)
class="macro">#Now we bring in our more powerfull black-box model
cat = CatBoostRegressor()
cat.fit(
    train_x.loc[:,[“BBM_20_2.class="num">0”,“BBL_20_2.class="num">0”,“BBU_20_2.class="num">0”,“ema_40”,“ema_20”,“ema_100”]],
    residuals)
lm_test_predictions = pd.DataFrame(lm.predict(test_x.loc[:,[“open”,“high”,“low”,“close”]]),index=test_y.index)
cat_full_test_predictions = cat_full.predict(test_x[predictors])
cat_residuals_predictions = pd.DataFrame(cat.predict(test_x.loc[:,[“BBM_20_2.class="num">0”,“BBL_20_2.class="num">0”,“BBU_20_2.class="num">0”,“ema_40”,“ema_20”,“ema_100”]]),index=test_y.index)
full_error = mean_squared_error(test_y,cat_full_test_predictions)

「混合模型接 MT5 的实时下单回路」

混合预测把线性模型输出与残差模型输出直接相加:hybrid_predictions = lm_test_predictions.iloc[:,0] + cat_residuals_predictions.iloc[:,0],再用均方误差对比纯模型误差,delta_error / full_error * 100 给出误差压缩百分比,回测里这个值常落在 10%~30% 区间,说明叠加残差有概率降低拟合偏差。 实盘侧先钉死标的与手数:MARKET_SYMBOL 设为 Volatility 75 Index,DEVIATION 给 100 点容差,VOLUME 取 symbol_info.volume_min*1 即最小允许单量。preprocess 函数在原始 df 上追加 ema_20 / ema_40 / ema_100 三条指数均线和 20 周期 2 倍标准差布林带,并截掉前 100 根预热数据。 fetch_prices 拉 MT5 的 M1 最近 200 根 K 线进 DataFrame,normalise_prices 按首值缩放特征。hybrid_forecast 取第 199~200 行分别喂给两个模型,out = forecast_1 + forecast_2 即混合信号。 主循环里无持仓时,model_forecast 大于 ask 则跟模型买、小于 ask 则跟模型卖。Volatility 75 Index 属高杠杆合成指数,极端跳空频繁,这段代码只解决信号到下单的连通,不隐含任何胜率保证。

MQL5 / C++
hybrid_predictions = lm_test_predictions.iloc[:,class="num">0] + cat_residuals_predictions.iloc[:,class="num">0]
hybrid_error = mean_squared_error(test_y, hybrid_predictions)
delta_error = full_error - hybrid_error(delta_error / full_error) * class="num">100
hybrid_predictions.plot()
MARKET_SYMBOL = "Volatility class="num">75 Index"
DEVIATION = class="num">100
VOLUME = class="num">0
symbol_info = MT5.symbol_info(MARKET_SYMBOL)
VOLUME = symbol_info.volume_min * class="num">1
def preprocess(df):
    # class="num">20 period exponential moving average
    df["ema_20"] = df.ta.ema(length=class="num">20)
    # class="num">40 period exponential moving average
    df["ema_40"] = df.ta.ema(length=class="num">40)
    # class="num">100 period exponential moving average
    df["ema_100"] = df.ta.ema(length=class="num">100)
    # class="num">20 period bollinger bands with class="num">2 standard deviations
    df.ta.bbands(length=class="num">20,sd=class="num">2,append=True)
    df = df.loc[class="num">100:,:]
def fetch_prices():
    current_prices = pd.DataFrame()
    current_prices = pd.DataFrame(MT5.copy_rates_from_pos(MARKET_SYMBOL,MT5.TIMEFRAME_M1,class="num">0,class="num">200))
    preprocess(current_prices)
    class="kw">return(current_prices)
def normalise_prices(raw_data):
    for col in raw_data.columns:
        if col in first_values:
            raw_data[col] = raw_data[col] / first_values[col]
model_forecast = class="num">0
def hybrid_forecast(model_1,model_2):
    market_data = fetch_prices()
    normalise_prices(market_data)
    forecast_1 = model_1.predict(market_data.loc[class="num">199:class="num">200,["open","high","low","close"]])
    forecast_2 = model_2.predict(market_data.loc[class="num">199:class="num">200,["BBM_20_2.class="num">0","BBL_20_2.class="num">0","BBU_20_2.class="num">0","ema_40","ema_20","ema_100"]])
    out = forecast_1 + forecast_2
    class="kw">return(out)
INITIAL_BALANCE = MT5.account_info().balance
CURRENT_BALANCE = class="num">0
if __name__ == "__main__":
    class="kw">while True:
        class="macro">#Account standing
        info = MT5.account_info()
        CURRENT_BALANCE = info.balance
        profit = CURRENT_BALANCE - INITIAL_BALANCE
        model_forecast = hybrid_forecast(lm,cat)
        print("Current forecast: ",model_forecast)
        class="macro">#We have no open positions
        if(MT5.positions_total() == class="num">0):
            print("No open positions")
            class="macro">#Buy
            if(model_forecast > MT5.symbol_info(MARKET_SYMBOL).ask):
                print("Following model forecast buy")
                MT5.Buy(MARKET_SYMBOL,VOLUME)
                last_trade = class="num">1
            class="macro">#Sell
            elif(model_forecast < MT5.symbol_info(MARKET_SYMBOL).ask):
                print("Following model forecast sell")
                MT5.Sell(MARKET_SYMBOL,VOLUME)

用模型预测价反向踢出持仓

这段逻辑只干一件事:当账户里已经有持仓时,拿模型预测价和当前卖价(ask)比,若方向对着干就平掉。 last_trade 是个状态位,0 表示上一次是空仓操作、1 表示上一次开了多。模型预测价高于 ask 且 last_trade 为 0,说明原本空仓逻辑下模型看多,现在持仓会亏,直接 MT5.Close() 清掉。 反过来,预测价低于 ask 且 last_trade 为 1,意味着模型转空而我们还多着,同样清仓。每次循环结束打印 profit 并 sleep(60),即每分钟轮询一次。外汇与贵金属杠杆高,模型误判可能在 60 秒内就扩大浮亏,这套关闭条件只防“对着干”,不防跳空。 开 MT5 把这段接进你的预测脚本,先拿模拟盘跑,看 last_trade 翻转与平仓打印是否对得上。

MQL5 / C++
last_trade = class="num">0

elif(MT5.positions_total() > class="num">0):
    print("Checking model forecast")

    if((model_forecast > MT5.symbol_info(MARKET_SYMBOL).ask) & (last_trade == class="num">0)):
        print("Model is forecasting a move that hurts our exposure. Closing positions")
        MT5.Close()

    elif((model_forecast < MT5.symbol_info(MARKET_SYMBOL).ask) & (last_trade == class="num">1)):
        print("Model is forecasting a move that hurts our exposure. Closing positions")
        MT5.Close()

    print("Total Profit/Loss: ",profit)
    time.sleep(class="num">60)

◍ 别急着下结论

模型越堆越大,内部决策路径就越难逆向还原。你没法解释它为什么在某根 K 线开仓,就谈不上判定它是按你预设的逻辑跑,还是撞上了样本里的统计巧合。 外汇与贵金属杠杆交易本身高风险,把不可解释的模型直接挂实盘,额外复杂性带来的不是alpha,而是你控制不了的回撤。 能持续从工具里抠出价值的上限,就是你对自己模型的认知深度。先能在本地把那套解释工具跑通、看清黑盒里的齿轮怎么转,再谈放大仓位。

交给小布盯盘做因子体检
这些诊断逻辑小布盯盘的 AIGC 已内置,打开对应品种页即可看到特征贡献与异常波动提示,你把重复劳动交给小布,专注决策就行。

常见问题

拿特征重要性排序和你对市场结构的认知对照,若高权特征明显违背常识,多半是样本泄漏或周期错位,外汇贵金属高风险,需复核数据切分。
单特征看孤立变动时预测怎么走,集并影响看一组特征协同后的更广效应,后者更容易暴露非线性交互导致的意外预测。
可以,小布盯盘的品种页把特征贡献与影响诊断做成现成视图,不用自己写解释脚本就能快速看模型为什么给出该信号。
用集并影响对比新旧特征下的预测分布偏移,若新特征只在高噪声段起作用,大概率过拟合,实盘前用样本外窗口再验一次。