本篇blog主要记录一下SentHive上面的聚合优化分析,以及一些模块化技巧

 

cluster_by_similarity_flexible优化



该函数用到了CPU以及GPU两个版本,在这个标题里面,我想分享一下的是我优化这个聚合算法的一个过程,以及最终的结果,还有未来可以拓展再优化的空间。

    首先先来说明一下这个算法的思路,来说一下他是输入什么东西,然后得到什么东西,内部是怎么做到的。
    输入:一个字典,{关键句-人数},这个字典是经过了简单聚合,意思就是如果我在一个大文本库中,有些句子一模一样,但是发言的用户不一样,就会把他们合一起,例如“我爱机器学习”: 5642(id),“我爱深度学习”:5643(id),“我爱机器学习”5644(id), 其中“我爱机器学习”有两个人说,所以这个字典是{“我爱机器学习”: 2,“我爱深度学习”: 1}.

    输出:类似如此,输入到该聚合算法的是一个简单聚合后的字典,但是我们想要更近一步地把一些相近的句子合并,这里就用到了 SentenceTransformer 加载的模型,使用他把句子转换为向量,然后使用util.cos_sim方法来获取句子间的相似度,根据他们的相似度聚合。最终输出更加“聚合”的字典,形式类似上面。

    内部黑盒:我是边于运行边写出的这个算法,所以最终的这个呈现的结果肯定没有我分享出这个过程的对比有代表性,先来说一下比较关心的时间复杂度:O(n^2),这个我自己还是有点不满意的,但是就根据他的核心逻辑:我需要把所有句子向量都和其他句子对比,然后根据他们的相似度合并,也就是一个句子要和N-1个句子对比,每个都要,也就是N(N-1),最终看起来也貌似还不错,所以优化的方案留到等下讲,先来说一下我报错/优化的历程。

    在最开始的时候,这个算法是这么做的:

    1.将字典转换成有序列表,然后提取句子,向量化,得到一个embedding_list,然后再初始化一个processed_words,这个用来记录已经聚类过的句子,初始化一个new_dic,用来输出。

    2.外层循环 i ,遍历每一个句子向量,如果他还没有被聚类,则放进字典new_dic,内层循环j, 开始相似度计算(两两配对),这里要遍历 i 后面的句子 j ,检查他是否已经处理,如果已经聚类放进了new_dic,则跳过,如果不是,则使用utils.cos_sim计算相似度,然后比对,绝对是否放进new_dic合并,然后标记是否聚类。
    3.最终输出。

这个是我一开始拿到的算法版本,不过说实话有点屎山,代码逻辑并不复杂,也是O(n^2),但是实际跑起来后,当我尝试了10w条数据时,发现:



    当时这个速度非常慢,跑了2,3个小时,才25%,最后想着一定得优化一下了。

    结果就出现了我优化后的第一版算法:

 



    1.句子向量化,将 N 条句子全部向量化为 Nxd 矩阵 (d是句子向量化后的维度)
    2.使用权量相似度矩阵,直接计算所有的 NxN 相似度
    3.一次性生成阈值矩阵
    4.根据全量相似度矩阵,以及阈值矩阵,生成邻居矩阵
    5.遍历聚类,遍历每个句子,检查是否聚合,找到所有为处理的相似句子,合并计数,标记已处理。

优化点:原来的代码嵌套循环,每次计算都是两个句子的余弦相似度,O(N^2*d)时间复杂度,并且重复计算,每个句子都要和后面的句子比,整个上三角全部计算一次,python层循环开销大,余弦相似度是单条计算,无法充分利用向量化。
    所以第一版优化思路是把所有句子向量一次性生成矩阵,然后用矩阵直接计算所有句子的余弦相似度,利用底层矩阵优化,然后再阈值处理上,把阈值条件广播成矩阵,直接和相似度矩阵做布尔比较,一次性完成,避免Python层循环.合并计数向量化时内部不用计算相似度,只做布尔索引和求和,Python循环的开销大幅降低,因为矩阵计算已经完成.。

    最终达到的效果是10w条数据聚合,几分钟就完成了。

    但是有一点导致这个算法不够好,也就是这里的sim_matrix生成逻辑,这个直接使用了全量矩阵embeddings,空间复杂度为O(n^2),在使用10w条数据时,直接导致内存爆炸,结果就是第二版优化:

    





    CPU 分支:每次计算 vec_i vs embeddings 得到 sim_row(长度N),这里是逐行计算,而不是一次性使用完全部的矩阵,一次性空间复杂度变为了O(n),并且这里的时间复杂度是O(n),然后通过广播生成阈值行 threshold_row,生成 neighbors_list邻接矩阵,后续聚合,总体空间复杂度是 O(n + total_neighbors),这里的total_neighbors一般为n*k,k为平均邻居。
        
    GPU 分支:分块计算 emb_i vs embeddings,然后逻辑和 CPU分支一致。空间复杂度为O(bxn + total_neighbors),其中 b 为 分块大小。

    此版优化兼顾空间和时间,算是一个不错的结果。其中CPU逐行操作是考虑到内存安全,然后GPU分块是再兼并了GPU本身的矩阵并行计算优势,他的核心数量非常多,一次性可以对大量数据执行矩阵/向量运算,所以这里采用了分块的逻辑。


    实验结果:(以下 _开头意为使用多少条数据,items意为实际聚类多少关键句)
        CPU _941: 668 items 6.20s
                _2959: 2078 items 8.35s
                _6528: 4663 items 13.19s
                _12460: 7641 items 18.86s
                _15466: 8170 items 20.93s
                _21749: 12598 items 28.15s
                _76769: 37752 items 86.54s
                _126322: 59240 items 152.09s
                _166639: 85198 items 255.32s
        
        GPU _941: 668 items 5.65s
                _2959: 2078 items 7.72s
                _6528: 4663 items 10.30s
                _12460: 7641 items 14.79s
                _15466: 8170 items 14.79s
                _21749: 12598 items 19.60s
                _76769: 37752 items 68.26s
                _126322: 59240 items 124.91s
                _166639: 85198 items 201.53s

