离散值区间匹配

归类是工作中最常见的问题之一,而其中离散值的区间匹配虽然逻辑简单,但在Power Query中需要谨慎选择现实的方法才不会在数据较多的情景下运行过久,下文将分享几种不同的方案。

区间匹配一般会根据类似下图的表格进行匹配(下文称Para):

由于Table.Join()目前不支持不等式连接,最常见的实现方法为筛选参数表(详见如下):

let
    Buffering = Table.Buffer(Para),
    Add_TB =
        Table.AddColumn(
            DB,
            "nTable",
            (x)=>
                Table.SelectRows(
                    Buffering,
                    (y)=> y[Start] <= x[Val] and y[End] >= x[Val]
                )
        ),
    Expansion =
        Table.ExpandTableColumn(
            Add_TB,
            "nTable",
            {"Des"}
        ),
    Data_Type =
        Table.TransformColumnTypes(
            Expansion,
            {
                "Des",
                type text
            }
        )
in
    Data_Type

以上方案十分简洁,但由于Power Query对表格的操作较慢,使用第8代i7和16G内存处理百万级数据的运行时间逼近50秒,但只要把参数表化为列表,运行时间大幅减少至6.7秒左右,演示代码如下:

let
    Buffering =
        List.Buffer(
            Table.ToRows(Para)
        ),
    Add_TB =
        Table.AddColumn(
            DB,
            "Des",
            (x)=>
                List.Select(
                    Buffering,
                    (y)=> y{0} <= x[Val] and y{1} >= x[Val]
                ){0}{2},
            type text
        )
in
    Add_TB

另一种最容易想到的方案是手写多个if,虽然很麻烦,但出乎意料得高效,相同条件下运行的时间在4.6秒左右,代码如下:

let
    Add_TB =
        Table.AddColumn(
            DB,
            "nTable",
            (x)=>
                if
                    x[Val] >= 5000
                then
                    "Cat9"
                else if
                    x[Val] >= 4000
                then
                    "Cat8"
                else if
                    x[Val] >= 3000
                then
                    "Cat7"
                else if
                    x[Val] >= 2000
                then
                    "Cat6"
                else if
                    x[Val] >= 1000
                then
                    "Cat5"
                else if
                    x[Val] >= 0
                then
                    "Cat4"
                else if
                    x[Val] >= -1000
                then
                    "Cat3"
                else if
                    x[Val] >= -2000
                then
                    "Cat2"
                else if
                    x[Val] >= -3000
                then
                    "Cat1"
                else
                    "Cat0",
            type text
        )
in
    Add_TB

由于Table.AddColumn()的第三个参数是函数,所以可以通过Expression.Evaluate()把文本转换为函数,从而避免手写多个if的痛苦,方法如下:

let
    Source =
        Expression.Evaluate(
                "(x)=>"
            ﹠
                Text.Combine(
                        Table.TransformRows(
                            Table.RemoveLastN(Para, 1),
                            (x)=>
                                Text.Format(
                                    "if x[Val] <= #{0} then #{1}",
                                    {
                                        x[End],
                                        Expression.Constant(x[Des])
                                    }
                                )
                        )
                    ﹠
                        {Expression.Constant(Table.Last(Para)[Des])},
                    " else "
                )
        )
in
    Source

构建好以上函数后,就可以用作Table.AddColumn()的第三参数完成匹配了:

let
    Add_TB =
        Table.AddColumn(
            DB,
            "nTable",
            F1,
            type text
        )
in
    Add_TB

动态if方案处理百万级数据的运行时间与手写if的方案非常接近(4.6秒),虽然if方案会在区间变多的情况下变慢,但感觉实际情景很少有区间的数量会多于100,所以这个方案应该在大多数情景下仍能保持高效。接下来,介绍以List.PositionOf()为主体的方法(实测Table.PositionOf()会慢一点,在Power Query中表格操作比列表操作要慢):

