名义变量的序数编码·进阶篇
🔢

名义变量的序数编码·进阶篇

(2/3)· 当决策树能直接吃类别、逻辑回归却只认数字,错配编码会让算法脑补出不存在的等级

新手友好 第 2/3 篇
给针形K线编个 3、纺锤线编个 1,很多交易者以为这只是给机器学习凑输入。实际上算法可能把这个 3 当成比 1 高级,把无顺序的类别硬读出排名,模型学到的全是噪声。

◍ 把K线形态字段压成模型能吃的数字

做价格行为相关的机器学习,第一步往往是把「bar_type / body_type / bar_pattern」这类文本标签变成数值。最直白的是序数编码:给每个类别硬派一个整数。这招只在你事先清楚类别和目标关系时才够用;无监督场景下用它会偷偷塞进本不存在的先后顺序,模型可能学到假规律。 不依赖目标变量的安全牌是独热、二进制和频率编码。独热把每个类别拆成 0/1 列,对 12 个月编码会多出 11 列,维度膨胀明显;二进制先转整数再摊成二进制位,同样 12 个月只需 4 列;频率编码直接用类别出现次数替换,不新增列,但主导类别太多时会带偏分布。 想让编码带一点预测力,就用盯住目标的方案。目标编码拿类别对应的目标均值填进去,高基数变量(比如几十种形态组合)不扩维就能塞进信息;留一法编码算均值时抠掉当前行,小样本里过拟合风险低一些;詹姆斯-斯坦因编码按样本量把类别均值往总均值拉,稀疏或类别不均时比前两种稳。 下面这段 Python 用了 category_encoders 把刚才说的七种全跑了一遍,列名都是价格行为提取的字段,复制去 Jupyter 里改 prices 就能看 head 输出。外汇和贵金属行情序列短、跳空多,编码后务必做交叉验证,过拟合概率不低。

MQL5 / C++
class="macro">#Ordinal encoding
ord_encoder = OrdinalEncoder(cols = ["bar_type","body_type","bar_pattern"])
ordinal_data = ord_encoder.fit_transform(prices)
print(" ordinal encoding\n ", ordinal_data.head())
class="macro">#One-Hot encoding
onehot_encoder = OneHotEncoder(cols = ["bar_type","body_type","bar_pattern"])
onehot_data = onehot_encoder.fit_transform(prices)
print(" ordinal encoding\n ", onehot_data.head())
class="macro">#Binary encoding
binary_encoder = BinaryEncoder(cols = ["bar_type","body_type","bar_pattern"])
binary_data = binary_encoder.fit_transform(prices)
print(" binary encoding\n ", binary_data.head())
class="macro">#Frequency encoding
freq_encoder = CountEncoder(cols = ["bar_type","body_type","bar_pattern"])
freq_data = freq_encoder.fit_transform(prices)
print(" frequency encoding\n ", freq_data.head())
class="macro">#Target encoding
target_encoder = TargetEncoder(cols = ["bar_type","body_type","bar_pattern"])
target_data = target_encoder.fit_transform(prices[["open","high","low","close","bar_type","body_type","bar_pattern"]], prices["target"])
print(" target encoding\n ", target_data.head())
class="macro">#LeaveOneOut encoding
oneout_encoder = LeaveOneOutEncoder(cols = ["bar_type","body_type","bar_pattern"])
oneout_data = oneout_encoder.fit_transform(prices[["open","high","low","close","bar_type","body_type","bar_pattern"]], prices["target"])
print(" LeaveOneOut encoding\n ", oneout_data.head())
class="macro">#James Stein encoding
james_encoder = JamesSteinEncoder(cols = ["bar_type","body_type","bar_pattern"])
james_data = james_encoder.fit_transform(prices[["open","high","low","close","bar_type","body_type","bar_pattern"]], prices["target"])
print(" James Stein encoding\n ", james_data.head())

「把名义变量塞进MT5的两种编码套路」