未来优化的点

           
     本次优化其实不是算法复杂度层面的优化,就不是理论上的优化,理论上算法复杂度还是O(n^2),而这里做到的是实现优化,也就是工程优化:使用向量化+矩阵操作,减少Python层的循环,使用稀疏矩阵、block分块,减少空间压力。
     所以未来可以拓展的点就在于算法层面的优化,可以考虑设计别的算法,来尝试将时间复杂度降低到O(n^2)以下,但是这个操作可能会使得一些聚类失效,因为理论上观察这个算法,是要知道每条句子和其他句子的比较的,如果要比较所有句子和其他所有句子,那么这个时间不可避免的是O(n^2),所以要实现更高效的算法,应该在影响聚类最小的程度下,尽可能地优化时间。


模块化技巧

     在这个标题,分享的是解构整个项目的模块,以及参数透传的设计技巧。

     逻辑链:想要模块化一整个项目,最重要的是逻辑链,你必须明白你每一步在做什么,这是第一点,然后就是尽可能地解耦他,这样你在使用过程就会很方便,配置一个模块的参数,不会影响另外一个模块,同时当你后续要优化,也可以仅仅针对一个模块优化,比较简单。
        
     那么在本次项目里面,还是再说一次这个逻辑链:输入一个csv,里面是用户id,言论内容,我想知道特定情绪的内容大概是什么样子,给我一个总结。所以输入进去的 csv 经过模型标注模块,然后统计模块统计出情绪分布,计算独立个体,打印结果,然后经过聚类模块,合并相似句,然后经过 LLM 模块,输出总结。

    当你有了这么一条逻辑链后,你自然地就知道整个项目的目录应该怎么构建了。

    所以在实际操作编写的时候,如果你发现有部分模块重复了,那就想办法解耦他,解耦成其他的模块,举个例子,在实践中我发现聚类的模块当时顺便把 LLM 的调用也写上去了,导致看起来整个模块很冗杂,就不知道里面在聚类还是在总结,所以我把ai模块解耦出来,单独写了一个,核心的思想就是在于,一个模块干一件事情,多了你就可以适当解耦,这里的聚类里面也是有一点耦合,因为我在里面用了textrank,然后又聚类,相当于是两轮数据处理,应该是把他们解耦的,不过比较懒了,这里就当成同一件事情得了。

    于此同时,你会发现各个模块都可以用与之对应的yaml文件来管理参数,这样你就开始写yaml文件来配置不同的模块,这里设计时会有个透传机制,就是层层传入参数。当我在pipeline里面调用主函数,传入了一些参数,然后主函数里面在调用不同模块,这个时候你的参数得传到不同模块去,如果不同模块里面还有不同函数,那么你就要一层一层地传进去。这就导致了一个问题:参数多,难管理。并且你后续想要加逻辑,加参数的时候,自己得写很多层,有时候还不知道哪些地方要调用得到,容易漏参。这时候模块化的理论就适配到了yaml文件,你可以在yaml文件里面配置不同模块的参数,然后主函数里面,传各自的模块config,例如:labeler_config,analysis_config,或者你还可以更加解耦,直接传入config,在主函数里面自己解析配置,然后针对不同的模块,传入他们需要的config文件(labeler_confi,analysis_config),然后这样递归地传下去,核心就是,我传给你一个配置,你根据你的需要,解析成对应的配置,然后使用。这种逻辑更加符合人类的思维,在管理的时候也非常直观,我在修改逻辑,加入参数,很自然地就可以在对应的函数添加一个参数即可,因为传入的大字段里面已经写好了配置,就看我怎么使用而已。


    感谢阅读!

评论