let
    Source = List.Buffer(Para[End]),
    Dess = List.Buffer(Para[Des]),
    Grp =
        Table.AddColumn(
            DB,
            "Pos",
            (x)=>
                Dess{
                    List.PositionOf(
                        Source, 
                        x[Val], 
                        Occurrence.First, 
                        (k,j)=>k>=j
                    )
                },
            type text
        )
in
    Grp

以上代码的大意为:

(1)把参数表的End列和Des列化为List并通过List.Buffer()缓冲至内存中;

(2)通过List.PositionOf找出所有大于或等于Val的最小End在列表中的相对位置;

(3)通过相对位置匹配出组别的描述

以List.PositionOf()为主体的方案比if方案稍慢,相同条件下大约耗时6秒,同时与if方案一样当区间数量变大时性能会下降。对于离散数值,还可以采用枚举法,即把参数表所有可能的数值枚举出来,然后通过内连接把区间找出来。枚举法需要设置两个极值,由于这种方案的效率很明显与这两个极值之差成反比,极值Ex_Val1设为第一个区间的End-1,极值Ex_Val2设为最后一个区间的Start+1,如下图所示:

枚举法的演示代码如下:

let
    Add_Cap =
        Table.AddColumn(
            DB,
            "Val_Adj",
            each if
                [Val] >= Ex_Val2
            then
                Ex_Val2
            else if
                [Val] <= Ex_Val1
            then
                Ex_Val1
            else
                [Val],
            type number
        ),
//远比List.Min(List.Max())要快
    Add_Col =
        Table.AddColumn(
            Para,
            "Vals",
            (x)=>{x[Start]..x[End]},
            type list
        ),
        
    Expansion = Table.ExpandListColumn(Add_Col, "Vals"),
    Inner =
        Table.Join(
            Add_Cap,
            "Val_Adj",
            Expansion,
            "Vals",
            JoinKind.Inner
        ),
    Col_Removal = Table.SelectColumns(Inner, {"Val", "Des"})
in
    Col_Removal

以上代码的大意为:

(1)把小于等于Ex_Val1的Val限制为Ex_Val1和大于等于Ex_Val2的Val限制为Ex_Val2

(2)通过Table.AddColumn()和Table.ExpandListColumn()构造一个枚举每一个Des中所有可能值的表格

(3)通过DB和辅助表的连接找出Val对应的Des

枚举法在相同的条件下,运行时间为5.7秒,略比if方案慢,但涉及的函数较简单比较适合追求效率的新手,不过缺点是这种匹配方案只适用于离散数值以及随着需要枚举的可能值的数量增加,效率会随之下降。关于离散值的区间匹配,目前主流AI会建议使用二分法,该方案的演示代码如下:

let    
    Para_List = Table.ToColumns(Para),
    
    S = List.Buffer(Para_List{0}),
    
    E = List.Buffer(Para_List{1}),
    
    D = List.Buffer(Para_List{2}),
    Cnt = List.Count(D),
    
    BS = 
        (value as number) as text =>
            let 
                Search =
                    (low as number, high as number) =>
                        let
                            mid = Number.IntegerDivide(low + high, 2),
                            outcome =
                                if 
                                    value < S{mid}
                                then
                                    @Search(low, mid-1)
                                else if 
                                    value > E{mid}
                                then
                                    @Search(mid + 1, high)
                                else
                                    D{mid}
                        in
                            outcome,
                output = Search(0, Cnt-1)
            in
                output,
    
    Result =
        Table.AddColumn(
            DB,
            "Interval", 
            each BS([Val]), 
            type text
        )
in
    Result

以上代码的大意为:从参数表中间的区间开始,如果Val比这个区间Start小,那么就往左边找,如果Val比这个区间End大,就往右边找,如果Val处于Start和End之间,说明已经找到所属的区间,往左或往右找时重复前面描述的所有动作。以下以500,000和-500,000作为例子分步说明这个方案的原理:

虽然受到一众AI的推荐,但这种方案在相同条件下需时10秒左右,很可能是递归在Power Query中效率不高。不难联想到,这种思路能够用来模仿以List.PositionOf()为主体的方案,演示代码如下:

let    
    E = List.Buffer(Para[End]),
    
    D = List.Buffer(Para[Des]),
    Cnt = List.Count(D),
    
    BS = (val as number) as text =>
        let 
            Pos = 
                (low as number, high as number) as number =>
                    let
                        mid = Number.IntegerDivide(low + high, 2),
                        
                        search =
                            if 
                                low > high 
                            then 
                                low
                            else if 
                                E{mid} >= val
                            then 
                                @Pos(low, mid-1)
                            else 
                                @Pos(mid+1, high)
                    in
                        search,
                                
            Cat = D{Pos(0, Cnt-1)}
        in
            Cat,
    
    Result =
        Table.AddColumn(
            DB,
            "Interval", 
            each BS([Val]), 
            type text
        )
in
    Result

以上代码的大意为:从参数表中间的区间开始,如果Val小于等于这个区间的End,那么就往左边找否则就往右边找,当low大于high时,说明已经找到大于等于Val的最小End,。以下以-500,000作为例子分步说明这个方案的原理:

在相同条件下,以上方案运行时间大约11秒,略慢于AI推荐的方案,同时也说明在Power Query中自定义函数很难超越内建的函数。实际上不难发现,在参数表已知的情况下,二分法等效于以下if方案:

let
    Add_TB =
        Table.AddColumn(
            DB,
            "nTable",
            each if
                [Val] < 0 
            then 
                if 
                    [Val] < -2000 
                then 
                    if
                        [Val] <= -3001 
                    then
                        "Cat0"
                    else
                        "Cat1"
                else
                    if 
                        [Val] <= -1001
                    then
                        "Cat2"
                    else
                        "Cat3"
            else
                if 
                    [Val] < 2000 
                then
                    if 
                        [Val] <= 999 
                    then 
                        "Cat4" 
                    else 
                        "Cat5"
                else 
                    if 
                        [Val] < 4000
                    then 
                        if 
                            [Val] <= 2999 
                        then 
                            "Cat6" 
                        else 
                            "Cat7"
                        else 
                            if 
                                [Val] <= 4999 
                            then
                                "Cat8"
                            else
                                "Cat9",
            type text
        )
in
    Add_TB

很可惜的是,以上方案并没有显著地比普通的if方案快,那就不花时间通过Expression.Evaluate()进行动态化了。最后想比较一下在实战环境中(允许离散值存在重复),以上方案有没有超越几年前方案,旧方案代码如下:

let
    Distinction = 
        List.Buffer(
            List.Distinct(DB[Maturity_Date])
        ),
    rTB =
        Table.ExpandListColumn(
            Table.AddColumn(
                Para,
                "Date",
                (x)=>
                    List.Select(
                        Distinction,
                        (y)=> x[Start] <= y and x[End] >= y
                    ),
                type list
            ),
            "Date"
        ),
    Joining =
        Table.Join(
            DB,
            "Maturity_Date",
            rTB,
            "Date",
            JoinKind.Inner
        ),
    Removal = Table.RemoveColumns(Joining, {"Date", "Start", "End"})
in
    Removal

以上代码的大意为:

(1)对所有的Maturity_Date进行去重;

(2)在参数表Para中,添加一计算列寻找落入每一个区间中的Maturity_Date;

(3)以DB为左表,步骤(2)的结果为右表进行内连接

旧套路运行时间大约11.7秒,而对比之下以List.PositionOf()为主体的方案需时10.9秒。

总结下来,新发现如下:

(1)在Power Query中,一般在处理同一个问题时,以Table.开头的方案一般会比以List.开头的方案慢;

(2)在Power Query中,一般在处理同一个问题时,if方案会比其他等价的函数组合更快;

(3)在Power Query中, 自定义函数一般会比内建函数慢

附件

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注