在MT5里处理分类特征,先得区分两种编码思路:独热(One-Hot)和目标编码变体。前者把每个类别摊成0/1列,后者在不扩维的前提下把类别映射到序数刻度,封装在nom2ord.mqh里,分别由COneHotEncoder和CNomOrd两个类承担。 COneHotEncoder的用法很直白:建实例后先调fit(),喂入特征矩阵和列索引数组。数组留空就默认全部列都是名义变量;fit()返回true后,再拿列数一致的矩阵去transform(),维度不对会直接报错。BTCUSD数据集跑下来,原始几列类别被展开成多列0/1,特征宽度明显增加。 CNomOrd走的是另一条路。fit()除了矩阵和列索引,还要一个目标变量向量。它先用百分位数把目标重标到0–100,最小值排0、最大值排100,中间按比例,这样异常值对编码的拉动被削弱,过拟合概率下降。 score()用蒙特卡洛置换检验算p值:反复打乱目标变量,看真实类别间目标百分位极差(最大均值减最小均值)是否显著偏离随机分布。原假设是「差异纯属偶然」。在BTCUSD上,只有多/空分类的p值显著,双K线形态和实体大小的p值偏高,说明它们跟目标的关系和随机变量差不多。 降维版fitTransform()能把任意多个名义变量压成单一序数变量,但演示脚本给出的新变量p值偏高——信息丢失是这种压缩的固有代价,实盘前应在历史样本上先验证区分度。外汇与贵金属市场高风险,这类特征工程只降低建模偏差,不预示任何方向。

MQL5 / C++
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| one hot encoder class                                        |
class=class="str">"cmt">//+------------------------------------------------------------------+
class COneHotEncoder
  {
class="kw">private:
   vector            m_mapping[];
   class="type">ulong             m_cat_cols[];
   class="type">ulong             m_vars,m_cols;
class="kw">public:
   class=class="str">"cmt">//+------------------------------------------------------------------+
   class=class="str">"cmt">//|  Constructor                                                    |
   class=class="str">"cmt">//+------------------------------------------------------------------+
                     COneHotEncoder(class="type">void)
     {
     }
   class=class="str">"cmt">//+------------------------------------------------------------------+
   class=class="str">"cmt">//|  Destructor                                                     |
   class=class="str">"cmt">//+------------------------------------------------------------------+
                    ~COneHotEncoder(class="type">void)
     {
      ArrayFree(m_mapping);
     }
   class=class="str">"cmt">//+------------------------------------------------------------------+
   class=class="str">"cmt">//| map categorical features of a training dataset                  |
   class=class="str">"cmt">//+------------------------------------------------------------------+
   class="type">bool              fit(matrix &in_data,class="type">ulong &cols[])
     {
     m_cols = in_data.Cols();
     matrix data = np::selectMatrixCols(in_data,cols);
     if(data.Cols()!=class="type">ulong(cols.Size()))
       {
       Print(__FUNCTION__, " invalid input data ");
       class="kw">return false;
       }
     m_vars = class="type">ulong(cols.Size());

类别特征矩阵的重映射与对齐

把任意特征矩阵转成模型能吃的类别编码,核心是先校验列维度。若输入列数不等于训练时记录的 m_cols,直接返回 1×1 零矩阵,避免后续越界。 先抽取类别列:用 np::selectMatrixCols 从 in_data 中按 m_cat_cols 挑出子矩阵 data,并确认 data.Cols() 等于 m_cat_cols.Size(),否则同样返回 1×1 零矩阵。 非类别列需单独保留。代码里用 ArrayBsearch 在 m_cat_cols 中找每一列索引 i,若找不到(m_cat_cols[found]!=i)就塞进 unchanged_feature_cols;最后用 selectMatrixCols 把原样特征拷到 input_copy。 类别列则按 m_mapping 做编码替换,累计新列数用 cumsum 向量跟踪,其长度为 MathMin(m_vars, data.Cols())。外汇与贵金属数据做这类特征工程时波动大,维度校验失败的概率偏高,建议在 MT5 里对历史 tick 数据先跑一次列数断言。

MQL5 / C++
if(ArrayCopy(m_cat_cols,cols)<=class="num">0 || !ArraySort(m_cat_cols))
  {
   Print(__FUNCTION__, " ArrayCopy or ArraySort failure ", GetLastError());
   class="kw">return false;
  }
if(ArrayResize(m_mapping,class="type">int(m_vars))<class="num">0)
  {
   Print(__FUNCTION__, " Vector array resize failure ", GetLastError());
   class="kw">return false;
  }
for(class="type">ulong i = class="num">0; i<m_vars; i++)
  {
   vector unique = data.Col(i);
   m_mapping[i] = np::unique(unique);
  }
class="kw">return true;
  }
  class=class="str">"cmt">//+------------------------------------------------------------------+
  class=class="str">"cmt">//| Transform abitrary feature matrix to learned category m_mapping   |
  class=class="str">"cmt">//+------------------------------------------------------------------+
  matrix              transform(matrix &in_data)
  {
   if(in_data.Cols()!=m_cols)
    {
     Print(__FUNCTION__," Column dimension of input not equal to ", m_cols);
     class="kw">return matrix::Zeros(class="num">1,class="num">1);
    }
   matrix out,input_copy;
   matrix data = np::selectMatrixCols(in_data,m_cat_cols);
   if(data.Cols()!=class="type">ulong(m_cat_cols.Size()))
    {
     Print(__FUNCTION__, " invalid input data ");
     class="kw">return matrix::Zeros(class="num">1,class="num">1);
    }
   class="type">ulong unchanged_feature_cols[];
   for(class="type">ulong i = class="num">0; i<in_data.Cols(); i++)
    {
     class="type">int found = ArrayBsearch(m_cat_cols,i);
     if(m_cat_cols[found]!=i)
       {
        if(!unchanged_feature_cols.Push(i))
          {
           Print(__FUNCTION__, " Failed array insertion ", GetLastError());
           class="kw">return matrix::Zeros(class="num">1,class="num">1);
          }
       }
    }
   input_copy = unchanged_feature_cols.Size()?np::selectMatrixCols(in_data,unchanged_feature_cols):input_copy;
   class="type">ulong numcols = class="num">0;
   vector cumsum = vector::Zeros(class="type">ulong(MathMin(m_vars,data.Cols())));

◍ 独热编码的列拼接实现细节

上面这段逻辑做的是分类特征到数值矩阵的转换:先算每个原始列在映射后占多少位,用 cumsum 记录累计偏移,再开一个全零矩阵 out,行数跟原数据一致,列数是所有映射类别数之和。 内层三层循环逐行扫描,若某单元格数值与映射表里的某个类别差不超过 1e-15,就在对应偏移位置写 1.0 并 break,等价于标准 one-hot 赋值。 最后用 matrixCopyCols 把原输入(若有)和 out 横向拼成 newfeaturematrix;任一步拷贝失败就 Print 报错并返回 1x1 零矩阵,调用方需自行判断尺寸来避坑。 下方 demo 脚本头里 TrainingSampleStartDate 设成 2023.12.31、StopDate 却是 2017.12.31,时间区间倒置,直接跑会取不到样本,改参数前先对调这两个日期。

MQL5 / C++
for(class="type">ulong i = class="num">0; i<cumsum.Size(); i++)
  {
   cumsum[i] = class="type">class="kw">double(numcols);
   numcols+=m_mapping[i].Size();
  }
out = matrix::Zeros(data.Rows(),numcols);
for(class="type">ulong i = class="num">0;i<data.Rows(); i++)
  {
   vector row = data.Row(i);
   for(class="type">ulong col = class="num">0; col<row.Size(); col++)
     {
      for(class="type">ulong k = class="num">0; k<m_mapping[col].Size(); k++)
        {
         if(MathAbs(row[col]-m_mapping[col][k])<=class="num">1.e-15)
           {
            out[i][class="type">ulong(cumsum[col])+k]=class="num">1.0;
            class="kw">break;
           }
        }
     }
  }
matrix newfeaturematrix(out.Rows(),input_copy.Cols()+out.Cols());
if((input_copy.Cols()>class="num">0 && !np::matrixCopyCols(newfeaturematrix,input_copy,class="num">0,input_copy.Cols())) || !np::matrixCopyCols(newfeaturematrix,out,input_copy.Cols()))
  {
   Print(__FUNCTION__, " Failed matrix copy ");
   class="kw">return matrix::Zeros(class="num">1,class="num">1);
  }
class="kw">return newfeaturematrix;
 }
};
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//|                     OneHotEncoding_demo.mq5 |
class=class="str">"cmt">//|          Copyright class="num">2024, MetaQuotes Ltd. |
class=class="str">"cmt">//|                 [MQL5官方文档] |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="macro">#class="kw">property copyright "Copyright class="num">2024, MetaQuotes Ltd."
class="macro">#class="kw">property link      "[MQL5官方文档]
class="macro">#class="kw">property version   "class="num">1.00"
class="macro">#class="kw">property script_show_inputs
class="macro">#include<np.mqh>
class="macro">#include<nom2ord.mqh>
class="macro">#include<ErrorDescription.mqh>
class=class="str">"cmt">//--- input parameters
input class="type">class="kw">datetime TrainingSampleStartDate=D&class="macro">#x27;class="num">2023.12.class="num">31&class="macro">#x27;;
input class="type">class="kw">datetime TrainingSampleStopDate=D&class="macro">#x27;class="num">2017.12.class="num">31&class="macro">#x27;;
input ENUM_TIMEFRAMES tf = PERIOD_D1;
input class="type">class="kw">string   SetSymbol="BTCUSD";
class=class="str">"cmt">//+------------------------------------------------------------------+

「训练集切分与对数价差矩阵落地」

做样本内建模前,先得把训练区间的 K 线索引抓准。用 iBarShift 把 TrainingSampleStartDate / StopDate 转成柱索引,若返回负值说明日期或周期不对,直接打印错误并退出,避免后面数组尺寸算歪。 size_insample 就是 (trainstop - trainstart) + 1,代表训练集总根数;若算出 ≤0 属于输入参数冲突,脚本会报 Invalid inputs。 predictors 矩阵被硬性 Resize 成 size_insample × 3,也就是每根 K 线留 3 个特征位,后续填数据若失败同样退场。 价格数据走 CopyRates 拉 OHLC 竖排矩阵,targets 取第 3 列(收盘价)做 log 变换后再 np::diff 一阶差分,得到可训练的收益率序列。循环里已开始按 prices[i][3] 与开盘价 prices[i][0] 比大小,为阴线判定打底——外汇与贵金属杠杆高,样本错配会让回测失真,建议开 MT5 用自己品种跑一遍确认索引偏移。

MQL5 / C++
class=class="str">"cmt">//|global integer variables                                                                 |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">int size_insample,                                                                     class=class="str">"cmt">//training set size
    size_observations,                                                                 class=class="str">"cmt">//size of of both training and testing sets combined
    price_handle=INVALID_HANDLE;                                                       class=class="str">"cmt">//log prices indicator handle
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//|class="type">class="kw">double global variables                                                                 |
class=class="str">"cmt">//+------------------------------------------------------------------+
matrix      prices;                                                                    class=class="str">"cmt">//array for log transformed prices
vector      targets;                                                                   class=class="str">"cmt">//differenced prices kept here
matrix      predictors;                                                                class=class="str">"cmt">//flat array arranged as matrix of all predictors ie size_observations by size_predictors
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| Script program start function                                                                |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">void OnStart()
  {
class=class="str">"cmt">//get relative shift of is and oos sets
   class="type">int trainstart,trainstop;
   trainstart=iBarShift(SetSymbol!=""?SetSymbol:NULL,tf,TrainingSampleStartDate);
   trainstop=iBarShift(SetSymbol!=""?SetSymbol:NULL,tf,TrainingSampleStopDate);
class=class="str">"cmt">//check for errors from ibarshift calls
   if(trainstart<class="num">0 || trainstop<class="num">0)
     {
       Print(ErrorDescription(GetLastError()));
       class="kw">return;
     }
class=class="str">"cmt">//---set the size of the sample sets
   size_insample=(trainstop - trainstart) + class="num">1;
class=class="str">"cmt">//---check for input errors
   if(size_insample<=class="num">0)
     {
       Print("Invalid inputs ");
       class="kw">return;
     }
class=class="str">"cmt">//---
   if(!predictors.Resize(size_insample,class="num">3))
     {
       Print("ArrayResize error ",ErrorDescription(GetLastError()));
       class="kw">return;
     }
class=class="str">"cmt">//---
   if(!prices.CopyRates(SetSymbol,tf,COPY_RATES_VERTICAL|COPY_RATES_OHLC,TrainingSampleStartDate,TrainingSampleStopDate))
     {
       Print("Copyrates error ",ErrorDescription(GetLastError()));
       class="kw">return;
     }
class=class="str">"cmt">//---
   targets = log(prices.Col(class="num">3));
   targets = np::diff(targets);
class=class="str">"cmt">//---
   class="type">class="kw">double bodyratio = class="num">0.0;
   for(class="type">ulong i = class="num">0; i<prices.Rows(); i++)
     {
       if(prices[i][class="num">3]<prices[i][class="num">0])

把K线形态编码成模型能吃的数字

这段逻辑干的事很直接:把每根K线的涨跌方向和实体占比,转成离散特征喂给后续模型。先判涨跌——若当前收盘不低于开盘,predictors[i][0]记0,否则记1,相当于给每根K线贴个多空标签。

实体占比用收盘-开盘除以最高-最低算,阈值切四档:≥0.75记0(实体饱满),0.5~0.75记1,0.25~0.5记2,低于0.25记3(针线)。外汇和贵金属这种高杠杆品种,影线占比突变往往伴随假突破,这档位划分能给模型留出识别毛刺的空间,但本身不预示方向,只是特征工程。

第三列存相邻K线关系:两根都涨且高、低都抬,记2;两根都跌但低点下移、高点抬(下跌收敛),记1;其余记0。i<1时没前一根,直接填0并跳过。 最后把 prices 和 predictors 对齐行数、拼成 fullFeatureMatrix,对第4/5/6列做 OneHot 编码。若行数对不上 targets 或矩阵合并失败,直接 Print 报错 return。开 MT5 把这段塞进你的特征函数,先 Print 出 original predictors 看分布,再决定阈值要不要按品种波动率重调。

MQL5 / C++
  predictors[i][class="num">0] = class="num">0.0;
  else
    predictors[i][class="num">0] = class="num">1.0;
  bodyratio = MathAbs(prices[i][class="num">3]-prices[i][class="num">0])/MathAbs(prices[i][class="num">1]-prices[i][class="num">2]);
  if(bodyratio >=class="num">0.75)
    predictors[i][class="num">1] = class="num">0.0;
  else
    if(bodyratio<class="num">0.75 && bodyratio>=class="num">0.5)
      predictors[i][class="num">1] = class="num">1.0;
    else
      if(bodyratio<class="num">0.5 && bodyratio>=class="num">0.25)
        predictors[i][class="num">1] = class="num">2.0;
      else
        predictors[i][class="num">1] = class="num">3.0;
  if(i<class="num">1)
    {
     predictors[i][class="num">2] = class="num">0.0;
     class="kw">continue;
    }
  if(predictors[i][class="num">0]==class="num">1.0 && predictors[i-class="num">1][class="num">0]==class="num">1.0 && prices[i][class="num">1]>prices[i-class="num">1][class="num">1] && prices[i][class="num">2]>prices[i-class="num">1][class="num">2])
    predictors[i][class="num">2] = class="num">2.0;
  else
    if(predictors[i][class="num">0]==class="num">0.0 && predictors[i-class="num">1][class="num">0]==class="num">0.0 && prices[i][class="num">2]<prices[i-class="num">1][class="num">2] && prices[i][class="num">1]>prices[i-class="num">1][class="num">1])
      predictors[i][class="num">2] = class="num">1.0;
    else
      predictors[i][class="num">2] = class="num">0.0;
  }
 targets = np::sliceVector(targets,class="num">1);
 prices = np::sliceMatrixRows(prices,class="num">1,predictors.Rows()-class="num">1);
 predictors = np::sliceMatrixRows(predictors,class="num">1,predictors.Rows()-class="num">1);
 matrix fullFeatureMatrix(predictors.Rows(),predictors.Cols()+prices.Cols());
 if(!np::matrixCopyCols(fullFeatureMatrix,prices,class="num">0,prices.Cols()) ||
    !np::matrixCopyCols(fullFeatureMatrix,predictors,prices.Cols()))
  {
   Print("Failed to merge matrices");
   class="kw">return;
  }
 if(predictors.Rows()!=targets.Size())
  {
   Print(" Error in aligning data structures ");
   class="kw">return;
  }
 COneHotEncoder enc;
 class="type">ulong selectedcols[] = {class="num">4,class="num">5,class="num">6};
 if(!enc.fit(fullFeatureMatrix,selectedcols))
   class="kw">return;
 matrix transformed = enc.transform(fullFeatureMatrix);
 Print(" Original predictors \n", fullFeatureMatrix);
让小布替你跑这套编码检查
这些诊断小布盯盘的 AIGC 已内置,打开对应品种页即可看到哪些名义字段被误当序数送入模型,你只管调特征。

常见问题

名义变量类别间无内在顺序,如K线形态;序数变量有等级,如趋势强度。误把名义当序数会让算法虚构层级关系。
它们需要数值输入且对数值大小敏感,名义类别无数学意义,必须转成可解释的数字形式才能训练。
可以,小布在品种页内置了特征诊断,会标记被误赋序数的名义字段,帮你避开人工编码带来的模型偏差。
MQL5 原生无类别容器,多用整数区分,写映射逻辑更繁琐,但能直接在 EA 内闭环,不必跨语言搬运数据。
文中按形态模式分三类,彼此无等级,属名义变量;若按高低排列赋予顺序才转成序数,原始定义不可乱序。